// @vitest-environment jsdom import type { JSX } from "solid-js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ImportParseResult } from "../../shared/import"; import type { VaultEntry } from "../../shared/types"; import { WORD_LIST } from "../../shared/recovery-word-list"; import { LockView } from "./Lock"; import { ExportView } from "./Export"; import { SettingsView } from "./Settings"; import { DEFAULT_BINDINGS } from "../../shared/shortcuts"; import { EntryView } from "./Entry"; import { ImportView } from "./Import"; import { AddView } from "./Add"; import { NotesEditor } from "./NotesEditor"; import { RecoveryWordEntryView } from "./RecoveryWordEntry"; import { RecoveryWordDisplayView } from "./RecoveryWordDisplay"; import { VaultView } from "./Vault"; import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils"; const sendMessageMock = vi.fn<(msg: unknown) => Promise>(); const triggerRingMock = vi.fn(); const triggerPipPulseMock = vi.fn(); const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise>(); const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 })); vi.mock("../App", () => ({ sendMessage: (msg: unknown) => sendMessageMock(msg), triggerRing: () => triggerRingMock(), triggerPipPulse: () => triggerPipPulseMock(), })); vi.mock("../../shared/bridge", () => ({ sendMessage: (msg: unknown) => sendMessageMock(msg), })); vi.mock("../../shared/import", () => ({ parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args), })); vi.mock("../../shared/favicon", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, faviconUrl: () => null }; }); vi.mock("../../crypto/totp", () => ({ generateTOTP: () => ({ code: "123 456", remaining: 24 }), parseOtpauthURI: (value: string) => value.startsWith("otpauth://") ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const } : null, })); vi.mock("../components/GeneratorDrawer", () => ({ GeneratorDrawer: () => null, })); vi.mock("../components/RuneBackground", () => ({ RuneBackground: () => null, })); vi.mock("@zxcvbn-ts/core", () => ({ zxcvbn: (value: string) => zxcvbnMock(value), zxcvbnOptions: { setOptions: vi.fn() }, })); vi.mock("@zxcvbn-ts/language-common", () => ({ adjacencyGraphs: {}, dictionary: {}, })); vi.mock("@zxcvbn-ts/language-en", () => ({ translations: {}, dictionary: {}, })); describe("popup contract tests", () => { const disposers: Array<() => void> = []; beforeEach(() => { document.body.innerHTML = ""; sendMessageMock.mockReset(); triggerRingMock.mockReset(); triggerPipPulseMock.mockReset(); parseImportSourceMock.mockReset(); zxcvbnMock.mockReset(); zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 })); parseImportSourceMock.mockResolvedValue({ format: "unknown", label: "Unknown format", entries: [], totalItems: 0, skippedItems: 0, }); vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { return window.setTimeout(() => cb(0), 0) as unknown as number; }); vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id)); vi.stubGlobal("URL", { createObjectURL: vi.fn(() => "blob:peach-test"), revokeObjectURL: vi.fn(), }); vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); (globalThis as { chrome?: typeof chrome }).chrome = { runtime: { getManifest: () => ({ version: "1.4.47" }), onMessage: { addListener: vi.fn() }, }, } as unknown as typeof chrome; }); afterEach(() => { while (disposers.length > 0) { disposers.pop()?.(); } vi.restoreAllMocks(); vi.unstubAllGlobals(); }); function mount(view: JSX.Element | (() => JSX.Element)) { const mounted = renderView(view); disposers.push(mounted.dispose); return mounted.container; } function sampleEntry(overrides: Partial = {}): VaultEntry { return { id: "entry-1", type: "login", title: "GitHub", username: "june", password: "secret123", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP", canary: { webhookUrl: "https://example.com/hook", }, createdAt: 1, modifiedAt: 1, ...overrides, }; } function sampleImportResult( overrides: Partial = {} ): ImportParseResult { return { format: "csv", label: "CSV import", entries: [ { type: "login", title: "Imported site", username: "imported-user", password: "imported-pass", url: "https://imported.test", }, ], totalItems: 1, skippedItems: 0, ...overrides, }; } function settingsProps(overrides: Partial[0]> = {}) { return { entriesCount: 1, breachMonitoring: true, lockOnClose: true, autoLockMinutes: 5, trashRetentionDays: 30, theme: "dark" as const, accentColor: "peach" as const, textSize: "medium" as const, reduceMotion: false, onBreachMonitoringChange: vi.fn(), onLockOnCloseChange: vi.fn(), onAutoLockMinutesChange: vi.fn(), onTrashRetentionChange: vi.fn(), onThemeChange: vi.fn(), onAccentChange: vi.fn(), onTextSizeChange: vi.fn(), onReduceMotionChange: vi.fn(), shortcuts: DEFAULT_BINDINGS, onShortcutsChange: vi.fn(), onBack: vi.fn(), onImport: vi.fn(), onExport: vi.fn(), onBreachCheckDone: vi.fn(), ...overrides, }; } async function selectImportFile( container: HTMLElement, contents = '{"export":"test"}' ) { // The Import view now opens onto a menu — click into the file subview // before locating the file input. Idempotent: a second click won't re-fire // because the menu unmounts once the subview renders. const fileCard = findButton(container, "Choose file →"); if (fileCard) { fileCard.click(); await flushView(); } const input = container.querySelector('input[type="file"]') as HTMLInputElement; const file = new File([contents], "import.json", { type: "application/json" }); Object.defineProperty(input, "files", { configurable: true, value: [file], }); input.dispatchEvent(new Event("change", { bubbles: true })); await flushView(); await flushView(); } function findButton(container: HTMLElement, text: string): HTMLButtonElement { return Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === text ) as HTMLButtonElement; } it("Lock sends UNLOCK with the entered passphrase", async () => { sendMessageMock.mockResolvedValue({ ok: true }); const onUnlock = vi.fn(async () => undefined); const container = mount(); const password = container.querySelector("#unlock-password") as HTMLInputElement; inputText(password, "correct horse battery staple"); (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "UNLOCK", payload: { password: "correct horse battery staple" }, }); expect(onUnlock).toHaveBeenCalled(); }); it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => { sendMessageMock.mockImplementation(async (msg: unknown) => { const type = (msg as { type: string }).type; if (type === "GET_PIN_STATUS") { return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } }; } return type === "GET_SETTINGS" ? { ok: true, data: { pinEnabled: true, pinPreferred: true } } : { ok: true }; }); const onUnlock = vi.fn(async () => undefined); const container = mount(); await flushView(); inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234"); (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" }, }); expect(onUnlock).toHaveBeenCalled(); expect(container.textContent).toContain("use master password"); }); it("places the PIN method switch below the unlock button", async () => { sendMessageMock.mockImplementation(async (msg: unknown) => { const type = (msg as { type: string }).type; if (type === "GET_PIN_STATUS") { return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } }; } return type === "GET_SETTINGS" ? { ok: true, data: { pinEnabled: true, pinPreferred: false } } : { ok: true }; }); const container = mount(); await flushView(); const buttons = Array.from(container.querySelectorAll("button")); const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock"); const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN"); expect(pinIndex).toBeGreaterThan(unlockIndex); }); it("keeps standalone note creation in Notes mode", async () => { const chromeApi = globalThis.chrome as unknown as { tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void }; }; chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) }; const addContainer = mount(); await flushView(); expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim())) .toEqual(["login", "card"]); expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull(); }); it("gives Notes mode the full note-formatting toolbar", async () => { const container = mount( ); await flushView(); expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy(); expect(container.querySelector('[title="Divider"]')).toBeTruthy(); expect(container.querySelector('[title="Quote"]')).toBeTruthy(); expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9); expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9); expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes."); }); it("saves formatting applied by the Notes toolbar before leaving", async () => { const savedNote = { id: "note-1", type: "note" as const, title: "Untitled", notes: "formatted", createdAt: 1, modifiedAt: 2, }; sendMessageMock.mockResolvedValue({ ok: true, data: savedNote }); const originalExecCommand = document.execCommand; const execCommandMock = vi.fn(() => { const body = document.querySelector(".peach-note-body-field"); if (body) body.innerHTML = "formatted"; return true; }); Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock }); try { const container = mount( ); await flushView(); container.querySelector('[title="Bold"]')?.dispatchEvent( new MouseEvent("mousedown", { bubbles: true, cancelable: true }) ); expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined); container.querySelector('[aria-label="Back to notes list"]')?.click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "SAVE_NOTE", payload: { id: undefined, title: "Untitled", body: "formatted" }, }); } finally { Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand }); } }); it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => { const container = mount(); const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement; const password = container.querySelector("#master-password") as HTMLInputElement; const confirm = container.querySelector("#confirm-password") as HTMLInputElement; inputText(password, "short"); inputText(confirm, "short"); await flushView(); expect(createButton.disabled).toBe(true); zxcvbnMock.mockImplementation(() => ({ score: 1 })); inputText(password, "weakweakweak"); inputText(confirm, "weakweakweak"); await flushView(); expect(createButton.disabled).toBe(true); }); it("Lock create-vault header pins the wordmark left with back on the right", async () => { const container = mount( {}} /> ); const header = container.querySelector(".peach-newvault-header") as HTMLElement; const wordmark = header.querySelector(".peach-wordmark-stack"); const back = header.querySelector(".peach-newvault-back"); expect(wordmark).toBeTruthy(); expect(back).toBeTruthy(); // Wordmark first so it never shifts between onboarding screens; back // takes the opposite corner instead of displacing it. expect(header.firstElementChild).toBe(wordmark); expect(header.lastElementChild).toBe(back); await flushView(); }); it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => { // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock sendMessageMock.mockImplementation( async (msg: unknown) => { const m = msg as { type: string; payload?: { password?: string } }; if (m.type === "CREATE_VAULT") return { ok: true }; if (m.type === "GET_RECOVERY_WORDS") { // Return 12 fake words — must pass findInvalidWords return { ok: true, data: [ "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", ], }; } return { ok: true }; } ); zxcvbnMock.mockImplementation(() => ({ score: 3 })); const onUnlock = vi.fn(async () => undefined); const container = mount(); const password = container.querySelector("#master-password") as HTMLInputElement; const confirm = container.querySelector("#confirm-password") as HTMLInputElement; const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement; inputText(password, "correct horse battery staple"); inputText(confirm, "correct horse battery staple"); await flushView(); expect(createButton.disabled).toBe(false); pressEnter(confirm); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "CREATE_VAULT", payload: { password: "correct horse battery staple" }, }); // After CREATE_VAULT success, the recovery word display is shown. // onUnlock is NOT called yet — user must first confirm their recovery words. expect(onUnlock).not.toHaveBeenCalled(); }); it("recovery display asks for three distinct randomized word positions and confirms", async () => { const words12 = [ "abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", ]; sendMessageMock.mockImplementation(async (msg: unknown) => { const type = (msg as { type?: string }).type; if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 }; return { ok: true }; }); vi.useFakeTimers(); const onDone = vi.fn(); try { const container = mount(); // onMount fires GET_RECOVERY_WORDS; let the resolved promise + render settle await vi.advanceTimersByTimeAsync(0); const prompts = Array.from( container.querySelectorAll(".peach-recovery-confirm-num") ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10)); expect(prompts).toHaveLength(3); expect(new Set(prompts).size).toBe(3); for (const p of prompts) { expect(p).toBeGreaterThanOrEqual(1); expect(p).toBeLessThanOrEqual(12); } // Inputs stay disabled until the 5s countdown expires const inputs = Array.from( container.querySelectorAll(".peach-recovery-confirm-input") ) as HTMLInputElement[]; expect(inputs).toHaveLength(3); expect(inputs[0]!.disabled).toBe(true); await vi.advanceTimersByTimeAsync(5000); expect(inputs[0]!.disabled).toBe(false); prompts.forEach((pos, i) => { inputText(inputs[i]!, words12[pos - 1]!); }); const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement; expect(confirmBtn.disabled).toBe(false); confirmBtn.click(); await vi.advanceTimersByTimeAsync(700); // success pause before onDone expect(onDone).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); it("Recovery words flow submits the new master password and completes", async () => { sendMessageMock.mockImplementation(async (msg: unknown) => { const type = (msg as { type?: string }).type; if (type === "VALIDATE_RECOVERY_WORDS") { return { ok: true, data: { valid: true, invalid: [] } }; } if (type === "RECOVER_WITH_WORDS") return { ok: true }; return { ok: true }; }); const canvasContext = { clearRect: vi.fn(), fillText: vi.fn(), beginPath: vi.fn(), closePath: vi.fn(), arc: vi.fn(), fill: vi.fn(), stroke: vi.fn(), save: vi.fn(), restore: vi.fn(), translate: vi.fn(), rotate: vi.fn(), scale: vi.fn(), drawImage: vi.fn(), measureText: vi.fn(() => ({ width: 0 })), setTransform: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(), } as unknown as CanvasRenderingContext2D; vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext); const onRecoveryComplete = vi.fn(async () => undefined); const container = mount( ); const recoveryWords = WORD_LIST.slice(0, 12); for (let i = 0; i < recoveryWords.length; i++) { const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement; inputText(input, recoveryWords[i]!); } await flushView(); findButton(container, "verify words").click(); await flushView(); expect(container.textContent).toContain("new master password"); const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement; const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement; inputText(newPassword, "fresh-recovery-password"); inputText(confirmPassword, "fresh-recovery-password"); await flushView(); findButton(container, "recover vault").click(); await flushView(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "RECOVER_WITH_WORDS", payload: { words: recoveryWords, newPassword: "fresh-recovery-password", }, }); expect(onRecoveryComplete).toHaveBeenCalled(); }); it("Export menu surfaces both backup formats", async () => { const container = mount(); await flushView(); expect(container.textContent).toContain("PeachScript"); expect(container.textContent).toContain("CSV"); }); it("Lock can open directly on backup restore from onboarding", async () => { const container = mount(() => ( )); await flushView(); expect(container.textContent).toContain("PeachScript"); expect(container.textContent).toContain("Codex"); expect(container.textContent).toContain("CSV"); expect(container.querySelector("#master-password")).toBeNull(); }); it("Lock restore back returns to onboarding, not the create-vault screen", async () => { const onBackToOnboarding = vi.fn(); const container = mount(() => ( )); await flushView(); (container.querySelector(".peach-restore-back") as HTMLButtonElement).click(); await flushView(); expect(onBackToOnboarding).toHaveBeenCalledTimes(1); }); it("Lock restore resumes on the picked PeachScript source, not the picker", async () => { const container = mount(() => ( )); await flushView(); expect(container.querySelector(".peach-restore-script")).toBeTruthy(); expect(container.querySelector("#restore-backup-pass")).toBeTruthy(); expect(container.querySelector(".peach-restore-sources")).toBeNull(); }); it("Lock restore resumes on the picked CSV source, not the picker", async () => { const container = mount(() => ( )); await flushView(); expect(container.querySelector("#restore-csv-pass")).toBeTruthy(); expect(container.querySelector("#restore-csv-confirm")).toBeTruthy(); expect(container.querySelector(".peach-restore-sources")).toBeNull(); }); it("Lock restore PeachScript form loads a dropped .peach file", async () => { const container = mount(() => ( )); await flushView(); const form = container.querySelector(".peach-restore-form") as HTMLElement; expect(form).toBeTruthy(); // The OS file picker dismisses the action popup, so drag-and-drop is the // file path that survives; a drop must land the file in the script field. const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" }); const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & { dataTransfer?: { files: File[] }; }; drop.dataTransfer = { files: [file] }; form.dispatchEvent(drop); await flushView(); const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement; expect(script.value).toBe("dropped-peach-backup"); }); it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => { const device = { deviceId: "phone-1", deviceName: "June's Phone", deviceType: "android" as const, longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: "b".repeat(64), pairedAt: 1, trustSource: "direct" as const, }; sendMessageMock.mockImplementation(async (msg: unknown) => { const m = msg as { type: string }; if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } }; return { ok: true, data: null }; }); const container = mount(); for (let i = 0; i < 30; i++) await Promise.resolve(); const openSync = Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""), ); openSync?.click(); for (let i = 0; i < 30; i++) await Promise.resolve(); const remove = Array.from(container.querySelectorAll("button")).find( (b) => (b.textContent ?? "").trim() === "remove", ); expect(remove).toBeTruthy(); // Guard against a vacuous pass: the disclosure must not already be on // screen before the user asks to remove anything. expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull(); remove!.click(); for (let i = 0; i < 10; i++) await Promise.resolve(); // Revocation must never be presented as securing the vault against the // removed device — recovery words survive it and restore everything. const note = container.querySelector(".peach-unpair-confirm-note"); expect(note).toBeTruthy(); const text = note!.textContent ?? ""; expect(text).toContain("master password"); expect(text).toContain("recovery words"); expect(text).toContain("future changes"); }); it("PB-26-709: pending device approval identifies the key, time, provenance, and vault scope", async () => { sendMessageMock.mockImplementation(async (msg: unknown) => { const m = msg as { type: string }; if (m.type === "GET_PENDING_VOUCHED_DEVICES") { return { ok: true, data: [{ deviceId: "phone-source-id", deviceName: "June's Phone", deviceType: "android", longTermPublicKeyHex: "a1b2c3d4e5f6" + "0".repeat(52), signingPublicKeyHex: "b".repeat(64), pairedAt: Date.UTC(2026, 8, 5), receivedAt: Date.UTC(2026, 8, 6, 20, 15), vouchedByDeviceId: "trusted-browser-id", }], }; } if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [] } }; return { ok: true, data: null }; }); const container = mount(); for (let i = 0; i < 30; i++) await Promise.resolve(); Array.from(container.querySelectorAll("button")) .find((button) => /sync/i.test(button.textContent ?? "")) ?.click(); for (let i = 0; i < 30; i++) await Promise.resolve(); expect(container.textContent).toContain("a1b2 c3d4 e5f6"); expect(container.textContent).toContain("trusted-browser-id"); expect(container.textContent).toContain("2026-09-06"); expect(container.textContent).toContain("existing vault contents"); }); it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => { const device = { deviceId: "phone-1", deviceName: "June's Phone", deviceType: "android" as const, longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: "b".repeat(64), pairedAt: 1, trustSource: "direct" as const, }; let paired: typeof device[] = [device]; sendMessageMock.mockImplementation(async (msg: any) => { if (msg?.type === "LIST_PAIRED_DEVICES") { return { ok: true, data: { devices: [...paired] } }; } if (msg?.type === "UNPAIR_DEVICE") { // Mirror the real handler: UNPAIR_DEVICE drops the row from the // registry. The next LIST_PAIRED_DEVICES must not see it. paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId); return { ok: true }; } if (msg?.type === "RENAME_PAIRED_DEVICE") { paired = paired.map((d) => d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d, ); return { ok: true }; } return { ok: true, data: null }; }); const container = mount(); for (let i = 0; i < 30; i++) await Promise.resolve(); Array.from(container.querySelectorAll("button")) .find((b) => /sync/i.test(b.textContent ?? "")) ?.click(); for (let i = 0; i < 30; i++) await Promise.resolve(); // The list itself: extracting the relay selector must not take the hub with it. expect(container.textContent).toContain("PAIRED DEVICES"); expect(container.textContent).toContain("June's Phone"); expect(container.textContent).toContain("Android"); Array.from(container.querySelectorAll("button")) .find((b) => (b.textContent ?? "").trim() === "rename")! .click(); const renameInput = container.querySelector('input[aria-label="Rename June\'s Phone"]')!; renameInput.value = "Travel phone"; renameInput.dispatchEvent(new InputEvent("input", { bubbles: true })); Array.from(container.querySelectorAll("button")) .find((b) => (b.textContent ?? "").trim() === "save")! .click(); for (let i = 0; i < 30; i++) await Promise.resolve(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "RENAME_PAIRED_DEVICE", payload: { deviceId: "phone-1", name: "Travel phone" }, }); expect(container.textContent).toContain("Travel phone"); const syncNowButton = container.querySelector( 'button[aria-label="Sync this vault now"]', )!; const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label")); const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!; const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!; expect( syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); expect( syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); Array.from(container.querySelectorAll("button")) .find((b) => (b.textContent ?? "").trim() === "remove")! .click(); for (let i = 0; i < 10; i++) await Promise.resolve(); const confirm = Array.from( container.querySelectorAll(".peach-unpair-confirm button"), ).find((b) => (b.textContent ?? "").trim() === "remove"); confirm!.click(); for (let i = 0; i < 30; i++) await Promise.resolve(); expect(sendMessageMock).toHaveBeenCalledWith({ type: "UNPAIR_DEVICE", payload: { deviceId: "phone-1" }, }); // The list must be refreshed and the device must be gone. The regression // we're guarding against: a Settings view that calls UNPAIR_DEVICE but // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI. expect(container.textContent).not.toContain("June's Phone"); expect(container.textContent).not.toContain("PAIRED DEVICES"); }); it("Settings keeps the confirmation and shows revoke failures", async () => { const device = { deviceId: "phone-1", deviceName: "June's Phone", deviceType: "android" as const, longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: "b".repeat(64), pairedAt: 1, trustSource: "direct" as const, }; sendMessageMock.mockImplementation(async (msg: unknown) => { const message = msg as { type?: string; payload?: { deviceId?: string } }; if (message.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } }; if (message.type === "UNPAIR_DEVICE") return { ok: false, error: "trust_data_corrupted" }; return { ok: true, data: null }; }); const container = mount(); for (let i = 0; i < 30; i++) await Promise.resolve(); for (let i = 0; i < 30; i++) await Promise.resolve(); Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click(); for (let i = 0; i < 30; i++) await Promise.resolve(); Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click(); for (let i = 0; i < 10; i++) await Promise.resolve(); Array.from(container.querySelectorAll(".peach-unpair-confirm button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click(); for (let i = 0; i < 30; i++) await Promise.resolve(); expect(container.textContent).toContain("trust_data_corrupted"); expect(container.textContent).toContain("June's Phone"); }); it("Settings keeps connection and recovery details collapsed until requested", async () => { sendMessageMock.mockImplementation(async (msg: any) => { if (msg?.type === "LIST_PAIRED_DEVICES") { return { ok: true, data: { devices: [] } }; } return { ok: true, data: null }; }); const container = mount(); for (let i = 0; i < 30; i++) await Promise.resolve(); Array.from(container.querySelectorAll("button")) .find((button) => /sync/i.test(button.textContent ?? "")) ?.click(); for (let i = 0; i < 20; i++) await Promise.resolve(); expect(container.textContent).toContain("SYNC STATUS"); expect(container.textContent).toContain("ADD A DEVICE"); expect(container.textContent).not.toContain("Public relay"); expect(container.textContent).not.toContain("Rotate sync identity"); const connection = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("CONNECTION")); connection?.click(); await Promise.resolve(); expect(container.textContent).toContain("Public relay"); const recovery = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("RECOVERY & SECURITY")); recovery?.click(); await Promise.resolve(); expect(container.textContent).toContain("Rotate sync identity"); }); it("Settings requires current, new, and confirm passwords before enabling save", async () => { sendMessageMock.mockResolvedValue({ ok: true, data: null }); const container = mount( ); await flushView(); expect(container.querySelectorAll(".stk-card").length).toBe(7); // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67] // "open settings to card I (Peach Pro) instead of card II (Protection)". expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro"); const changeButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "change" ) as HTMLButtonElement; changeButton.click(); await flushView(); const saveButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "save new password" ) as HTMLButtonElement; expect(saveButton.disabled).toBe(true); const inputs = container.querySelectorAll('input[type="password"]'); inputText(inputs[0] as HTMLInputElement, "current-pass"); inputText(inputs[1] as HTMLInputElement, "new-pass-12345"); await flushView(); expect(saveButton.disabled).toBe(true); inputText(inputs[2] as HTMLInputElement, "new-pass-12345"); await flushView(); expect(saveButton.disabled).toBe(false); }); it("Settings feedback highlights the selected category and hides the form after submit", async () => { sendMessageMock.mockResolvedValue({ ok: true, data: null }); const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({ ok: true, json: async () => ({ success: true }), })); vi.stubGlobal("fetch", fetchMock); const container = mount( ); await flushView(); const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement; expect(feedbackCard.classList.contains("is-active")).toBe(true); const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[]; const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!; const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!; expect(bugButton.getAttribute("aria-pressed")).toBe("true"); expect(featureButton.getAttribute("aria-pressed")).toBe("false"); featureButton.click(); await flushView(); expect(bugButton.getAttribute("aria-pressed")).toBe("false"); expect(featureButton.getAttribute("aria-pressed")).toBe("true"); expect(featureButton.classList.contains("is-active")).toBe(true); inputText( container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement, ' Please make this easier to see & safer.' ); const sendButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "Send" ) as HTMLButtonElement; sendButton.click(); await vi.waitFor(() => { expect(container.textContent).toContain("Thank you"); }); const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); expect(requestBody.subject).toContain("Feature request"); expect(requestBody.message).toBe( "<script>alert("xss")</script> Please make this easier to see & safer." ); expect(requestBody.message).not.toContain("