diff --git a/example-apps/dashnote/public/favicon.svg b/example-apps/dashnote/public/favicon.svg new file mode 100644 index 0000000..00f248d --- /dev/null +++ b/example-apps/dashnote/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/example-apps/dashnote/src/App.tsx b/example-apps/dashnote/src/App.tsx index 2f167e2..14726b6 100644 --- a/example-apps/dashnote/src/App.tsx +++ b/example-apps/dashnote/src/App.tsx @@ -98,7 +98,9 @@ function App() { /> )} {tab === "how-it-works" && } - {tab === "settings" && } + {tab === "settings" && ( + setLoginOpen(true)} /> + )} diff --git a/example-apps/dashnote/src/components/AppShell.tsx b/example-apps/dashnote/src/components/AppShell.tsx index e1eee78..618e454 100644 --- a/example-apps/dashnote/src/components/AppShell.tsx +++ b/example-apps/dashnote/src/components/AppShell.tsx @@ -116,9 +116,9 @@ export function AppShell({ closeDrawer(); }} /> - {status !== "authenticated" && ( + {status !== "authenticated" && status !== "browsing" && ( { diff --git a/example-apps/dashnote/src/components/DeleteNoteModal.tsx b/example-apps/dashnote/src/components/DeleteNoteModal.tsx new file mode 100644 index 0000000..3f4fc80 --- /dev/null +++ b/example-apps/dashnote/src/components/DeleteNoteModal.tsx @@ -0,0 +1,55 @@ +import { Modal } from "./Modal"; + +export interface DeleteNoteModalProps { + open: boolean; + noteTitle: string; + deleting: boolean; + onCancel: () => void; + onConfirm: () => void; +} + +export function DeleteNoteModal({ + open, + noteTitle, + deleting, + onCancel, + onConfirm, +}: DeleteNoteModalProps) { + const trimmed = noteTitle.trim(); + const subject = trimmed ? `“${trimmed}”` : "this note"; + + return ( + { + if (!deleting) onCancel(); + }} + footer={ + <> + + + + } + > +

+ Permanently delete {subject} from Dash Platform? This can't be + undone. +

+
+ ); +} diff --git a/example-apps/dashnote/src/components/IdentityCard.tsx b/example-apps/dashnote/src/components/IdentityCard.tsx index eee938d..8833c9e 100644 --- a/example-apps/dashnote/src/components/IdentityCard.tsx +++ b/example-apps/dashnote/src/components/IdentityCard.tsx @@ -69,7 +69,7 @@ export function IdentityCard({ onClick={onLoginClick} className="w-full rounded-md bg-accent px-3 py-2 text-[12px] font-semibold text-bg transition hover:bg-accent-dim" > - Login + Sign in
- Switch identity + {isAuthed ? "Switch identity" : "Sign in"} {isAuthed && ( - )}
)} @@ -221,57 +203,38 @@ export function LoginModal({ open, onClose }: LoginModalProps) { Remember this identity on this device - + {!isWifInput && ( + <> + - {showAdvanced && ( -
- {!isWifInput && ( - + {showAdvanced && ( +
+ +
)} - -
- - Contract ID (optional) - - setContractInput(event.target.value)} - placeholder="Paste a Dashnote note contract ID to reuse" - className="rounded-md border border-line bg-bg px-3 py-2 font-mono text-[12px] text-ink outline-none transition focus:border-accent-dim" - /> - -
-
+ )} {error && ( @@ -291,7 +254,7 @@ export function LoginModal({ open, onClose }: LoginModalProps) { disabled={submitting || !secret.trim() || previewBlocksLogin} className="flex-1 rounded-md bg-accent px-4 py-2 text-[13px] font-semibold text-bg transition hover:bg-accent-dim disabled:cursor-not-allowed disabled:bg-surface-2 disabled:text-ink-4" > - {submitting ? "Connecting…" : "Login"} + {submitting ? "Connecting…" : "Sign in"} ); } diff --git a/example-apps/dashnote/test/DeleteNoteModal.test.tsx b/example-apps/dashnote/test/DeleteNoteModal.test.tsx new file mode 100644 index 0000000..f891399 --- /dev/null +++ b/example-apps/dashnote/test/DeleteNoteModal.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DeleteNoteModal } from "../src/components/DeleteNoteModal"; + +afterEach(() => { + cleanup(); +}); + +describe("DeleteNoteModal", () => { + it("renders nothing when open is false", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("shows the note title in the body when present", () => { + render( + , + ); + expect(screen.getByText(/groceries/i)).toBeTruthy(); + }); + + it("falls back to 'this note' when the title is blank", () => { + render( + , + ); + expect(screen.getByText(/this note/i)).toBeTruthy(); + }); + + it("fires onConfirm when Delete is clicked", () => { + const onConfirm = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /^delete$/i })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("fires onCancel when Cancel is clicked", () => { + const onCancel = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /^cancel$/i })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("disables both buttons while a delete is in flight", () => { + render( + , + ); + expect( + screen.getByRole("button", { name: /deleting…/i }).hasAttribute("disabled"), + ).toBe(true); + expect( + screen.getByRole("button", { name: /^cancel$/i }).hasAttribute("disabled"), + ).toBe(true); + }); + + it("does not fire onCancel when the disabled Cancel button is clicked mid-delete", () => { + const onCancel = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /^cancel$/i })); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("ignores Escape dismiss while deleting", () => { + const onCancel = vi.fn(); + render( + , + ); + // The shared Modal listens for Escape on window and calls onClose; + // DeleteNoteModal's onClose wrapper short-circuits while deleting. + fireEvent.keyDown(window, { key: "Escape" }); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("ignores backdrop dismiss while deleting", () => { + const onCancel = vi.fn(); + render( + , + ); + // The shared Modal forwards backdrop clicks through onClose. The + // wrapper guards onCancel when deleting=true so an accidental + // dismiss mid-delete doesn't desync the parent's state. + const dialog = screen.getByRole("dialog"); + fireEvent.click(dialog.parentElement!); + expect(onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/example-apps/dashnote/test/IdentityCard.test.tsx b/example-apps/dashnote/test/IdentityCard.test.tsx index e9d66eb..d658464 100644 --- a/example-apps/dashnote/test/IdentityCard.test.tsx +++ b/example-apps/dashnote/test/IdentityCard.test.tsx @@ -91,13 +91,13 @@ describe("IdentityCard", () => { expect(screen.getByText(TRUNCATED_ID)).toBeTruthy(); }); - it("hides the identity layout and shows a Login button when not connected", () => { + it("hides the identity layout and shows a Sign in button when not connected", () => { renderCard({ status: "idle", identityId: IDENTITY_ID, dpnsName: "alice", }); - expect(screen.getByRole("button", { name: /login/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /^sign in$/i })).toBeTruthy(); // The identity layout (name + truncated id) must not render in the // disconnected state, even when an id and name are passed in. expect(screen.queryByText("@alice")).toBeNull(); @@ -106,8 +106,8 @@ describe("IdentityCard", () => { // Regression: when the card was unified into a single menu trigger, readonly // (connected-but-not-signed-in) silently lost its one-click path to the - // login modal — the menu offered Settings and Switch identity but no direct - // Login. The card must call onLoginClick on click and render no menu. + // login modal — the menu offered Settings and Sign in but no direct Sign in + // affordance. The card must call onLoginClick on click and render no menu. it("calls onLoginClick on click when readonly, without opening a menu", () => { const onLoginClick = vi.fn(); renderCard({ @@ -159,6 +159,25 @@ describe("IdentityCard", () => { expect(onLoginClick).toHaveBeenCalled(); }); + // In browsing mode the user has no signed-in key to switch *from*, so the + // menu entry is labeled Sign in (not Switch identity). It still calls + // onLoginClick — only the affordance changes. + it("labels the login entry 'Sign in' in browsing mode and calls onLoginClick", () => { + const onLoginClick = vi.fn(); + renderCard({ + status: "browsing", + identityId: IDENTITY_ID, + dpnsName: null, + onLoginClick, + }); + fireEvent.click(screen.getByRole("button", { expanded: false })); + expect( + screen.queryByRole("menuitem", { name: /switch identity/i }), + ).toBeNull(); + fireEvent.click(screen.getByRole("menuitem", { name: /^sign in$/i })); + expect(onLoginClick).toHaveBeenCalled(); + }); + it("calls session.logout when Log out is chosen from the menu", () => { const logout = vi.fn(); mockUseSession.mockReturnValue({ logout }); diff --git a/example-apps/dashnote/test/LoginModal.test.tsx b/example-apps/dashnote/test/LoginModal.test.tsx index 87d3a3a..6faf75c 100644 --- a/example-apps/dashnote/test/LoginModal.test.tsx +++ b/example-apps/dashnote/test/LoginModal.test.tsx @@ -99,7 +99,7 @@ describe("LoginModal", () => { expect(onClose).not.toHaveBeenCalled(); expect(screen.getByPlaceholderText(/mnemonic phrase/i)).toBeTruthy(); - expect(screen.getByRole("button", { name: /^login$/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /^sign in$/i })).toBeTruthy(); }); it("submits the mnemonic via session.login and closes on success", async () => { @@ -112,7 +112,7 @@ describe("LoginModal", () => { fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { target: { value: "test mnemonic phrase" }, }); - fireEvent.click(screen.getByRole("button", { name: /^login$/i })); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); await waitFor(() => { expect(login).toHaveBeenCalledWith("test mnemonic phrase", { @@ -134,7 +134,7 @@ describe("LoginModal", () => { fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { target: { value: "garbage" }, }); - fireEvent.click(screen.getByRole("button", { name: /^login$/i })); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); expect(await screen.findByText("Bad mnemonic")).toBeTruthy(); }); @@ -145,11 +145,103 @@ describe("LoginModal", () => { render(); const button = screen.getByRole("button", { - name: /^login$/i, + name: /^sign in$/i, }) as HTMLButtonElement; expect(button.disabled).toBe(true); }); + it("keeps the login button disabled for a whitespace-only secret", () => { + mockUseSession.mockReturnValue(makeSession()); + + render(); + + fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { + target: { value: " \t " }, + }); + + const button = screen.getByRole("button", { + name: /^sign in$/i, + }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + }); + + it("disables the login button while a login is in flight", async () => { + // Resolve manually so we can observe the in-flight state. The label + // also flips to "Connecting…" while submitting is true. + let resolveLogin: (() => void) | undefined; + const login = vi.fn( + () => + new Promise((resolve) => { + resolveLogin = () => resolve(); + }), + ); + mockUseSession.mockReturnValue(makeSession({ login })); + + render(); + + fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { + target: { value: "phrase" }, + }); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); + + const connectingButton = (await screen.findByRole("button", { + name: /connecting/i, + })) as HTMLButtonElement; + expect(connectingButton.disabled).toBe(true); + + resolveLogin?.(); + await waitFor(() => { + expect(login).toHaveBeenCalled(); + }); + }); + + it("falls back to identityIndex=0 when the index field is non-numeric", async () => { + // Number.parseInt("abc", 10) is NaN; the handler must coerce that to 0 + // rather than passing NaN through to session.login. + const login = vi.fn().mockResolvedValue(undefined); + mockUseSession.mockReturnValue(makeSession({ login })); + + render(); + + fireEvent.click(screen.getByText(/advanced settings/i)); + fireEvent.change(screen.getByRole("spinbutton"), { + target: { value: "abc" }, + }); + fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { + target: { value: "phrase" }, + }); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); + + await waitFor(() => { + expect(login).toHaveBeenCalledWith("phrase", { + identityIndex: 0, + rememberMe: true, + }); + }); + }); + + it("preserves identityIndex and showAdvanced across modal reopens", () => { + // The open-effect resets secret/rememberMe/useDifferentIdentity/error, + // but NOT identityIndex or showAdvanced — verify that contract so a + // future refactor doesn't quietly change it. + mockUseSession.mockReturnValue(makeSession()); + + const { rerender } = render(); + + fireEvent.click(screen.getByText(/advanced settings/i)); + fireEvent.change(screen.getByRole("spinbutton"), { + target: { value: "7" }, + }); + + rerender(); + rerender(); + + // Disclosure is still open, value still 7. + expect((screen.getByRole("spinbutton") as HTMLInputElement).value).toBe( + "7", + ); + }); + it("uses identity index from advanced settings", async () => { const login = vi.fn().mockResolvedValue(undefined); mockUseSession.mockReturnValue(makeSession({ login })); @@ -163,7 +255,7 @@ describe("LoginModal", () => { fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { target: { value: "phrase" }, }); - fireEvent.click(screen.getByRole("button", { name: /^login$/i })); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); await waitFor(() => { expect(login).toHaveBeenCalledWith("phrase", { @@ -221,7 +313,7 @@ describe("LoginModal", () => { fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { target: { value: "phrase" }, }); - fireEvent.click(screen.getByRole("button", { name: /^login$/i })); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); await waitFor(() => { expect(login).toHaveBeenCalledWith("phrase", { @@ -245,7 +337,7 @@ describe("LoginModal", () => { fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { target: { value: "phrase" }, }); - fireEvent.click(screen.getByRole("button", { name: /^login$/i })); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); await waitFor(() => { expect(login).toHaveBeenCalledWith("phrase", { @@ -341,23 +433,6 @@ describe("LoginModal", () => { expect(checkbox.checked).toBe(true); }); - it("calls forgetIdentity when Forget this device is clicked from the login form", () => { - const forgetIdentity = vi.fn(); - mockUseSession.mockReturnValue( - makeSession({ - rememberedIdentityId: "remembered-identity-id", - forgetIdentity, - }), - ); - - render(); - - fireEvent.click( - screen.getByRole("button", { name: /forget this device/i }), - ); - expect(forgetIdentity).toHaveBeenCalled(); - }); - it("hides the remembered identity panel when no identity is remembered", () => { mockUseSession.mockReturnValue(makeSession()); @@ -395,7 +470,7 @@ describe("LoginModal", () => { screen.getByPlaceholderText(/mnemonic phrase/i) as HTMLInputElement, { target: { value: "fresh mnemonic" } }, ); - fireEvent.click(screen.getByRole("button", { name: /^login$/i })); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); await waitFor(() => { expect(login).toHaveBeenCalledWith("fresh mnemonic", { @@ -483,15 +558,17 @@ describe("LoginModal", () => { expect(screen.queryByRole("spinbutton")).not.toBeNull(); }); - it("hides the identity-index field when the input parses as a WIF", () => { + it("hides the Advanced settings disclosure when the input parses as a WIF", () => { + // WIF input has no DIP-13 derivation, so the only knob inside Advanced + // (Identity index) is irrelevant — the disclosure itself disappears. mockUseSession.mockReturnValue(makeSession()); render(); fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { target: { value: "cVHcfvcWNc7DvqaPCwM6Z3DqZ" }, }); - fireEvent.click(screen.getByText(/advanced settings/i)); + expect(screen.queryByText(/advanced settings/i)).toBeNull(); expect(screen.queryByRole("spinbutton")).toBeNull(); }); @@ -504,7 +581,7 @@ describe("LoginModal", () => { fireEvent.change(screen.getByPlaceholderText(/mnemonic phrase/i), { target: { value: "cVHcfvcWNc7DvqaPCwM6Z3DqZ" }, }); - fireEvent.click(screen.getByRole("button", { name: /^login$/i })); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); await waitFor(() => { expect(login).toHaveBeenCalledWith("cVHcfvcWNc7DvqaPCwM6Z3DqZ", { @@ -613,7 +690,7 @@ describe("LoginModal", () => { securityLevelName: "CRITICAL", }); const button = screen.getByRole("button", { - name: /^login$/i, + name: /^sign in$/i, }) as HTMLButtonElement; expect(button.disabled).toBe(true); }); @@ -625,7 +702,7 @@ describe("LoginModal", () => { dpnsName: null, }); const button = screen.getByRole("button", { - name: /^login$/i, + name: /^sign in$/i, }) as HTMLButtonElement; expect(button.disabled).toBe(true); }); @@ -635,7 +712,7 @@ describe("LoginModal", () => { // be able to hit Enter immediately after pasting. renderWithSecret({ status: "checking" }); const button = screen.getByRole("button", { - name: /^login$/i, + name: /^sign in$/i, }) as HTMLButtonElement; expect(button.disabled).toBe(false); }); diff --git a/example-apps/dashnote/test/NotesWorkspace.test.tsx b/example-apps/dashnote/test/NotesWorkspace.test.tsx index 9e7da1b..ec7f151 100644 --- a/example-apps/dashnote/test/NotesWorkspace.test.tsx +++ b/example-apps/dashnote/test/NotesWorkspace.test.tsx @@ -6,6 +6,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -113,7 +114,7 @@ describe("NotesWorkspace", () => { ); expect(screen.getByText(/sign in to see your notes/i)).toBeTruthy(); - const loginButton = screen.getByRole("button", { name: /^log in$/i }); + const loginButton = screen.getByRole("button", { name: /^sign in$/i }); fireEvent.click(loginButton); expect(onOpenLogin).toHaveBeenCalled(); expect(screen.queryByRole("button", { name: /new note/i })).toBeNull(); @@ -239,6 +240,10 @@ describe("NotesWorkspace", () => { }); fireEvent.click(screen.getByRole("button", { name: /^delete$/i })); + const confirmDialog = screen.getByRole("dialog"); + fireEvent.click( + within(confirmDialog).getByRole("button", { name: /^delete$/i }), + ); await waitFor(() => { expect(mockDeleteNote).toHaveBeenCalledWith( expect.objectContaining({ noteId: "note-2" }), @@ -664,7 +669,7 @@ describe("NotesWorkspace", () => { ).toBe("Edited offline"); }); - it("deletes a note via the bottom 'Delete note' button", async () => { + it("deletes a note via the bottom 'Delete note' button after confirming the modal", async () => { mockUseSession.mockReturnValue(makeSession()); mockListMyNotes.mockResolvedValue([noteFixture]); mockGetNote.mockResolvedValue(noteFixture); @@ -682,6 +687,14 @@ describe("NotesWorkspace", () => { fireEvent.click(screen.getByRole("button", { name: /delete note/i })); + // Trigger doesn't fire the delete directly anymore — the + // confirmation modal must be acknowledged first. + expect(mockDeleteNote).not.toHaveBeenCalled(); + const dialog = screen.getByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /^delete$/i }), + ); + await waitFor(() => { expect(mockDeleteNote).toHaveBeenCalledWith( expect.objectContaining({ noteId: "note-mobile" }), diff --git a/example-apps/dashnote/test/SettingsPanel.test.tsx b/example-apps/dashnote/test/SettingsPanel.test.tsx index a5a7414..a76c25f 100644 --- a/example-apps/dashnote/test/SettingsPanel.test.tsx +++ b/example-apps/dashnote/test/SettingsPanel.test.tsx @@ -81,7 +81,7 @@ describe("SettingsPanel", () => { mockUseSession.mockReturnValue( makeSession({ identityId: "id-abc", dpnsName: "alice" }), ); - render(); + render(); const block = screen.getByTestId("settings-identity-block"); expect(within(block).getByText("id-abc")).toBeTruthy(); expect(within(block).getByText("✓ alice.dash")).toBeTruthy(); @@ -91,29 +91,32 @@ describe("SettingsPanel", () => { mockUseSession.mockReturnValue( makeSession({ identityId: "id-abc", dpnsName: null }), ); - render(); + render(); const block = screen.getByTestId("settings-identity-block"); expect(within(block).queryByText(/\.dash$/)).toBeNull(); }); it("renders the network indicator as testnet", () => { mockUseSession.mockReturnValue(makeSession()); - render(); + render(); expect(screen.getByText("testnet")).toBeTruthy(); }); - it("shows an empty state when not signed in or browsing", () => { + it("shows an empty state with a Sign in button when not signed in or browsing", () => { mockUseSession.mockReturnValue( makeSession({ status: "readonly", identityId: null, keyManager: null }), ); - render(); + const onOpenLogin = vi.fn(); + render(); expect(screen.getByText(/sign in to view/i)).toBeTruthy(); expect(screen.queryByTestId("settings-identity-block")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /^sign in$/i })); + expect(onOpenLogin).toHaveBeenCalled(); }); it("hides the danger zone when nothing is remembered", () => { mockUseSession.mockReturnValue(makeSession({ rememberedIdentityId: null })); - render(); + render(); expect( screen.queryByRole("button", { name: /forget this device/i }), ).toBeNull(); @@ -127,7 +130,7 @@ describe("SettingsPanel", () => { forgetIdentity, }), ); - render(); + render(); fireEvent.click( screen.getByRole("button", { name: /forget this device/i }), ); @@ -141,7 +144,7 @@ describe("SettingsPanel", () => { value: { writeText }, }); mockUseSession.mockReturnValue(makeSession({ identityId: "id-xyz" })); - render(); + render(); fireEvent.click(screen.getByRole("button", { name: /copy identity id/i })); await waitFor(() => { expect(writeText).toHaveBeenCalledWith("id-xyz"); @@ -153,7 +156,7 @@ describe("SettingsPanel", () => { mockUseSession.mockReturnValue( makeSession({ contractId: "old", setContractId }), ); - render(); + render(); const input = screen.getByPlaceholderText(/paste a note contract id/i); fireEvent.change(input, { target: { value: " new-contract " } }); fireEvent.click(screen.getByRole("button", { name: /use this id/i })); @@ -169,7 +172,7 @@ describe("SettingsPanel", () => { mockUseSession.mockReturnValue( makeSession({ setContractId, sdk, keyManager, log }), ); - render(); + render(); fireEvent.click( screen.getByRole("button", { name: /register a fresh contract/i }), ); @@ -197,7 +200,7 @@ describe("SettingsPanel", () => { }), ); mockUseSession.mockReturnValue(makeSession({ setContractId: vi.fn() })); - render(); + render(); const button = screen.getByRole("button", { name: /register a fresh contract/i, }); @@ -214,7 +217,7 @@ describe("SettingsPanel", () => { const setContractId = vi.fn(); mockRegisterContract.mockRejectedValue(new Error("Network down")); mockUseSession.mockReturnValue(makeSession({ setContractId })); - render(); + render(); fireEvent.click( screen.getByRole("button", { name: /register a fresh contract/i }), ); @@ -224,7 +227,7 @@ describe("SettingsPanel", () => { it("invokes clearCachedNotes with the current identity ID", () => { mockUseSession.mockReturnValue(makeSession({ identityId: "id-cache" })); - render(); + render(); fireEvent.click( screen.getByRole("button", { name: /clear local cache for this device/i, diff --git a/example-apps/dashnote/test/e2e/auth.spec.ts b/example-apps/dashnote/test/e2e/auth.spec.ts index a510f14..649d54a 100644 --- a/example-apps/dashnote/test/e2e/auth.spec.ts +++ b/example-apps/dashnote/test/e2e/auth.spec.ts @@ -80,43 +80,6 @@ test("forget-this-device via the Settings panel drops back to readonly", async ( ).toBeHidden(); }); -test("forget-this-device via the LoginModal also clears the remembered identity", async ({ - page, -}) => { - await loginViaModal(page, { rememberMe: true }); - - // Log out so the modal can be opened with the rememberedIdentity panel - // visible. Logout-without-forget keeps the hint, dropping to browsing. - await openIdentityMenu(page); - await page.getByRole("menuitem", { name: /^log out$/i }).click(); - await expect( - page.getByText("Browsing (read-only)", { exact: true }), - ).toBeVisible({ timeout: 30_000 }); - - // Open the login modal — the "Forget this device" button is rendered - // beside "Use a different identity" when rememberedIdentityId is set. - // The IdentityCard in browsing state is a button with aria-haspopup="menu" - // (the "Switch identity" menuitem opens the login modal). - await openIdentityMenu(page); - await page.getByRole("menuitem", { name: /switch identity/i }).click(); - - const dialog = page.getByRole("dialog"); - await expect(dialog).toBeVisible(); - await dialog.getByRole("button", { name: /forget this device/i }).click(); - - // The form re-renders without the remembered panel. - await expect( - dialog.locator('[data-testid="remembered-identity-panel"]'), - ).toBeHidden(); - - // Close and reload — readonly state confirms the hint is gone. - await dialog.getByRole("button", { name: /^cancel$/i }).click(); - await page.reload(); - await expect( - page.getByText("Browsing (read-only)", { exact: true }), - ).toBeHidden(); -}); - test("Switch identity from the IdentityCard menu re-opens the login form", async ({ page, }) => { @@ -130,7 +93,9 @@ test("Switch identity from the IdentityCard menu re-opens the login form", async const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(dialog.getByPlaceholder(/mnemonic phrase|wif/i)).toBeVisible(); - await expect(dialog.getByRole("button", { name: /^Login$/ })).toBeDisabled(); + await expect( + dialog.getByRole("button", { name: /^Sign in$/ }), + ).toBeDisabled(); }); test("Settings tab is reachable from both the IdentityCard menu and the sidebar NavButton", async ({ diff --git a/example-apps/dashnote/test/e2e/fixtures.ts b/example-apps/dashnote/test/e2e/fixtures.ts index 74c725f..324dc92 100644 --- a/example-apps/dashnote/test/e2e/fixtures.ts +++ b/example-apps/dashnote/test/e2e/fixtures.ts @@ -52,7 +52,7 @@ function isMobile(page: Page): boolean { } /** - * Resolve a sidebar nav button (Notes / How it works / Login) by label. + * Resolve a sidebar nav button (Notes / How it works / Sign in) by label. * * On mobile the sidebar is off-canvas; this helper opens the hamburger * drawer first so the button is in the visible viewport. @@ -74,9 +74,11 @@ export async function navButton(page: Page, label: RegExp | string) { } /** - * Open the IdentityCard menu (the popover with Settings / Switch identity - * / Log out items). Only available when the session is `authenticated` or - * `browsing` — caller is responsible for being in that state. + * Open the IdentityCard menu (the popover with Settings, Switch identity + * or Sign in, and Log out items). Only available when the session is + * `authenticated` or `browsing` — caller is responsible for being in that + * state. The middle entry reads "Switch identity" when authenticated and + * "Sign in" when browsing read-only. * * On mobile the sidebar is off-canvas; opens the drawer transparently * via the hamburger first. @@ -102,9 +104,14 @@ export async function openIdentityMenu(page: Page) { } /** - * Open the LoginModal via the sidebar "Login" nav button, fill the - * mnemonic from PLATFORM_MNEMONIC, submit, and wait for the - * IdentityCard to report `Authenticated`. + * Open the LoginModal, fill the mnemonic from PLATFORM_MNEMONIC, submit, + * and wait for the IdentityCard to report `Authenticated`. + * + * The entry point depends on session state: in `idle/connecting/readonly` + * the sidebar exposes a "Sign in" NavButton; in `browsing` (remembered + * identity after reload) the sidebar entry is hidden and the modal is + * reached via the IdentityCard menu instead. We detect which surface + * exists and use whichever one is visible. * * Defaults to `rememberMe: false` (the modal's default is true) so tests * start from a clean localStorage; opt in explicitly when exercising the @@ -121,7 +128,17 @@ export async function loginViaModal( throw new Error("PLATFORM_MNEMONIC is required for loginViaModal"); } - await (await navButton(page, /login$/i)).click(); + const browsing = await page + .getByText("Browsing (read-only)", { exact: true }) + .isVisible() + .catch(() => false); + + if (browsing) { + await openIdentityMenu(page); + await page.getByRole("menuitem", { name: /^sign in$/i }).click(); + } else { + await (await navButton(page, /sign in$/i)).click(); + } const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); @@ -132,7 +149,7 @@ export async function loginViaModal( if (!rememberMe) { await rememberCheckbox.uncheck(); } - await dialog.getByRole("button", { name: /^Login$/ }).click(); + await dialog.getByRole("button", { name: /^Sign in$/ }).click(); await expect(dialog).toBeHidden({ timeout: 60_000 }); await expect(page.getByText("Authenticated", { exact: true })).toBeVisible({ @@ -251,12 +268,6 @@ export async function deleteNoteByTitle(page: Page, title: string) { timeout: 30_000, }); - // window.confirm("Delete this note permanently?") — auto-accept. - const dialogHandler = (dialog: import("@playwright/test").Dialog) => { - void dialog.accept(); - }; - page.once("dialog", dialogHandler); - // Desktop renders the "Delete" button in the editor header; mobile // renders "Delete note" near the bottom. Match either label. await page @@ -264,6 +275,12 @@ export async function deleteNoteByTitle(page: Page, title: string) { .first() .click(); + // DeleteNoteModal opens for confirmation; scope to its dialog so we + // don't accidentally re-match the editor's Delete trigger. + const confirmDialog = page.getByRole("dialog"); + await expect(confirmDialog).toBeVisible(); + await confirmDialog.getByRole("button", { name: /^delete$/i }).click(); + // Item leaves the list after reloadNotes resolves. await expect(page.locator("button", { hasText: title })).toHaveCount(0, { timeout: 60_000, diff --git a/example-apps/dashnote/test/e2e/smoke.spec.ts b/example-apps/dashnote/test/e2e/smoke.spec.ts index 3add610..cee777a 100644 --- a/example-apps/dashnote/test/e2e/smoke.spec.ts +++ b/example-apps/dashnote/test/e2e/smoke.spec.ts @@ -61,15 +61,15 @@ test.describe("theme toggle", () => { }); test.describe("login modal", () => { - test("sidebar Login button opens the modal", async ({ page }) => { - await (await navButton(page, /login$/i)).click(); + test("sidebar Sign in button opens the modal", async ({ page }) => { + await (await navButton(page, /sign in$/i)).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(dialog.getByPlaceholder(/mnemonic phrase|wif/i)).toBeVisible(); }); test("Cancel button closes the modal", async ({ page }) => { - await (await navButton(page, /login$/i)).click(); + await (await navButton(page, /sign in$/i)).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: /^cancel$/i }).click(); @@ -77,19 +77,19 @@ test.describe("login modal", () => { }); test("Escape closes the modal", async ({ page }) => { - await (await navButton(page, /login$/i)).click(); + await (await navButton(page, /sign in$/i)).click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await page.keyboard.press("Escape"); await expect(dialog).toBeHidden(); }); - test("Login button is disabled until a secret is entered", async ({ + test("Sign in button is disabled until a secret is entered", async ({ page, }) => { - await (await navButton(page, /login$/i)).click(); + await (await navButton(page, /sign in$/i)).click(); const dialog = page.getByRole("dialog"); - const submit = dialog.getByRole("button", { name: /^Login$/ }); + const submit = dialog.getByRole("button", { name: /^Sign in$/ }); await expect(submit).toBeDisabled(); await dialog.getByPlaceholder(/mnemonic phrase|wif/i).fill("abandon"); await expect(submit).toBeEnabled(); @@ -98,7 +98,7 @@ test.describe("login modal", () => { test("Advanced settings reveals the identity-index field for mnemonic input", async ({ page, }) => { - await (await navButton(page, /login$/i)).click(); + await (await navButton(page, /sign in$/i)).click(); const dialog = page.getByRole("dialog"); // Type something with whitespace so detectSecretShape() picks "mnemonic" // and the identity-index field is rendered inside Advanced. @@ -108,14 +108,27 @@ test.describe("login modal", () => { await expect(dialog.locator('input[type="number"]')).toBeVisible(); }); - test("Advanced settings exposes a contract-ID field", async ({ page }) => { - await (await navButton(page, /login$/i)).click(); + test("Advanced settings disclosure is hidden when input parses as a WIF", async ({ + page, + }) => { + // WIF input has no DIP-13 derivation, so identity-index is irrelevant + // and the whole Advanced disclosure should disappear. + await (await navButton(page, /sign in$/i)).click(); const dialog = page.getByRole("dialog"); - await dialog.getByRole("button", { name: /advanced settings/i }).click(); - await expect(dialog.getByText(/contract id/i).first()).toBeVisible(); + + // Mnemonic-shaped input first → disclosure renders. + await dialog.getByPlaceholder(/mnemonic phrase|wif/i).fill("word word"); await expect( - dialog.getByPlaceholder(/dashnote note contract id/i), + dialog.getByRole("button", { name: /advanced settings/i }), ).toBeVisible(); + + // Switch to WIF-shaped input → disclosure disappears. + await dialog + .getByPlaceholder(/mnemonic phrase|wif/i) + .fill("cVHcfvcWNc7DvqaPCwM6Z3DqZ"); + await expect( + dialog.getByRole("button", { name: /advanced settings/i }), + ).toBeHidden(); }); });