// @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 { 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 { 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/import", () => ({ parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args), })); vi.mock("../../shared/favicon", () => ({ 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("zxcvbn", () => ({ default: (value: string) => zxcvbnMock(value), })); 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, requiresPassword: false, }); 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.unstubAllGlobals(); }); function mount(view: 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, requiresPassword: false, ...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 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 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("Export menu surfaces both backup formats", async () => { const container = mount(); await flushView(); expect(container.textContent).toContain("PeachScript"); expect(container.textContent).toContain("CSV"); }); it("Lock onboarding restore options include Peach Codex", async () => { const onOpenCodexRestore = vi.fn(); const container = mount( ); const restoreButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.includes("restore from backup") ) as HTMLButtonElement; restoreButton.click(); await flushView(); expect(container.textContent).toContain("PeachScript"); expect(container.textContent).toContain("Codex"); expect(container.textContent).toContain("CSV"); const codexTab = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "Codex" ) as HTMLButtonElement; codexTab.click(); await flushView(); expect(container.textContent).toContain("Restore from Peach Codex"); expect(container.textContent).toContain("Scan printed Codex pages"); const openCodexButton = Array.from(container.querySelectorAll("button")).find( (candidate) => candidate.textContent?.trim() === "open Codex restore →" ) as HTMLButtonElement; openCodexButton.click(); expect(onOpenCodexRestore).toHaveBeenCalledTimes(1); }); 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("