123import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { RecoveryWordEntryView } from "./RecoveryWordEntry";16import { VaultView } from "./Vault";17import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1819const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();20const triggerRingMock = vi.fn();21const triggerPipPulseMock = vi.fn();22const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();23const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2425vi.mock("../App", () => ({26 sendMessage: (msg: unknown) => sendMessageMock(msg),27 triggerRing: () => triggerRingMock(),28 triggerPipPulse: () => triggerPipPulseMock(),29}));3031vi.mock("../../shared/import", () => ({32 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),33}));3435vi.mock("../../shared/favicon", async (importOriginal) => {36 const actual = await importOriginal<typeof import("../../shared/favicon")>();37 return { ...actual, faviconUrl: () => null };38});3940vi.mock("../../crypto/totp", () => ({41 generateTOTP: () => ({ code: "123 456", remaining: 24 }),42 parseOtpauthURI: (value: string) =>43 value.startsWith("otpauth://")44 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }45 : null,46}));4748vi.mock("../components/GeneratorDrawer", () => ({49 GeneratorDrawer: () => null,50}));5152vi.mock("@zxcvbn-ts/core", () => ({53 zxcvbn: (value: string) => zxcvbnMock(value),54 zxcvbnOptions: { setOptions: vi.fn() },55}));56vi.mock("@zxcvbn-ts/language-common", () => ({57 adjacencyGraphs: {},58 dictionary: {},59}));60vi.mock("@zxcvbn-ts/language-en", () => ({61 translations: {},62 dictionary: {},63}));6465describe("popup contract tests", () => {66 const disposers: Array<() => void> = [];6768 beforeEach(() => {69 document.body.innerHTML = "";70 sendMessageMock.mockReset();71 triggerRingMock.mockReset();72 triggerPipPulseMock.mockReset();73 parseImportSourceMock.mockReset();74 zxcvbnMock.mockReset();75 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));76 parseImportSourceMock.mockResolvedValue({77 format: "unknown",78 label: "Unknown format",79 entries: [],80 totalItems: 0,81 skippedItems: 0,82 requiresPassword: false,83 });8485 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {86 return window.setTimeout(() => cb(0), 0) as unknown as number;87 });88 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));89 vi.stubGlobal("URL", {90 createObjectURL: vi.fn(() => "blob:peach-test"),91 revokeObjectURL: vi.fn(),92 });93 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9495 (globalThis as { chrome?: typeof chrome }).chrome = {96 runtime: {97 getManifest: () => ({ version: "1.4.47" }),98 onMessage: { addListener: vi.fn() },99 },100 } as unknown as typeof chrome;101 });102103 afterEach(() => {104 while (disposers.length > 0) {105 disposers.pop()?.();106 }107 vi.restoreAllMocks();108 vi.unstubAllGlobals();109 });110111 function mount(view: JSX.Element) {112 const mounted = renderView(view);113 disposers.push(mounted.dispose);114 return mounted.container;115 }116117 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {118 return {119 id: "entry-1",120 type: "login",121 title: "GitHub",122 username: "june",123 password: "secret123",124 url: "https://github.com",125 totp: "JBSWY3DPEHPK3PXP",126 canary: {127 webhookUrl: "https://example.com/hook",128 },129 createdAt: 1,130 modifiedAt: 1,131 ...overrides,132 };133 }134135 function sampleImportResult(136 overrides: Partial<ImportParseResult> = {}137 ): ImportParseResult {138 return {139 format: "csv",140 label: "CSV import",141 entries: [142 {143 type: "login",144 title: "Imported site",145 username: "imported-user",146 password: "imported-pass",147 url: "https://imported.test",148 },149 ],150 totalItems: 1,151 skippedItems: 0,152 requiresPassword: false,153 ...overrides,154 };155 }156157 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {158 return {159 entriesCount: 1,160 breachMonitoring: true,161 lockOnClose: true,162 autoLockMinutes: 5,163 trashRetentionDays: 30,164 theme: "dark" as const,165 accentColor: "peach" as const,166 textSize: "medium" as const,167 reduceMotion: false,168 onBreachMonitoringChange: vi.fn(),169 onLockOnCloseChange: vi.fn(),170 onAutoLockMinutesChange: vi.fn(),171 onTrashRetentionChange: vi.fn(),172 onThemeChange: vi.fn(),173 onAccentChange: vi.fn(),174 onTextSizeChange: vi.fn(),175 onReduceMotionChange: vi.fn(),176 shortcuts: DEFAULT_BINDINGS,177 onShortcutsChange: vi.fn(),178 onBack: vi.fn(),179 onImport: vi.fn(),180 onExport: vi.fn(),181 onBreachCheckDone: vi.fn(),182 ...overrides,183 };184 }185186 async function selectImportFile(187 container: HTMLElement,188 contents = '{"export":"test"}'189 ) {190 191 192 193 const fileCard = findButton(container, "Choose file →");194 if (fileCard) {195 fileCard.click();196 await flushView();197 }198 const input = container.querySelector('input[type="file"]') as HTMLInputElement;199 const file = new File([contents], "import.json", { type: "application/json" });200 Object.defineProperty(input, "files", {201 configurable: true,202 value: [file],203 });204 input.dispatchEvent(new Event("change", { bubbles: true }));205 await flushView();206 await flushView();207 }208209 function findButton(container: HTMLElement, text: string): HTMLButtonElement {210 return Array.from(container.querySelectorAll("button")).find(211 (candidate) => candidate.textContent?.trim() === text212 ) as HTMLButtonElement;213 }214215 it("Lock sends UNLOCK with the entered passphrase", async () => {216 sendMessageMock.mockResolvedValue({ ok: true });217 const onUnlock = vi.fn(async () => undefined);218 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);219220 const password = container.querySelector("#unlock-password") as HTMLInputElement;221 inputText(password, "correct horse battery staple");222 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();223 await flushView();224225 expect(sendMessageMock).toHaveBeenCalledWith({226 type: "UNLOCK",227 payload: { password: "correct horse battery staple" },228 });229 expect(onUnlock).toHaveBeenCalled();230 });231232 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {233 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);234 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;235 const password = container.querySelector("#master-password") as HTMLInputElement;236 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;237238 inputText(password, "short");239 inputText(confirm, "short");240 await flushView();241 expect(createButton.disabled).toBe(true);242243 zxcvbnMock.mockImplementation(() => ({ score: 1 }));244 inputText(password, "weakweakweak");245 inputText(confirm, "weakweakweak");246 await flushView();247 expect(createButton.disabled).toBe(true);248 });249250 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {251 252 sendMessageMock.mockImplementation(253 async (msg: unknown) => {254 const m = msg as { type: string; payload?: { password?: string } };255 if (m.type === "CREATE_VAULT") return { ok: true };256 if (m.type === "GET_RECOVERY_WORDS") {257 258 return {259 ok: true,260 data: [261 "abandon", "ability", "able", "about", "above", "absent",262 "absorb", "abstract", "absurd", "abuse", "access", "accident",263 ],264 };265 }266 return { ok: true };267 }268 );269 zxcvbnMock.mockImplementation(() => ({ score: 3 }));270 const onUnlock = vi.fn(async () => undefined);271 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);272273 const password = container.querySelector("#master-password") as HTMLInputElement;274 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;275 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;276277 inputText(password, "correct horse battery staple");278 inputText(confirm, "correct horse battery staple");279 await flushView();280 expect(createButton.disabled).toBe(false);281282 pressEnter(confirm);283 await flushView();284285 expect(sendMessageMock).toHaveBeenCalledWith({286 type: "CREATE_VAULT",287 payload: { password: "correct horse battery staple" },288 });289 290 291 expect(onUnlock).not.toHaveBeenCalled();292 });293294 it("Recovery words flow submits the new master password and completes", async () => {295 sendMessageMock.mockImplementation(async (msg: unknown) => {296 const type = (msg as { type?: string }).type;297 if (type === "VALIDATE_RECOVERY_WORDS") {298 return { ok: true, data: { valid: true, invalid: [] } };299 }300 if (type === "RECOVER_WITH_WORDS") return { ok: true };301 return { ok: true };302 });303 const canvasContext = {304 clearRect: vi.fn(),305 fillText: vi.fn(),306 beginPath: vi.fn(),307 closePath: vi.fn(),308 arc: vi.fn(),309 fill: vi.fn(),310 stroke: vi.fn(),311 save: vi.fn(),312 restore: vi.fn(),313 translate: vi.fn(),314 rotate: vi.fn(),315 scale: vi.fn(),316 drawImage: vi.fn(),317 measureText: vi.fn(() => ({ width: 0 })),318 setTransform: vi.fn(),319 fillRect: vi.fn(),320 strokeRect: vi.fn(),321 } as unknown as CanvasRenderingContext2D;322 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);323324 const onRecoveryComplete = vi.fn(async () => undefined);325 const container = mount(326 <RecoveryWordEntryView327 onCancel={vi.fn()}328 onRecoveryComplete={onRecoveryComplete}329 />330 );331332 const recoveryWords = WORD_LIST.slice(0, 12);333 for (let i = 0; i < recoveryWords.length; i++) {334 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;335 inputText(input, recoveryWords[i]!);336 }337 await flushView();338339 findButton(container, "verify words").click();340 await flushView();341342 expect(container.textContent).toContain("new master password");343344 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;345 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;346 inputText(newPassword, "fresh-recovery-password");347 inputText(confirmPassword, "fresh-recovery-password");348 await flushView();349350 findButton(container, "recover vault").click();351 await flushView();352353 expect(sendMessageMock).toHaveBeenCalledWith({354 type: "RECOVER_WITH_WORDS",355 payload: {356 words: recoveryWords,357 newPassword: "fresh-recovery-password",358 },359 });360 expect(onRecoveryComplete).toHaveBeenCalled();361 });362363 it("Export menu surfaces both backup formats", async () => {364 const container = mount(<ExportView onBack={vi.fn()} />);365 await flushView();366367 expect(container.textContent).toContain("PeachScript");368 expect(container.textContent).toContain("CSV");369 });370371 it("Lock onboarding restore options include Peach Codex", async () => {372 const onOpenCodexRestore = vi.fn();373 const container = mount(374 <LockView375 hasVault={false}376 ready={true}377 onUnlock={vi.fn()}378 onOpenCodexRestore={onOpenCodexRestore}379 />380 );381382 const restoreButton = Array.from(container.querySelectorAll("button")).find(383 (candidate) => candidate.textContent?.includes("restore from backup")384 ) as HTMLButtonElement;385 restoreButton.click();386 await flushView();387388 expect(container.textContent).toContain("PeachScript");389 expect(container.textContent).toContain("Codex");390 expect(container.textContent).toContain("CSV");391392 const codexTab = Array.from(container.querySelectorAll("button")).find(393 (candidate) => candidate.textContent?.trim() === "Codex"394 ) as HTMLButtonElement;395 codexTab.click();396 await flushView();397398 expect(container.textContent).toContain("Restore from Peach Codex");399 expect(container.textContent).toContain("Scan printed Codex pages");400401 const openCodexButton = Array.from(container.querySelectorAll("button")).find(402 (candidate) => candidate.textContent?.trim() === "open Codex restore →"403 ) as HTMLButtonElement;404 openCodexButton.click();405406 expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);407 });408409 it("Settings requires current, new, and confirm passwords before enabling save", async () => {410 sendMessageMock.mockResolvedValue({ ok: true, data: null });411 const container = mount(412 <SettingsView413 entriesCount={1}414 breachMonitoring={true}415 lockOnClose={true}416 autoLockMinutes={5}417 trashRetentionDays={30}418 theme="dark"419 accentColor="peach"420 textSize="medium"421 reduceMotion={false}422 onBreachMonitoringChange={vi.fn()}423 onLockOnCloseChange={vi.fn()}424 onAutoLockMinutesChange={vi.fn()}425 onTrashRetentionChange={vi.fn()}426 onThemeChange={vi.fn()}427 onAccentChange={vi.fn()}428 onTextSizeChange={vi.fn()}429 onReduceMotionChange={vi.fn()}430 shortcuts={DEFAULT_BINDINGS}431 onShortcutsChange={vi.fn()}432 onBack={vi.fn()}433 onImport={vi.fn()}434 onExport={vi.fn()}435 onBreachCheckDone={vi.fn()}436 />437 );438 await flushView();439440 expect(container.querySelectorAll(".stk-card").length).toBe(7);441 442 443 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");444445 const changeButton = Array.from(container.querySelectorAll("button")).find(446 (candidate) => candidate.textContent?.trim() === "change"447 ) as HTMLButtonElement;448 changeButton.click();449 await flushView();450451 const saveButton = Array.from(container.querySelectorAll("button")).find(452 (candidate) => candidate.textContent?.trim() === "save new password"453 ) as HTMLButtonElement;454 expect(saveButton.disabled).toBe(true);455456 const inputs = container.querySelectorAll('input[type="password"]');457 inputText(inputs[0] as HTMLInputElement, "current-pass");458 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");459 await flushView();460 expect(saveButton.disabled).toBe(true);461462 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");463 await flushView();464 expect(saveButton.disabled).toBe(false);465 });466467 it("Settings feedback highlights the selected category and hides the form after submit", async () => {468 sendMessageMock.mockResolvedValue({ ok: true, data: null });469 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({470 ok: true,471 json: async () => ({ success: true }),472 }));473 vi.stubGlobal("fetch", fetchMock);474475 const container = mount(476 <SettingsView477 entriesCount={1}478 breachMonitoring={true}479 lockOnClose={true}480 autoLockMinutes={5}481 trashRetentionDays={30}482 theme="dark"483 accentColor="peach"484 textSize="medium"485 reduceMotion={false}486 initialSection="feedback"487 onBreachMonitoringChange={vi.fn()}488 onLockOnCloseChange={vi.fn()}489 onAutoLockMinutesChange={vi.fn()}490 onTrashRetentionChange={vi.fn()}491 onThemeChange={vi.fn()}492 onAccentChange={vi.fn()}493 onTextSizeChange={vi.fn()}494 onReduceMotionChange={vi.fn()}495 shortcuts={DEFAULT_BINDINGS}496 onShortcutsChange={vi.fn()}497 onBack={vi.fn()}498 onImport={vi.fn()}499 onExport={vi.fn()}500 onBreachCheckDone={vi.fn()}501 />502 );503 await flushView();504505 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;506 expect(feedbackCard.classList.contains("is-active")).toBe(true);507508 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];509 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;510 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;511 expect(bugButton.getAttribute("aria-pressed")).toBe("true");512 expect(featureButton.getAttribute("aria-pressed")).toBe("false");513514 featureButton.click();515 await flushView();516517 expect(bugButton.getAttribute("aria-pressed")).toBe("false");518 expect(featureButton.getAttribute("aria-pressed")).toBe("true");519 expect(featureButton.classList.contains("is-active")).toBe(true);520521 inputText(522 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,523 '<script>alert("xss")</script> Please make this easier to see & safer.'524 );525 const sendButton = Array.from(container.querySelectorAll("button")).find(526 (candidate) => candidate.textContent?.trim() === "Send"527 ) as HTMLButtonElement;528 sendButton.click();529530 await vi.waitFor(() => {531 expect(container.textContent).toContain("Thank you");532 });533534 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));535 expect(requestBody.subject).toContain("Feature request");536 expect(requestBody.message).toBe(537 "<script>alert("xss")</script> Please make this easier to see & safer."538 );539 expect(requestBody.message).not.toContain("<script>");540 expect(container.textContent).toContain("Someone will process it soon");541 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();542 expect(container.querySelector(".peach-feedback-submit")).toBeNull();543 });544545 it("Settings keeps license activation on the full-screen celebration path", async () => {546 const onLicenseChange = vi.fn(async () => undefined);547 sendMessageMock.mockImplementation(async (msg: unknown) => {548 const type = (msg as { type?: string }).type;549 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };550 if (type === "ACTIVATE_LICENSE") {551 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };552 }553 if (type === "GET_ENTRIES") return { ok: true, data: [] };554 return { ok: true, data: null };555 });556557 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);558 await flushView();559560 inputText(561 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,562 "PEACH-TEST-KEY",563 );564 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;565 apply.click();566567 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));568 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);569 expect(container.textContent).not.toContain("thank you ✦");570 });571572 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {573 sendMessageMock.mockImplementation(async (msg: unknown) => {574 const type = (msg as { type?: string }).type;575 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };576 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };577 if (type === "GET_ENTRIES") return { ok: true, data: [] };578 return { ok: true, data: null };579 });580581 const onLicenseChangeFromStar = vi.fn();582 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);583 await flushView();584 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();585 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);586 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();587588 const onLicenseChangeFromPeach = vi.fn();589 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);590 await flushView();591 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();592 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);593 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();594 });595596 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {597 sendMessageMock.mockResolvedValue({ ok: true, data: null });598 const fetchMock = vi.fn();599 const requestMock = vi.fn(async () => false);600 vi.stubGlobal("fetch", fetchMock);601 vi.stubGlobal("browser", {602 permissions: {603 getAll: vi.fn(async () => ({ data_collection: [] })),604 request: requestMock,605 },606 });607608 const container = mount(609 <SettingsView610 entriesCount={1}611 breachMonitoring={true}612 lockOnClose={true}613 autoLockMinutes={5}614 trashRetentionDays={30}615 theme="dark"616 accentColor="peach"617 textSize="medium"618 reduceMotion={false}619 initialSection="feedback"620 onBreachMonitoringChange={vi.fn()}621 onLockOnCloseChange={vi.fn()}622 onAutoLockMinutesChange={vi.fn()}623 onTrashRetentionChange={vi.fn()}624 onThemeChange={vi.fn()}625 onAccentChange={vi.fn()}626 onTextSizeChange={vi.fn()}627 onReduceMotionChange={vi.fn()}628 shortcuts={DEFAULT_BINDINGS}629 onShortcutsChange={vi.fn()}630 onBack={vi.fn()}631 onImport={vi.fn()}632 onExport={vi.fn()}633 onBreachCheckDone={vi.fn()}634 />635 );636 await flushView();637638 inputText(639 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,640 "Please improve the feedback flow."641 );642 const sendButton = Array.from(container.querySelectorAll("button")).find(643 (candidate) => candidate.textContent?.trim() === "Send"644 ) as HTMLButtonElement;645 sendButton.click();646647 await vi.waitFor(() => {648 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });649 });650 expect(fetchMock).not.toHaveBeenCalled();651 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();652 });653654 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {655 sendMessageMock.mockImplementation(async (msg: unknown) => {656 const m = msg as { type: string };657 if (m.type === "GET_RECOVERY_WORDS") {658 return {659 ok: true,660 data: [661 "abandon", "ability", "able", "about", "above", "absent",662 "absorb", "abstract", "absurd", "abuse", "access", "accident",663 ],664 };665 }666 return { ok: true, data: null };667 });668 const onAutoLockMinutesChange = vi.fn();669 const onThemeChange = vi.fn();670 const container = mount(671 <SettingsView672 entriesCount={1}673 breachMonitoring={true}674 lockOnClose={true}675 autoLockMinutes={5}676 trashRetentionDays={30}677 theme="dark"678 accentColor="peach"679 textSize="medium"680 reduceMotion={false}681 onBreachMonitoringChange={vi.fn()}682 onLockOnCloseChange={vi.fn()}683 onAutoLockMinutesChange={onAutoLockMinutesChange}684 onTrashRetentionChange={vi.fn()}685 onThemeChange={onThemeChange}686 onAccentChange={vi.fn()}687 onTextSizeChange={vi.fn()}688 onReduceMotionChange={vi.fn()}689 shortcuts={DEFAULT_BINDINGS}690 onShortcutsChange={vi.fn()}691 onBack={vi.fn()}692 onImport={vi.fn()}693 onExport={vi.fn()}694 onBreachCheckDone={vi.fn()}695 />696 );697 await flushView();698 sendMessageMock.mockClear();699700 701 const selects = container.querySelectorAll("select");702 changeSelect(selects[0] as HTMLSelectElement, "15");703 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);704705 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;706 appearanceCard.click();707 await flushView();708 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");709 const themeButton = Array.from(container.querySelectorAll("button")).find(710 (candidate) => candidate.textContent?.trim() === "Switch to light"711 ) as HTMLButtonElement;712 themeButton.click();713 expect(onThemeChange).toHaveBeenCalledWith("light");714715 716 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;717 protectionCard.click();718 await flushView();719720 const changeButton = Array.from(container.querySelectorAll("button")).find(721 (candidate) => candidate.textContent?.trim() === "change"722 ) as HTMLButtonElement;723 changeButton.click();724 await flushView();725726 const inputs = container.querySelectorAll('input[type="password"]');727 inputText(inputs[0] as HTMLInputElement, "current-pass");728 inputText(inputs[1] as HTMLInputElement, "new-password-123");729 inputText(inputs[2] as HTMLInputElement, "new-password-123");730 await flushView();731732 const saveButton = Array.from(container.querySelectorAll("button")).find(733 (candidate) => candidate.textContent?.trim() === "save new password"734 ) as HTMLButtonElement;735 saveButton.click();736 await flushView();737738 expect(sendMessageMock).toHaveBeenCalledWith({739 type: "CHANGE_PASSWORD",740 payload: {741 currentPassword: "current-pass",742 newPassword: "new-password-123",743 },744 });745746 747 expect(container.textContent).toContain("save your recovery words");748 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });749 });750751 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {752 sendMessageMock.mockResolvedValue({ ok: true });753 const container = mount(754 <EntryView755 entry={sampleEntry()}756 onDelete={vi.fn()}757 onFieldSaved={vi.fn(async () => undefined)}758 />759 );760761 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;762 passwordButton.click();763 await flushView();764765 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {766 type: "COPY",767 payload: { text: "secret123", label: "password", alreadyWritten: false },768 });769 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {770 type: "FIRE_CANARY_PING",771 payload: {772 webhookUrl: "https://example.com/hook",773 entryTitle: "GitHub",774 trigger: "password_copied",775 },776 });777778 sendMessageMock.mockClear();779 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;780 usernameButton.click();781 await flushView();782783 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {784 type: "COPY",785 payload: { text: "june", label: "username", alreadyWritten: false },786 });787 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {788 type: "FIRE_CANARY_PING",789 payload: {790 webhookUrl: "https://example.com/hook",791 entryTitle: "GitHub",792 trigger: "username_copied",793 },794 });795 });796797 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {798 sendMessageMock.mockResolvedValue({ ok: true });799 const onFieldSaved = vi.fn(async () => undefined);800 const container = mount(801 <EntryView802 entry={sampleEntry()}803 onDelete={vi.fn()}804 onFieldSaved={onFieldSaved}805 />806 );807808 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;809 passwordValue.click();810 await flushView();811812 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;813 inputText(passwordInput, "");814 await flushView();815816 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;817 saveButton.click();818 await flushView();819820 expect(sendMessageMock).not.toHaveBeenCalledWith(821 expect.objectContaining({ type: "UPDATE_ENTRY" })822 );823 expect(onFieldSaved).not.toHaveBeenCalled();824825 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;826 totpMain.click();827828 await vi.waitFor(() => {829 expect(sendMessageMock).toHaveBeenLastCalledWith({830 type: "COPY",831 payload: { text: "123456", label: "totp", alreadyWritten: false },832 });833 }, { timeout: 500 });834 });835836 it("Entry closes an unchanged inline editor when focus leaves it", async () => {837 sendMessageMock.mockResolvedValue({ ok: true });838 const container = mount(839 <EntryView840 entry={sampleEntry()}841 onDelete={vi.fn()}842 onFieldSaved={vi.fn(async () => undefined)}843 />844 );845846 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;847 usernameValue.click();848 await flushView();849850 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;851 usernameInput.blur();852 await flushView();853854 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();855 expect(sendMessageMock).not.toHaveBeenCalledWith(856 expect.objectContaining({ type: "UPDATE_ENTRY" })857 );858 });859860 it("Entry lets TOTP secrets be edited inline", async () => {861 sendMessageMock.mockResolvedValue({ ok: true });862 const onFieldSaved = vi.fn(async () => undefined);863 const container = mount(864 <EntryView865 entry={sampleEntry()}866 onDelete={vi.fn()}867 onFieldSaved={onFieldSaved}868 />869 );870871 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;872 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));873 await flushView();874875 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;876 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");877 await flushView();878879 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;880 saveButton.click();881 await flushView();882883 expect(sendMessageMock).toHaveBeenCalledWith({884 type: "UPDATE_ENTRY",885 payload: {886 id: "entry-1",887 patch: { totp: "NB2W45DFOIZA====" },888 },889 });890 expect(onFieldSaved).toHaveBeenCalled();891 });892893 it("Vault seeds and focuses search when a search seed is provided", async () => {894 const container = mount(895 <VaultView896 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}897 sortOrder="url-first"898 searchSeed="g"899 searchSeedNonce={1}900 onSelectEntry={vi.fn()}901 onSortOrderChange={vi.fn()}902 onAdd={vi.fn()}903 onClose={vi.fn()}904 />905 );906907 await flushView();908909 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;910 expect(search.value).toBe("g");911 expect(document.activeElement).toBe(search);912 });913914 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {915 parseImportSourceMock.mockResolvedValue(916 sampleImportResult({917 entries: [918 {919 type: "login",920 title: "Imported site",921 username: "imported-user",922 password: "imported-pass",923 url: "https://imported.test",924 },925 ],926 })927 );928 sendMessageMock.mockImplementation(async (msg: unknown) => {929 const type = (msg as { type?: string }).type;930 if (type === "GET_ENTRIES") {931 return {932 ok: true,933 data: [934 sampleEntry({935 id: "existing-1",936 title: "Imported site",937 username: "imported-user",938 url: "https://imported.test",939 }),940 ],941 };942 }943 if (type === "BATCH_ADD_ENTRIES") {944 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };945 }946 return { ok: true };947 });948 const onImport = vi.fn(async () => undefined);949 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);950 await flushView();951 sendMessageMock.mockClear();952953 await selectImportFile(container);954 findButton(container, "Import 1").click();955 await flushView();956957 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);958 expect(sendMessageMock).toHaveBeenCalledWith({959 type: "BATCH_ADD_ENTRIES",960 payload: {961 entries: [962 expect.objectContaining({963 title: "Imported site",964 username: "imported-user",965 password: "imported-pass",966 url: "https://imported.test",967 }),968 ],969 },970 });971 expect(onImport).toHaveBeenCalled();972 expect(container.textContent).toContain("1 entry appears to be duplicates");973 });974975 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {976 parseImportSourceMock.mockResolvedValue(sampleImportResult());977 sendMessageMock.mockImplementation(async (msg: unknown) => {978 const type = (msg as { type?: string }).type;979 if (type === "GET_ENTRIES") {980 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };981 }982 if (type === "CLEAR_VAULT_ENTRIES") {983 return { ok: true };984 }985 if (type === "BATCH_ADD_ENTRIES") {986 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };987 }988 return { ok: true };989 });990 const onImport = vi.fn(async () => undefined);991 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);992 await flushView();993 sendMessageMock.mockClear();994995 await selectImportFile(container);996 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();997 await flushView();998999 findButton(container, "Replace vault with 1").click();1000 await flushView();10011002 expect(sendMessageMock).not.toHaveBeenCalledWith(1003 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1004 );1005 expect(container.textContent).toContain("Replace forever? This cannot be undone.");10061007 findButton(container, "Yes, replace forever").click();1008 await flushView();10091010 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1011 expect(sendMessageMock).toHaveBeenCalledWith({1012 type: "BATCH_ADD_ENTRIES",1013 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1014 });1015 expect(onImport).toHaveBeenCalled();1016 });10171018 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1019 parseImportSourceMock.mockResolvedValue(sampleImportResult());1020 sendMessageMock.mockImplementation(async (msg: unknown) => {1021 const type = (msg as { type?: string }).type;1022 if (type === "CREATE_VAULT") return { ok: true };1023 if (type === "BATCH_ADD_ENTRIES") {1024 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1025 }1026 return { ok: true };1027 });1028 const onImport = vi.fn(async () => undefined);1029 const container = mount(1030 <ImportView1031 mode="onboarding"1032 replacesExistingVault={true}1033 onBack={vi.fn()}1034 onImport={onImport}1035 />1036 );10371038 await selectImportFile(container);10391040 const createButton = findButton(container, "Create vault + import 1");1041 expect(createButton.disabled).toBe(true);1042 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1043 expect(container.textContent).toContain("Use at least 8 characters");10441045 const masterInput = container.querySelector(1046 'input[placeholder="new master password"]'1047 ) as HTMLInputElement;1048 const confirmInput = container.querySelector(1049 'input[placeholder="confirm master password"]'1050 ) as HTMLInputElement;1051 const revealButton = container.querySelector(1052 'button[aria-label="Show passwords"]'1053 ) as HTMLButtonElement;1054 expect(revealButton).toBeTruthy();1055 revealButton.click();1056 await flushView();1057 expect(masterInput.type).toBe("text");1058 expect(confirmInput.type).toBe("text");10591060 inputText(masterInput, "fresh-master-password");1061 inputText(confirmInput, "fresh-master-password");1062 await flushView();10631064 expect(createButton.disabled).toBe(false);1065 createButton.click();1066 await flushView();10671068 expect(sendMessageMock).toHaveBeenCalledWith({1069 type: "CREATE_VAULT",1070 payload: { password: "fresh-master-password" },1071 });1072 expect(sendMessageMock).toHaveBeenCalledWith({1073 type: "BATCH_ADD_ENTRIES",1074 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1075 });1076 expect(onImport).toHaveBeenCalled();1077 });10781079 it("Import encrypted backups reparse with the backup password before import", async () => {1080 parseImportSourceMock1081 .mockResolvedValueOnce(1082 sampleImportResult({1083 format: "peach-json",1084 label: "Peach encrypted backup",1085 entries: [],1086 totalItems: 0,1087 requiresPassword: true,1088 })1089 )1090 .mockResolvedValueOnce(1091 sampleImportResult({1092 format: "peach-json",1093 label: "Peach encrypted backup",1094 })1095 );1096 sendMessageMock.mockImplementation(async (msg: unknown) => {1097 const type = (msg as { type?: string }).type;1098 if (type === "GET_ENTRIES") return { ok: true, data: [] };1099 if (type === "BATCH_ADD_ENTRIES") {1100 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1101 }1102 return { ok: true };1103 });1104 const onImport = vi.fn(async () => undefined);1105 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1106 await flushView();1107 sendMessageMock.mockClear();11081109 await selectImportFile(container, '{"type":"peach-export"}');1110 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1111 inputText(password, "backup-passphrase");1112 pressEnter(password);1113 await flushView();1114 await flushView();11151116 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1117 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");11181119 findButton(container, "Import 1").click();1120 await flushView();11211122 expect(sendMessageMock).toHaveBeenCalledWith({1123 type: "BATCH_ADD_ENTRIES",1124 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1125 });1126 expect(onImport).toHaveBeenCalled();1127 });1128});