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", () => ({53 default: (value: string) => zxcvbnMock(value),54}));5556describe("popup contract tests", () => {57 const disposers: Array<() => void> = [];5859 beforeEach(() => {60 document.body.innerHTML = "";61 sendMessageMock.mockReset();62 triggerRingMock.mockReset();63 triggerPipPulseMock.mockReset();64 parseImportSourceMock.mockReset();65 zxcvbnMock.mockReset();66 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));67 parseImportSourceMock.mockResolvedValue({68 format: "unknown",69 label: "Unknown format",70 entries: [],71 totalItems: 0,72 skippedItems: 0,73 requiresPassword: false,74 });7576 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {77 return window.setTimeout(() => cb(0), 0) as unknown as number;78 });79 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));80 vi.stubGlobal("URL", {81 createObjectURL: vi.fn(() => "blob:peach-test"),82 revokeObjectURL: vi.fn(),83 });84 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8586 (globalThis as { chrome?: typeof chrome }).chrome = {87 runtime: {88 getManifest: () => ({ version: "1.4.47" }),89 onMessage: { addListener: vi.fn() },90 },91 } as unknown as typeof chrome;92 });9394 afterEach(() => {95 while (disposers.length > 0) {96 disposers.pop()?.();97 }98 vi.restoreAllMocks();99 vi.unstubAllGlobals();100 });101102 function mount(view: JSX.Element) {103 const mounted = renderView(view);104 disposers.push(mounted.dispose);105 return mounted.container;106 }107108 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {109 return {110 id: "entry-1",111 type: "login",112 title: "GitHub",113 username: "june",114 password: "secret123",115 url: "https://github.com",116 totp: "JBSWY3DPEHPK3PXP",117 canary: {118 webhookUrl: "https://example.com/hook",119 },120 createdAt: 1,121 modifiedAt: 1,122 ...overrides,123 };124 }125126 function sampleImportResult(127 overrides: Partial<ImportParseResult> = {}128 ): ImportParseResult {129 return {130 format: "csv",131 label: "CSV import",132 entries: [133 {134 type: "login",135 title: "Imported site",136 username: "imported-user",137 password: "imported-pass",138 url: "https://imported.test",139 },140 ],141 totalItems: 1,142 skippedItems: 0,143 requiresPassword: false,144 ...overrides,145 };146 }147148 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {149 return {150 entriesCount: 1,151 breachMonitoring: true,152 lockOnClose: true,153 autoLockMinutes: 5,154 trashRetentionDays: 30,155 theme: "dark" as const,156 accentColor: "peach" as const,157 textSize: "medium" as const,158 reduceMotion: false,159 onBreachMonitoringChange: vi.fn(),160 onLockOnCloseChange: vi.fn(),161 onAutoLockMinutesChange: vi.fn(),162 onTrashRetentionChange: vi.fn(),163 onThemeChange: vi.fn(),164 onAccentChange: vi.fn(),165 onTextSizeChange: vi.fn(),166 onReduceMotionChange: vi.fn(),167 shortcuts: DEFAULT_BINDINGS,168 onShortcutsChange: vi.fn(),169 onBack: vi.fn(),170 onImport: vi.fn(),171 onExport: vi.fn(),172 onBreachCheckDone: vi.fn(),173 ...overrides,174 };175 }176177 async function selectImportFile(178 container: HTMLElement,179 contents = '{"export":"test"}'180 ) {181 182 183 184 const fileCard = findButton(container, "Choose file →");185 if (fileCard) {186 fileCard.click();187 await flushView();188 }189 const input = container.querySelector('input[type="file"]') as HTMLInputElement;190 const file = new File([contents], "import.json", { type: "application/json" });191 Object.defineProperty(input, "files", {192 configurable: true,193 value: [file],194 });195 input.dispatchEvent(new Event("change", { bubbles: true }));196 await flushView();197 await flushView();198 }199200 function findButton(container: HTMLElement, text: string): HTMLButtonElement {201 return Array.from(container.querySelectorAll("button")).find(202 (candidate) => candidate.textContent?.trim() === text203 ) as HTMLButtonElement;204 }205206 it("Lock sends UNLOCK with the entered passphrase", async () => {207 sendMessageMock.mockResolvedValue({ ok: true });208 const onUnlock = vi.fn(async () => undefined);209 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);210211 const password = container.querySelector("#unlock-password") as HTMLInputElement;212 inputText(password, "correct horse battery staple");213 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();214 await flushView();215216 expect(sendMessageMock).toHaveBeenCalledWith({217 type: "UNLOCK",218 payload: { password: "correct horse battery staple" },219 });220 expect(onUnlock).toHaveBeenCalled();221 });222223 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {224 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);225 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;226 const password = container.querySelector("#master-password") as HTMLInputElement;227 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;228229 inputText(password, "short");230 inputText(confirm, "short");231 await flushView();232 expect(createButton.disabled).toBe(true);233234 zxcvbnMock.mockImplementation(() => ({ score: 1 }));235 inputText(password, "weakweakweak");236 inputText(confirm, "weakweakweak");237 await flushView();238 expect(createButton.disabled).toBe(true);239 });240241 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {242 243 sendMessageMock.mockImplementation(244 async (msg: unknown) => {245 const m = msg as { type: string; payload?: { password?: string } };246 if (m.type === "CREATE_VAULT") return { ok: true };247 if (m.type === "GET_RECOVERY_WORDS") {248 249 return {250 ok: true,251 data: [252 "abandon", "ability", "able", "about", "above", "absent",253 "absorb", "abstract", "absurd", "abuse", "access", "accident",254 ],255 };256 }257 return { ok: true };258 }259 );260 zxcvbnMock.mockImplementation(() => ({ score: 3 }));261 const onUnlock = vi.fn(async () => undefined);262 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);263264 const password = container.querySelector("#master-password") as HTMLInputElement;265 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;266 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;267268 inputText(password, "correct horse battery staple");269 inputText(confirm, "correct horse battery staple");270 await flushView();271 expect(createButton.disabled).toBe(false);272273 pressEnter(confirm);274 await flushView();275276 expect(sendMessageMock).toHaveBeenCalledWith({277 type: "CREATE_VAULT",278 payload: { password: "correct horse battery staple" },279 });280 281 282 expect(onUnlock).not.toHaveBeenCalled();283 });284285 it("Recovery words flow submits the new master password and completes", async () => {286 sendMessageMock.mockImplementation(async (msg: unknown) => {287 const type = (msg as { type?: string }).type;288 if (type === "VALIDATE_RECOVERY_WORDS") {289 return { ok: true, data: { valid: true, invalid: [] } };290 }291 if (type === "RECOVER_WITH_WORDS") return { ok: true };292 return { ok: true };293 });294 const canvasContext = {295 clearRect: vi.fn(),296 fillText: vi.fn(),297 beginPath: vi.fn(),298 closePath: vi.fn(),299 arc: vi.fn(),300 fill: vi.fn(),301 stroke: vi.fn(),302 save: vi.fn(),303 restore: vi.fn(),304 translate: vi.fn(),305 rotate: vi.fn(),306 scale: vi.fn(),307 drawImage: vi.fn(),308 measureText: vi.fn(() => ({ width: 0 })),309 setTransform: vi.fn(),310 fillRect: vi.fn(),311 strokeRect: vi.fn(),312 } as unknown as CanvasRenderingContext2D;313 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);314315 const onRecoveryComplete = vi.fn(async () => undefined);316 const container = mount(317 <RecoveryWordEntryView318 onCancel={vi.fn()}319 onRecoveryComplete={onRecoveryComplete}320 />321 );322323 const recoveryWords = WORD_LIST.slice(0, 12);324 for (let i = 0; i < recoveryWords.length; i++) {325 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;326 inputText(input, recoveryWords[i]!);327 }328 await flushView();329330 findButton(container, "verify words").click();331 await flushView();332333 expect(container.textContent).toContain("new master password");334335 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;336 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;337 inputText(newPassword, "fresh-recovery-password");338 inputText(confirmPassword, "fresh-recovery-password");339 await flushView();340341 findButton(container, "recover vault").click();342 await flushView();343344 expect(sendMessageMock).toHaveBeenCalledWith({345 type: "RECOVER_WITH_WORDS",346 payload: {347 words: recoveryWords,348 newPassword: "fresh-recovery-password",349 },350 });351 expect(onRecoveryComplete).toHaveBeenCalled();352 });353354 it("Export menu surfaces both backup formats", async () => {355 const container = mount(<ExportView onBack={vi.fn()} />);356 await flushView();357358 expect(container.textContent).toContain("PeachScript");359 expect(container.textContent).toContain("CSV");360 });361362 it("Lock onboarding restore options include Peach Codex", async () => {363 const onOpenCodexRestore = vi.fn();364 const container = mount(365 <LockView366 hasVault={false}367 ready={true}368 onUnlock={vi.fn()}369 onOpenCodexRestore={onOpenCodexRestore}370 />371 );372373 const restoreButton = Array.from(container.querySelectorAll("button")).find(374 (candidate) => candidate.textContent?.includes("restore from backup")375 ) as HTMLButtonElement;376 restoreButton.click();377 await flushView();378379 expect(container.textContent).toContain("PeachScript");380 expect(container.textContent).toContain("Codex");381 expect(container.textContent).toContain("CSV");382383 const codexTab = Array.from(container.querySelectorAll("button")).find(384 (candidate) => candidate.textContent?.trim() === "Codex"385 ) as HTMLButtonElement;386 codexTab.click();387 await flushView();388389 expect(container.textContent).toContain("Restore from Peach Codex");390 expect(container.textContent).toContain("Scan printed Codex pages");391392 const openCodexButton = Array.from(container.querySelectorAll("button")).find(393 (candidate) => candidate.textContent?.trim() === "open Codex restore →"394 ) as HTMLButtonElement;395 openCodexButton.click();396397 expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);398 });399400 it("Settings requires current, new, and confirm passwords before enabling save", async () => {401 sendMessageMock.mockResolvedValue({ ok: true, data: null });402 const container = mount(403 <SettingsView404 entriesCount={1}405 breachMonitoring={true}406 lockOnClose={true}407 autoLockMinutes={5}408 trashRetentionDays={30}409 theme="dark"410 accentColor="peach"411 textSize="medium"412 reduceMotion={false}413 onBreachMonitoringChange={vi.fn()}414 onLockOnCloseChange={vi.fn()}415 onAutoLockMinutesChange={vi.fn()}416 onTrashRetentionChange={vi.fn()}417 onThemeChange={vi.fn()}418 onAccentChange={vi.fn()}419 onTextSizeChange={vi.fn()}420 onReduceMotionChange={vi.fn()}421 shortcuts={DEFAULT_BINDINGS}422 onShortcutsChange={vi.fn()}423 onBack={vi.fn()}424 onImport={vi.fn()}425 onExport={vi.fn()}426 onBreachCheckDone={vi.fn()}427 />428 );429 await flushView();430431 expect(container.querySelectorAll(".stk-card").length).toBe(7);432 433 434 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");435436 const changeButton = Array.from(container.querySelectorAll("button")).find(437 (candidate) => candidate.textContent?.trim() === "change"438 ) as HTMLButtonElement;439 changeButton.click();440 await flushView();441442 const saveButton = Array.from(container.querySelectorAll("button")).find(443 (candidate) => candidate.textContent?.trim() === "save new password"444 ) as HTMLButtonElement;445 expect(saveButton.disabled).toBe(true);446447 const inputs = container.querySelectorAll('input[type="password"]');448 inputText(inputs[0] as HTMLInputElement, "current-pass");449 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");450 await flushView();451 expect(saveButton.disabled).toBe(true);452453 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");454 await flushView();455 expect(saveButton.disabled).toBe(false);456 });457458 it("Settings feedback highlights the selected category and hides the form after submit", async () => {459 sendMessageMock.mockResolvedValue({ ok: true, data: null });460 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({461 ok: true,462 json: async () => ({ success: true }),463 }));464 vi.stubGlobal("fetch", fetchMock);465466 const container = mount(467 <SettingsView468 entriesCount={1}469 breachMonitoring={true}470 lockOnClose={true}471 autoLockMinutes={5}472 trashRetentionDays={30}473 theme="dark"474 accentColor="peach"475 textSize="medium"476 reduceMotion={false}477 initialSection="feedback"478 onBreachMonitoringChange={vi.fn()}479 onLockOnCloseChange={vi.fn()}480 onAutoLockMinutesChange={vi.fn()}481 onTrashRetentionChange={vi.fn()}482 onThemeChange={vi.fn()}483 onAccentChange={vi.fn()}484 onTextSizeChange={vi.fn()}485 onReduceMotionChange={vi.fn()}486 shortcuts={DEFAULT_BINDINGS}487 onShortcutsChange={vi.fn()}488 onBack={vi.fn()}489 onImport={vi.fn()}490 onExport={vi.fn()}491 onBreachCheckDone={vi.fn()}492 />493 );494 await flushView();495496 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;497 expect(feedbackCard.classList.contains("is-active")).toBe(true);498499 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];500 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;501 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;502 expect(bugButton.getAttribute("aria-pressed")).toBe("true");503 expect(featureButton.getAttribute("aria-pressed")).toBe("false");504505 featureButton.click();506 await flushView();507508 expect(bugButton.getAttribute("aria-pressed")).toBe("false");509 expect(featureButton.getAttribute("aria-pressed")).toBe("true");510 expect(featureButton.classList.contains("is-active")).toBe(true);511512 inputText(513 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,514 '<script>alert("xss")</script> Please make this easier to see & safer.'515 );516 const sendButton = Array.from(container.querySelectorAll("button")).find(517 (candidate) => candidate.textContent?.trim() === "Send"518 ) as HTMLButtonElement;519 sendButton.click();520521 await vi.waitFor(() => {522 expect(container.textContent).toContain("Thank you");523 });524525 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));526 expect(requestBody.subject).toContain("Feature request");527 expect(requestBody.message).toBe(528 "<script>alert("xss")</script> Please make this easier to see & safer."529 );530 expect(requestBody.message).not.toContain("<script>");531 expect(container.textContent).toContain("Someone will process it soon");532 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();533 expect(container.querySelector(".peach-feedback-submit")).toBeNull();534 });535536 it("Settings keeps license activation on the full-screen celebration path", async () => {537 const onLicenseChange = vi.fn(async () => undefined);538 sendMessageMock.mockImplementation(async (msg: unknown) => {539 const type = (msg as { type?: string }).type;540 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };541 if (type === "ACTIVATE_LICENSE") {542 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };543 }544 if (type === "GET_ENTRIES") return { ok: true, data: [] };545 return { ok: true, data: null };546 });547548 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);549 await flushView();550551 inputText(552 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,553 "PEACH-TEST-KEY",554 );555 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;556 apply.click();557558 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));559 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);560 expect(container.textContent).not.toContain("thank you ✦");561 });562563 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {564 sendMessageMock.mockImplementation(async (msg: unknown) => {565 const type = (msg as { type?: string }).type;566 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };567 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };568 if (type === "GET_ENTRIES") return { ok: true, data: [] };569 return { ok: true, data: null };570 });571572 const onLicenseChangeFromStar = vi.fn();573 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);574 await flushView();575 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();576 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);577 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();578579 const onLicenseChangeFromPeach = vi.fn();580 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);581 await flushView();582 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();583 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);584 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();585 });586587 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {588 sendMessageMock.mockResolvedValue({ ok: true, data: null });589 const fetchMock = vi.fn();590 const requestMock = vi.fn(async () => false);591 vi.stubGlobal("fetch", fetchMock);592 vi.stubGlobal("browser", {593 permissions: {594 getAll: vi.fn(async () => ({ data_collection: [] })),595 request: requestMock,596 },597 });598599 const container = mount(600 <SettingsView601 entriesCount={1}602 breachMonitoring={true}603 lockOnClose={true}604 autoLockMinutes={5}605 trashRetentionDays={30}606 theme="dark"607 accentColor="peach"608 textSize="medium"609 reduceMotion={false}610 initialSection="feedback"611 onBreachMonitoringChange={vi.fn()}612 onLockOnCloseChange={vi.fn()}613 onAutoLockMinutesChange={vi.fn()}614 onTrashRetentionChange={vi.fn()}615 onThemeChange={vi.fn()}616 onAccentChange={vi.fn()}617 onTextSizeChange={vi.fn()}618 onReduceMotionChange={vi.fn()}619 shortcuts={DEFAULT_BINDINGS}620 onShortcutsChange={vi.fn()}621 onBack={vi.fn()}622 onImport={vi.fn()}623 onExport={vi.fn()}624 onBreachCheckDone={vi.fn()}625 />626 );627 await flushView();628629 inputText(630 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,631 "Please improve the feedback flow."632 );633 const sendButton = Array.from(container.querySelectorAll("button")).find(634 (candidate) => candidate.textContent?.trim() === "Send"635 ) as HTMLButtonElement;636 sendButton.click();637638 await vi.waitFor(() => {639 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });640 });641 expect(fetchMock).not.toHaveBeenCalled();642 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();643 });644645 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {646 sendMessageMock.mockImplementation(async (msg: unknown) => {647 const m = msg as { type: string };648 if (m.type === "GET_RECOVERY_WORDS") {649 return {650 ok: true,651 data: [652 "abandon", "ability", "able", "about", "above", "absent",653 "absorb", "abstract", "absurd", "abuse", "access", "accident",654 ],655 };656 }657 return { ok: true, data: null };658 });659 const onAutoLockMinutesChange = vi.fn();660 const onThemeChange = vi.fn();661 const container = mount(662 <SettingsView663 entriesCount={1}664 breachMonitoring={true}665 lockOnClose={true}666 autoLockMinutes={5}667 trashRetentionDays={30}668 theme="dark"669 accentColor="peach"670 textSize="medium"671 reduceMotion={false}672 onBreachMonitoringChange={vi.fn()}673 onLockOnCloseChange={vi.fn()}674 onAutoLockMinutesChange={onAutoLockMinutesChange}675 onTrashRetentionChange={vi.fn()}676 onThemeChange={onThemeChange}677 onAccentChange={vi.fn()}678 onTextSizeChange={vi.fn()}679 onReduceMotionChange={vi.fn()}680 shortcuts={DEFAULT_BINDINGS}681 onShortcutsChange={vi.fn()}682 onBack={vi.fn()}683 onImport={vi.fn()}684 onExport={vi.fn()}685 onBreachCheckDone={vi.fn()}686 />687 );688 await flushView();689 sendMessageMock.mockClear();690691 692 const selects = container.querySelectorAll("select");693 changeSelect(selects[0] as HTMLSelectElement, "15");694 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);695696 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;697 appearanceCard.click();698 await flushView();699 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");700 const themeButton = Array.from(container.querySelectorAll("button")).find(701 (candidate) => candidate.textContent?.trim() === "Switch to light"702 ) as HTMLButtonElement;703 themeButton.click();704 expect(onThemeChange).toHaveBeenCalledWith("light");705706 707 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;708 protectionCard.click();709 await flushView();710711 const changeButton = Array.from(container.querySelectorAll("button")).find(712 (candidate) => candidate.textContent?.trim() === "change"713 ) as HTMLButtonElement;714 changeButton.click();715 await flushView();716717 const inputs = container.querySelectorAll('input[type="password"]');718 inputText(inputs[0] as HTMLInputElement, "current-pass");719 inputText(inputs[1] as HTMLInputElement, "new-password-123");720 inputText(inputs[2] as HTMLInputElement, "new-password-123");721 await flushView();722723 const saveButton = Array.from(container.querySelectorAll("button")).find(724 (candidate) => candidate.textContent?.trim() === "save new password"725 ) as HTMLButtonElement;726 saveButton.click();727 await flushView();728729 expect(sendMessageMock).toHaveBeenCalledWith({730 type: "CHANGE_PASSWORD",731 payload: {732 currentPassword: "current-pass",733 newPassword: "new-password-123",734 },735 });736737 738 expect(container.textContent).toContain("save your recovery words");739 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });740 });741742 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {743 sendMessageMock.mockResolvedValue({ ok: true });744 const container = mount(745 <EntryView746 entry={sampleEntry()}747 onDelete={vi.fn()}748 onFieldSaved={vi.fn(async () => undefined)}749 />750 );751752 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;753 passwordButton.click();754 await flushView();755756 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {757 type: "COPY",758 payload: { text: "secret123", label: "password", alreadyWritten: false },759 });760 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {761 type: "FIRE_CANARY_PING",762 payload: {763 webhookUrl: "https://example.com/hook",764 entryTitle: "GitHub",765 trigger: "password_copied",766 },767 });768769 sendMessageMock.mockClear();770 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;771 usernameButton.click();772 await flushView();773774 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {775 type: "COPY",776 payload: { text: "june", label: "username", alreadyWritten: false },777 });778 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {779 type: "FIRE_CANARY_PING",780 payload: {781 webhookUrl: "https://example.com/hook",782 entryTitle: "GitHub",783 trigger: "username_copied",784 },785 });786 });787788 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {789 sendMessageMock.mockResolvedValue({ ok: true });790 const onFieldSaved = vi.fn(async () => undefined);791 const container = mount(792 <EntryView793 entry={sampleEntry()}794 onDelete={vi.fn()}795 onFieldSaved={onFieldSaved}796 />797 );798799 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;800 passwordValue.click();801 await flushView();802803 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;804 inputText(passwordInput, "");805 await flushView();806807 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;808 saveButton.click();809 await flushView();810811 expect(sendMessageMock).not.toHaveBeenCalledWith(812 expect.objectContaining({ type: "UPDATE_ENTRY" })813 );814 expect(onFieldSaved).not.toHaveBeenCalled();815816 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;817 totpMain.click();818819 await vi.waitFor(() => {820 expect(sendMessageMock).toHaveBeenLastCalledWith({821 type: "COPY",822 payload: { text: "123456", label: "totp", alreadyWritten: false },823 });824 }, { timeout: 500 });825 });826827 it("Entry closes an unchanged inline editor when focus leaves it", async () => {828 sendMessageMock.mockResolvedValue({ ok: true });829 const container = mount(830 <EntryView831 entry={sampleEntry()}832 onDelete={vi.fn()}833 onFieldSaved={vi.fn(async () => undefined)}834 />835 );836837 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;838 usernameValue.click();839 await flushView();840841 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;842 usernameInput.blur();843 await flushView();844845 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();846 expect(sendMessageMock).not.toHaveBeenCalledWith(847 expect.objectContaining({ type: "UPDATE_ENTRY" })848 );849 });850851 it("Entry lets TOTP secrets be edited inline", async () => {852 sendMessageMock.mockResolvedValue({ ok: true });853 const onFieldSaved = vi.fn(async () => undefined);854 const container = mount(855 <EntryView856 entry={sampleEntry()}857 onDelete={vi.fn()}858 onFieldSaved={onFieldSaved}859 />860 );861862 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;863 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));864 await flushView();865866 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;867 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");868 await flushView();869870 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;871 saveButton.click();872 await flushView();873874 expect(sendMessageMock).toHaveBeenCalledWith({875 type: "UPDATE_ENTRY",876 payload: {877 id: "entry-1",878 patch: { totp: "NB2W45DFOIZA====" },879 },880 });881 expect(onFieldSaved).toHaveBeenCalled();882 });883884 it("Vault seeds and focuses search when a search seed is provided", async () => {885 const container = mount(886 <VaultView887 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}888 sortOrder="url-first"889 searchSeed="g"890 searchSeedNonce={1}891 onSelectEntry={vi.fn()}892 onSortOrderChange={vi.fn()}893 onAdd={vi.fn()}894 onClose={vi.fn()}895 />896 );897898 await flushView();899900 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;901 expect(search.value).toBe("g");902 expect(document.activeElement).toBe(search);903 });904905 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {906 parseImportSourceMock.mockResolvedValue(907 sampleImportResult({908 entries: [909 {910 type: "login",911 title: "Imported site",912 username: "imported-user",913 password: "imported-pass",914 url: "https://imported.test",915 },916 ],917 })918 );919 sendMessageMock.mockImplementation(async (msg: unknown) => {920 const type = (msg as { type?: string }).type;921 if (type === "GET_ENTRIES") {922 return {923 ok: true,924 data: [925 sampleEntry({926 id: "existing-1",927 title: "Imported site",928 username: "imported-user",929 url: "https://imported.test",930 }),931 ],932 };933 }934 if (type === "BATCH_ADD_ENTRIES") {935 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };936 }937 return { ok: true };938 });939 const onImport = vi.fn(async () => undefined);940 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);941 await flushView();942 sendMessageMock.mockClear();943944 await selectImportFile(container);945 findButton(container, "Import 1").click();946 await flushView();947948 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);949 expect(sendMessageMock).toHaveBeenCalledWith({950 type: "BATCH_ADD_ENTRIES",951 payload: {952 entries: [953 expect.objectContaining({954 title: "Imported site",955 username: "imported-user",956 password: "imported-pass",957 url: "https://imported.test",958 }),959 ],960 },961 });962 expect(onImport).toHaveBeenCalled();963 expect(container.textContent).toContain("1 entry appears to be duplicates");964 });965966 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {967 parseImportSourceMock.mockResolvedValue(sampleImportResult());968 sendMessageMock.mockImplementation(async (msg: unknown) => {969 const type = (msg as { type?: string }).type;970 if (type === "GET_ENTRIES") {971 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };972 }973 if (type === "CLEAR_VAULT_ENTRIES") {974 return { ok: true };975 }976 if (type === "BATCH_ADD_ENTRIES") {977 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };978 }979 return { ok: true };980 });981 const onImport = vi.fn(async () => undefined);982 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);983 await flushView();984 sendMessageMock.mockClear();985986 await selectImportFile(container);987 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();988 await flushView();989990 findButton(container, "Replace vault with 1").click();991 await flushView();992993 expect(sendMessageMock).not.toHaveBeenCalledWith(994 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })995 );996 expect(container.textContent).toContain("Replace forever? This cannot be undone.");997998 findButton(container, "Yes, replace forever").click();999 await flushView();10001001 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1002 expect(sendMessageMock).toHaveBeenCalledWith({1003 type: "BATCH_ADD_ENTRIES",1004 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1005 });1006 expect(onImport).toHaveBeenCalled();1007 });10081009 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1010 parseImportSourceMock.mockResolvedValue(sampleImportResult());1011 sendMessageMock.mockImplementation(async (msg: unknown) => {1012 const type = (msg as { type?: string }).type;1013 if (type === "CREATE_VAULT") return { ok: true };1014 if (type === "BATCH_ADD_ENTRIES") {1015 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1016 }1017 return { ok: true };1018 });1019 const onImport = vi.fn(async () => undefined);1020 const container = mount(1021 <ImportView1022 mode="onboarding"1023 replacesExistingVault={true}1024 onBack={vi.fn()}1025 onImport={onImport}1026 />1027 );10281029 await selectImportFile(container);10301031 const createButton = findButton(container, "Create vault + import 1");1032 expect(createButton.disabled).toBe(true);1033 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1034 expect(container.textContent).toContain("Use at least 8 characters");10351036 const masterInput = container.querySelector(1037 'input[placeholder="new master password"]'1038 ) as HTMLInputElement;1039 const confirmInput = container.querySelector(1040 'input[placeholder="confirm master password"]'1041 ) as HTMLInputElement;1042 const revealButton = container.querySelector(1043 'button[aria-label="Show passwords"]'1044 ) as HTMLButtonElement;1045 expect(revealButton).toBeTruthy();1046 revealButton.click();1047 await flushView();1048 expect(masterInput.type).toBe("text");1049 expect(confirmInput.type).toBe("text");10501051 inputText(masterInput, "fresh-master-password");1052 inputText(confirmInput, "fresh-master-password");1053 await flushView();10541055 expect(createButton.disabled).toBe(false);1056 createButton.click();1057 await flushView();10581059 expect(sendMessageMock).toHaveBeenCalledWith({1060 type: "CREATE_VAULT",1061 payload: { password: "fresh-master-password" },1062 });1063 expect(sendMessageMock).toHaveBeenCalledWith({1064 type: "BATCH_ADD_ENTRIES",1065 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1066 });1067 expect(onImport).toHaveBeenCalled();1068 });10691070 it("Import encrypted backups reparse with the backup password before import", async () => {1071 parseImportSourceMock1072 .mockResolvedValueOnce(1073 sampleImportResult({1074 format: "peach-json",1075 label: "Peach encrypted backup",1076 entries: [],1077 totalItems: 0,1078 requiresPassword: true,1079 })1080 )1081 .mockResolvedValueOnce(1082 sampleImportResult({1083 format: "peach-json",1084 label: "Peach encrypted backup",1085 })1086 );1087 sendMessageMock.mockImplementation(async (msg: unknown) => {1088 const type = (msg as { type?: string }).type;1089 if (type === "GET_ENTRIES") return { ok: true, data: [] };1090 if (type === "BATCH_ADD_ENTRIES") {1091 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1092 }1093 return { ok: true };1094 });1095 const onImport = vi.fn(async () => undefined);1096 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1097 await flushView();1098 sendMessageMock.mockClear();10991100 await selectImportFile(container, '{"type":"peach-export"}');1101 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1102 inputText(password, "backup-passphrase");1103 pressEnter(password);1104 await flushView();1105 await flushView();11061107 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1108 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");11091110 findButton(container, "Import 1").click();1111 await flushView();11121113 expect(sendMessageMock).toHaveBeenCalledWith({1114 type: "BATCH_ADD_ENTRIES",1115 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1116 });1117 expect(onImport).toHaveBeenCalled();1118 });1119});