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 { LockView } from "./Lock";9import { ExportView } from "./Export";10import { SettingsView } from "./Settings";11import { DEFAULT_BINDINGS } from "../../shared/shortcuts";12import { EntryView } from "./Entry";13import { ImportView } from "./Import";14import { VaultView } from "./Vault";15import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1617const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();18const triggerRingMock = vi.fn();19const triggerPipPulseMock = vi.fn();20const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();21const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2223vi.mock("../App", () => ({24 sendMessage: (msg: unknown) => sendMessageMock(msg),25 triggerRing: () => triggerRingMock(),26 triggerPipPulse: () => triggerPipPulseMock(),27}));2829vi.mock("../../shared/import", () => ({30 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),31}));3233vi.mock("../../shared/favicon", async (importOriginal) => {34 const actual = await importOriginal<typeof import("../../shared/favicon")>();35 return { ...actual, faviconUrl: () => null };36});3738vi.mock("../../crypto/totp", () => ({39 generateTOTP: () => ({ code: "123 456", remaining: 24 }),40 parseOtpauthURI: (value: string) =>41 value.startsWith("otpauth://")42 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }43 : null,44}));4546vi.mock("../components/GeneratorDrawer", () => ({47 GeneratorDrawer: () => null,48}));4950vi.mock("zxcvbn", () => ({51 default: (value: string) => zxcvbnMock(value),52}));5354describe("popup contract tests", () => {55 const disposers: Array<() => void> = [];5657 beforeEach(() => {58 document.body.innerHTML = "";59 sendMessageMock.mockReset();60 triggerRingMock.mockReset();61 triggerPipPulseMock.mockReset();62 parseImportSourceMock.mockReset();63 zxcvbnMock.mockReset();64 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));65 parseImportSourceMock.mockResolvedValue({66 format: "unknown",67 label: "Unknown format",68 entries: [],69 totalItems: 0,70 skippedItems: 0,71 requiresPassword: false,72 });7374 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {75 return window.setTimeout(() => cb(0), 0) as unknown as number;76 });77 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));78 vi.stubGlobal("URL", {79 createObjectURL: vi.fn(() => "blob:peach-test"),80 revokeObjectURL: vi.fn(),81 });82 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8384 (globalThis as { chrome?: typeof chrome }).chrome = {85 runtime: {86 getManifest: () => ({ version: "1.4.47" }),87 onMessage: { addListener: vi.fn() },88 },89 } as unknown as typeof chrome;90 });9192 afterEach(() => {93 while (disposers.length > 0) {94 disposers.pop()?.();95 }96 vi.unstubAllGlobals();97 });9899 function mount(view: JSX.Element) {100 const mounted = renderView(view);101 disposers.push(mounted.dispose);102 return mounted.container;103 }104105 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {106 return {107 id: "entry-1",108 type: "login",109 title: "GitHub",110 username: "june",111 password: "secret123",112 url: "https://github.com",113 totp: "JBSWY3DPEHPK3PXP",114 canary: {115 webhookUrl: "https://example.com/hook",116 },117 createdAt: 1,118 modifiedAt: 1,119 ...overrides,120 };121 }122123 function sampleImportResult(124 overrides: Partial<ImportParseResult> = {}125 ): ImportParseResult {126 return {127 format: "csv",128 label: "CSV import",129 entries: [130 {131 type: "login",132 title: "Imported site",133 username: "imported-user",134 password: "imported-pass",135 url: "https://imported.test",136 },137 ],138 totalItems: 1,139 skippedItems: 0,140 requiresPassword: false,141 ...overrides,142 };143 }144145 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {146 return {147 entriesCount: 1,148 breachMonitoring: true,149 lockOnClose: true,150 autoLockMinutes: 5,151 trashRetentionDays: 30,152 theme: "dark" as const,153 accentColor: "peach" as const,154 textSize: "medium" as const,155 reduceMotion: false,156 onBreachMonitoringChange: vi.fn(),157 onLockOnCloseChange: vi.fn(),158 onAutoLockMinutesChange: vi.fn(),159 onTrashRetentionChange: vi.fn(),160 onThemeChange: vi.fn(),161 onAccentChange: vi.fn(),162 onTextSizeChange: vi.fn(),163 onReduceMotionChange: vi.fn(),164 shortcuts: DEFAULT_BINDINGS,165 onShortcutsChange: vi.fn(),166 onBack: vi.fn(),167 onImport: vi.fn(),168 onExport: vi.fn(),169 onBreachCheckDone: vi.fn(),170 ...overrides,171 };172 }173174 async function selectImportFile(175 container: HTMLElement,176 contents = '{"export":"test"}'177 ) {178 179 180 181 const fileCard = findButton(container, "Choose file →");182 if (fileCard) {183 fileCard.click();184 await flushView();185 }186 const input = container.querySelector('input[type="file"]') as HTMLInputElement;187 const file = new File([contents], "import.json", { type: "application/json" });188 Object.defineProperty(input, "files", {189 configurable: true,190 value: [file],191 });192 input.dispatchEvent(new Event("change", { bubbles: true }));193 await flushView();194 await flushView();195 }196197 function findButton(container: HTMLElement, text: string): HTMLButtonElement {198 return Array.from(container.querySelectorAll("button")).find(199 (candidate) => candidate.textContent?.trim() === text200 ) as HTMLButtonElement;201 }202203 it("Lock sends UNLOCK with the entered passphrase", async () => {204 sendMessageMock.mockResolvedValue({ ok: true });205 const onUnlock = vi.fn(async () => undefined);206 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);207208 const password = container.querySelector("#unlock-password") as HTMLInputElement;209 inputText(password, "correct horse battery staple");210 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();211 await flushView();212213 expect(sendMessageMock).toHaveBeenCalledWith({214 type: "UNLOCK",215 payload: { password: "correct horse battery staple" },216 });217 expect(onUnlock).toHaveBeenCalled();218 });219220 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {221 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);222 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;223 const password = container.querySelector("#master-password") as HTMLInputElement;224 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;225226 inputText(password, "short");227 inputText(confirm, "short");228 await flushView();229 expect(createButton.disabled).toBe(true);230231 zxcvbnMock.mockImplementation(() => ({ score: 1 }));232 inputText(password, "weakweakweak");233 inputText(confirm, "weakweakweak");234 await flushView();235 expect(createButton.disabled).toBe(true);236 });237238 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {239 240 sendMessageMock.mockImplementation(241 async (msg: unknown) => {242 const m = msg as { type: string; payload?: { password?: string } };243 if (m.type === "CREATE_VAULT") return { ok: true };244 if (m.type === "GET_RECOVERY_WORDS") {245 246 return {247 ok: true,248 data: [249 "abandon", "ability", "able", "about", "above", "absent",250 "absorb", "abstract", "absurd", "abuse", "access", "accident",251 ],252 };253 }254 return { ok: true };255 }256 );257 zxcvbnMock.mockImplementation(() => ({ score: 3 }));258 const onUnlock = vi.fn(async () => undefined);259 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);260261 const password = container.querySelector("#master-password") as HTMLInputElement;262 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;263 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;264265 inputText(password, "correct horse battery staple");266 inputText(confirm, "correct horse battery staple");267 await flushView();268 expect(createButton.disabled).toBe(false);269270 pressEnter(confirm);271 await flushView();272273 expect(sendMessageMock).toHaveBeenCalledWith({274 type: "CREATE_VAULT",275 payload: { password: "correct horse battery staple" },276 });277 278 279 expect(onUnlock).not.toHaveBeenCalled();280 });281282 it("Export menu surfaces both backup formats", async () => {283 const container = mount(<ExportView onBack={vi.fn()} />);284 await flushView();285286 expect(container.textContent).toContain("PeachScript");287 expect(container.textContent).toContain("CSV");288 });289290 it("Lock onboarding restore options include Peach Codex", async () => {291 const onOpenCodexRestore = vi.fn();292 const container = mount(293 <LockView294 hasVault={false}295 ready={true}296 onUnlock={vi.fn()}297 onOpenCodexRestore={onOpenCodexRestore}298 />299 );300301 const restoreButton = Array.from(container.querySelectorAll("button")).find(302 (candidate) => candidate.textContent?.includes("restore from backup")303 ) as HTMLButtonElement;304 restoreButton.click();305 await flushView();306307 expect(container.textContent).toContain("PeachScript");308 expect(container.textContent).toContain("Codex");309 expect(container.textContent).toContain("CSV");310311 const codexTab = Array.from(container.querySelectorAll("button")).find(312 (candidate) => candidate.textContent?.trim() === "Codex"313 ) as HTMLButtonElement;314 codexTab.click();315 await flushView();316317 expect(container.textContent).toContain("Restore from Peach Codex");318 expect(container.textContent).toContain("Scan printed Codex pages");319320 const openCodexButton = Array.from(container.querySelectorAll("button")).find(321 (candidate) => candidate.textContent?.trim() === "open Codex restore →"322 ) as HTMLButtonElement;323 openCodexButton.click();324325 expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);326 });327328 it("Settings requires current, new, and confirm passwords before enabling save", async () => {329 sendMessageMock.mockResolvedValue({ ok: true, data: null });330 const container = mount(331 <SettingsView332 entriesCount={1}333 breachMonitoring={true}334 lockOnClose={true}335 autoLockMinutes={5}336 trashRetentionDays={30}337 theme="dark"338 accentColor="peach"339 textSize="medium"340 reduceMotion={false}341 onBreachMonitoringChange={vi.fn()}342 onLockOnCloseChange={vi.fn()}343 onAutoLockMinutesChange={vi.fn()}344 onTrashRetentionChange={vi.fn()}345 onThemeChange={vi.fn()}346 onAccentChange={vi.fn()}347 onTextSizeChange={vi.fn()}348 onReduceMotionChange={vi.fn()}349 shortcuts={DEFAULT_BINDINGS}350 onShortcutsChange={vi.fn()}351 onBack={vi.fn()}352 onImport={vi.fn()}353 onExport={vi.fn()}354 onBreachCheckDone={vi.fn()}355 />356 );357 await flushView();358359 expect(container.querySelectorAll(".stk-card").length).toBe(7);360 361 362 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");363364 const changeButton = Array.from(container.querySelectorAll("button")).find(365 (candidate) => candidate.textContent?.trim() === "change"366 ) as HTMLButtonElement;367 changeButton.click();368 await flushView();369370 const saveButton = Array.from(container.querySelectorAll("button")).find(371 (candidate) => candidate.textContent?.trim() === "save new password"372 ) as HTMLButtonElement;373 expect(saveButton.disabled).toBe(true);374375 const inputs = container.querySelectorAll('input[type="password"]');376 inputText(inputs[0] as HTMLInputElement, "current-pass");377 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");378 await flushView();379 expect(saveButton.disabled).toBe(true);380381 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");382 await flushView();383 expect(saveButton.disabled).toBe(false);384 });385386 it("Settings feedback highlights the selected category and hides the form after submit", async () => {387 sendMessageMock.mockResolvedValue({ ok: true, data: null });388 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({389 ok: true,390 json: async () => ({ success: true }),391 }));392 vi.stubGlobal("fetch", fetchMock);393394 const container = mount(395 <SettingsView396 entriesCount={1}397 breachMonitoring={true}398 lockOnClose={true}399 autoLockMinutes={5}400 trashRetentionDays={30}401 theme="dark"402 accentColor="peach"403 textSize="medium"404 reduceMotion={false}405 initialSection="feedback"406 onBreachMonitoringChange={vi.fn()}407 onLockOnCloseChange={vi.fn()}408 onAutoLockMinutesChange={vi.fn()}409 onTrashRetentionChange={vi.fn()}410 onThemeChange={vi.fn()}411 onAccentChange={vi.fn()}412 onTextSizeChange={vi.fn()}413 onReduceMotionChange={vi.fn()}414 shortcuts={DEFAULT_BINDINGS}415 onShortcutsChange={vi.fn()}416 onBack={vi.fn()}417 onImport={vi.fn()}418 onExport={vi.fn()}419 onBreachCheckDone={vi.fn()}420 />421 );422 await flushView();423424 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;425 expect(feedbackCard.classList.contains("is-active")).toBe(true);426427 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];428 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;429 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;430 expect(bugButton.getAttribute("aria-pressed")).toBe("true");431 expect(featureButton.getAttribute("aria-pressed")).toBe("false");432433 featureButton.click();434 await flushView();435436 expect(bugButton.getAttribute("aria-pressed")).toBe("false");437 expect(featureButton.getAttribute("aria-pressed")).toBe("true");438 expect(featureButton.classList.contains("is-active")).toBe(true);439440 inputText(441 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,442 '<script>alert("xss")</script> Please make this easier to see & safer.'443 );444 const sendButton = Array.from(container.querySelectorAll("button")).find(445 (candidate) => candidate.textContent?.trim() === "Send"446 ) as HTMLButtonElement;447 sendButton.click();448449 await vi.waitFor(() => {450 expect(container.textContent).toContain("Thank you");451 });452453 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));454 expect(requestBody.subject).toContain("Feature request");455 expect(requestBody.message).toBe(456 "<script>alert("xss")</script> Please make this easier to see & safer."457 );458 expect(requestBody.message).not.toContain("<script>");459 expect(container.textContent).toContain("Someone will process it soon");460 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();461 expect(container.querySelector(".peach-feedback-submit")).toBeNull();462 });463464 it("Settings keeps license activation on the full-screen celebration path", async () => {465 const onLicenseChange = vi.fn(async () => undefined);466 sendMessageMock.mockImplementation(async (msg: unknown) => {467 const type = (msg as { type?: string }).type;468 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };469 if (type === "ACTIVATE_LICENSE") {470 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };471 }472 if (type === "GET_ENTRIES") return { ok: true, data: [] };473 return { ok: true, data: null };474 });475476 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);477 await flushView();478479 inputText(480 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,481 "PEACH-TEST-KEY",482 );483 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;484 apply.click();485486 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));487 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);488 expect(container.textContent).not.toContain("thank you ✦");489 });490491 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {492 sendMessageMock.mockImplementation(async (msg: unknown) => {493 const type = (msg as { type?: string }).type;494 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };495 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };496 if (type === "GET_ENTRIES") return { ok: true, data: [] };497 return { ok: true, data: null };498 });499500 const onLicenseChangeFromStar = vi.fn();501 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);502 await flushView();503 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();504 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);505 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();506507 const onLicenseChangeFromPeach = vi.fn();508 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);509 await flushView();510 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();511 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);512 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();513 });514515 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {516 sendMessageMock.mockResolvedValue({ ok: true, data: null });517 const fetchMock = vi.fn();518 const requestMock = vi.fn(async () => false);519 vi.stubGlobal("fetch", fetchMock);520 vi.stubGlobal("browser", {521 permissions: {522 getAll: vi.fn(async () => ({ data_collection: [] })),523 request: requestMock,524 },525 });526527 const container = mount(528 <SettingsView529 entriesCount={1}530 breachMonitoring={true}531 lockOnClose={true}532 autoLockMinutes={5}533 trashRetentionDays={30}534 theme="dark"535 accentColor="peach"536 textSize="medium"537 reduceMotion={false}538 initialSection="feedback"539 onBreachMonitoringChange={vi.fn()}540 onLockOnCloseChange={vi.fn()}541 onAutoLockMinutesChange={vi.fn()}542 onTrashRetentionChange={vi.fn()}543 onThemeChange={vi.fn()}544 onAccentChange={vi.fn()}545 onTextSizeChange={vi.fn()}546 onReduceMotionChange={vi.fn()}547 shortcuts={DEFAULT_BINDINGS}548 onShortcutsChange={vi.fn()}549 onBack={vi.fn()}550 onImport={vi.fn()}551 onExport={vi.fn()}552 onBreachCheckDone={vi.fn()}553 />554 );555 await flushView();556557 inputText(558 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,559 "Please improve the feedback flow."560 );561 const sendButton = Array.from(container.querySelectorAll("button")).find(562 (candidate) => candidate.textContent?.trim() === "Send"563 ) as HTMLButtonElement;564 sendButton.click();565566 await vi.waitFor(() => {567 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });568 });569 expect(fetchMock).not.toHaveBeenCalled();570 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();571 });572573 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {574 sendMessageMock.mockImplementation(async (msg: unknown) => {575 const m = msg as { type: string };576 if (m.type === "GET_RECOVERY_WORDS") {577 return {578 ok: true,579 data: [580 "abandon", "ability", "able", "about", "above", "absent",581 "absorb", "abstract", "absurd", "abuse", "access", "accident",582 ],583 };584 }585 return { ok: true, data: null };586 });587 const onAutoLockMinutesChange = vi.fn();588 const onThemeChange = vi.fn();589 const container = mount(590 <SettingsView591 entriesCount={1}592 breachMonitoring={true}593 lockOnClose={true}594 autoLockMinutes={5}595 trashRetentionDays={30}596 theme="dark"597 accentColor="peach"598 textSize="medium"599 reduceMotion={false}600 onBreachMonitoringChange={vi.fn()}601 onLockOnCloseChange={vi.fn()}602 onAutoLockMinutesChange={onAutoLockMinutesChange}603 onTrashRetentionChange={vi.fn()}604 onThemeChange={onThemeChange}605 onAccentChange={vi.fn()}606 onTextSizeChange={vi.fn()}607 onReduceMotionChange={vi.fn()}608 shortcuts={DEFAULT_BINDINGS}609 onShortcutsChange={vi.fn()}610 onBack={vi.fn()}611 onImport={vi.fn()}612 onExport={vi.fn()}613 onBreachCheckDone={vi.fn()}614 />615 );616 await flushView();617 sendMessageMock.mockClear();618619 620 const selects = container.querySelectorAll("select");621 changeSelect(selects[0] as HTMLSelectElement, "15");622 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);623624 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;625 appearanceCard.click();626 await flushView();627 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");628 const themeButton = Array.from(container.querySelectorAll("button")).find(629 (candidate) => candidate.textContent?.trim() === "Switch to light"630 ) as HTMLButtonElement;631 themeButton.click();632 expect(onThemeChange).toHaveBeenCalledWith("light");633634 635 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;636 protectionCard.click();637 await flushView();638639 const changeButton = Array.from(container.querySelectorAll("button")).find(640 (candidate) => candidate.textContent?.trim() === "change"641 ) as HTMLButtonElement;642 changeButton.click();643 await flushView();644645 const inputs = container.querySelectorAll('input[type="password"]');646 inputText(inputs[0] as HTMLInputElement, "current-pass");647 inputText(inputs[1] as HTMLInputElement, "new-password-123");648 inputText(inputs[2] as HTMLInputElement, "new-password-123");649 await flushView();650651 const saveButton = Array.from(container.querySelectorAll("button")).find(652 (candidate) => candidate.textContent?.trim() === "save new password"653 ) as HTMLButtonElement;654 saveButton.click();655 await flushView();656657 expect(sendMessageMock).toHaveBeenCalledWith({658 type: "CHANGE_PASSWORD",659 payload: {660 currentPassword: "current-pass",661 newPassword: "new-password-123",662 },663 });664665 666 expect(container.textContent).toContain("save your recovery words");667 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });668 });669670 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {671 sendMessageMock.mockResolvedValue({ ok: true });672 const container = mount(673 <EntryView674 entry={sampleEntry()}675 onDelete={vi.fn()}676 onFieldSaved={vi.fn(async () => undefined)}677 />678 );679680 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;681 passwordButton.click();682 await flushView();683684 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {685 type: "COPY",686 payload: { text: "secret123", label: "password", alreadyWritten: false },687 });688 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {689 type: "FIRE_CANARY_PING",690 payload: {691 webhookUrl: "https://example.com/hook",692 entryTitle: "GitHub",693 trigger: "password_copied",694 },695 });696697 sendMessageMock.mockClear();698 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;699 usernameButton.click();700 await flushView();701702 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {703 type: "COPY",704 payload: { text: "june", label: "username", alreadyWritten: false },705 });706 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {707 type: "FIRE_CANARY_PING",708 payload: {709 webhookUrl: "https://example.com/hook",710 entryTitle: "GitHub",711 trigger: "username_copied",712 },713 });714 });715716 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {717 sendMessageMock.mockResolvedValue({ ok: true });718 const onFieldSaved = vi.fn(async () => undefined);719 const container = mount(720 <EntryView721 entry={sampleEntry()}722 onDelete={vi.fn()}723 onFieldSaved={onFieldSaved}724 />725 );726727 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;728 passwordValue.click();729 await flushView();730731 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;732 inputText(passwordInput, "");733 await flushView();734735 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;736 saveButton.click();737 await flushView();738739 expect(sendMessageMock).not.toHaveBeenCalledWith(740 expect.objectContaining({ type: "UPDATE_ENTRY" })741 );742 expect(onFieldSaved).not.toHaveBeenCalled();743744 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;745 totpMain.click();746747 await vi.waitFor(() => {748 expect(sendMessageMock).toHaveBeenLastCalledWith({749 type: "COPY",750 payload: { text: "123456", label: "totp", alreadyWritten: false },751 });752 }, { timeout: 500 });753 });754755 it("Entry closes an unchanged inline editor when focus leaves it", async () => {756 sendMessageMock.mockResolvedValue({ ok: true });757 const container = mount(758 <EntryView759 entry={sampleEntry()}760 onDelete={vi.fn()}761 onFieldSaved={vi.fn(async () => undefined)}762 />763 );764765 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;766 usernameValue.click();767 await flushView();768769 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;770 usernameInput.blur();771 await flushView();772773 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();774 expect(sendMessageMock).not.toHaveBeenCalledWith(775 expect.objectContaining({ type: "UPDATE_ENTRY" })776 );777 });778779 it("Entry lets TOTP secrets be edited inline", async () => {780 sendMessageMock.mockResolvedValue({ ok: true });781 const onFieldSaved = vi.fn(async () => undefined);782 const container = mount(783 <EntryView784 entry={sampleEntry()}785 onDelete={vi.fn()}786 onFieldSaved={onFieldSaved}787 />788 );789790 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;791 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));792 await flushView();793794 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;795 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");796 await flushView();797798 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;799 saveButton.click();800 await flushView();801802 expect(sendMessageMock).toHaveBeenCalledWith({803 type: "UPDATE_ENTRY",804 payload: {805 id: "entry-1",806 patch: { totp: "NB2W45DFOIZA====" },807 },808 });809 expect(onFieldSaved).toHaveBeenCalled();810 });811812 it("Vault seeds and focuses search when a search seed is provided", async () => {813 const container = mount(814 <VaultView815 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}816 sortOrder="url-first"817 searchSeed="g"818 searchSeedNonce={1}819 onSelectEntry={vi.fn()}820 onSortOrderChange={vi.fn()}821 onAdd={vi.fn()}822 onClose={vi.fn()}823 />824 );825826 await flushView();827828 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;829 expect(search.value).toBe("g");830 expect(document.activeElement).toBe(search);831 });832833 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {834 parseImportSourceMock.mockResolvedValue(835 sampleImportResult({836 entries: [837 {838 type: "login",839 title: "Imported site",840 username: "imported-user",841 password: "imported-pass",842 url: "https://imported.test",843 },844 ],845 })846 );847 sendMessageMock.mockImplementation(async (msg: unknown) => {848 const type = (msg as { type?: string }).type;849 if (type === "GET_ENTRIES") {850 return {851 ok: true,852 data: [853 sampleEntry({854 id: "existing-1",855 title: "Imported site",856 username: "imported-user",857 url: "https://imported.test",858 }),859 ],860 };861 }862 if (type === "BATCH_ADD_ENTRIES") {863 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };864 }865 return { ok: true };866 });867 const onImport = vi.fn(async () => undefined);868 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);869 await flushView();870 sendMessageMock.mockClear();871872 await selectImportFile(container);873 findButton(container, "Import 1").click();874 await flushView();875876 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);877 expect(sendMessageMock).toHaveBeenCalledWith({878 type: "BATCH_ADD_ENTRIES",879 payload: {880 entries: [881 expect.objectContaining({882 title: "Imported site",883 username: "imported-user",884 password: "imported-pass",885 url: "https://imported.test",886 }),887 ],888 },889 });890 expect(onImport).toHaveBeenCalled();891 expect(container.textContent).toContain("1 entry appears to be duplicates");892 });893894 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {895 parseImportSourceMock.mockResolvedValue(sampleImportResult());896 sendMessageMock.mockImplementation(async (msg: unknown) => {897 const type = (msg as { type?: string }).type;898 if (type === "GET_ENTRIES") {899 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };900 }901 if (type === "CLEAR_VAULT_ENTRIES") {902 return { ok: true };903 }904 if (type === "BATCH_ADD_ENTRIES") {905 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };906 }907 return { ok: true };908 });909 const onImport = vi.fn(async () => undefined);910 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);911 await flushView();912 sendMessageMock.mockClear();913914 await selectImportFile(container);915 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();916 await flushView();917918 findButton(container, "Replace vault with 1").click();919 await flushView();920921 expect(sendMessageMock).not.toHaveBeenCalledWith(922 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })923 );924 expect(container.textContent).toContain("Replace forever? This cannot be undone.");925926 findButton(container, "Yes, replace forever").click();927 await flushView();928929 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });930 expect(sendMessageMock).toHaveBeenCalledWith({931 type: "BATCH_ADD_ENTRIES",932 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },933 });934 expect(onImport).toHaveBeenCalled();935 });936937 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {938 parseImportSourceMock.mockResolvedValue(sampleImportResult());939 sendMessageMock.mockImplementation(async (msg: unknown) => {940 const type = (msg as { type?: string }).type;941 if (type === "CREATE_VAULT") return { ok: true };942 if (type === "BATCH_ADD_ENTRIES") {943 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };944 }945 return { ok: true };946 });947 const onImport = vi.fn(async () => undefined);948 const container = mount(949 <ImportView950 mode="onboarding"951 replacesExistingVault={true}952 onBack={vi.fn()}953 onImport={onImport}954 />955 );956957 await selectImportFile(container);958959 const createButton = findButton(container, "Create vault + import 1");960 expect(createButton.disabled).toBe(true);961 expect(container.textContent).toContain("Restoring now replaces the locked local vault");962 expect(container.textContent).toContain("Use at least 8 characters");963964 const masterInput = container.querySelector(965 'input[placeholder="new master password"]'966 ) as HTMLInputElement;967 const confirmInput = container.querySelector(968 'input[placeholder="confirm master password"]'969 ) as HTMLInputElement;970 const revealButton = container.querySelector(971 'button[aria-label="Show passwords"]'972 ) as HTMLButtonElement;973 expect(revealButton).toBeTruthy();974 revealButton.click();975 await flushView();976 expect(masterInput.type).toBe("text");977 expect(confirmInput.type).toBe("text");978979 inputText(masterInput, "fresh-master-password");980 inputText(confirmInput, "fresh-master-password");981 await flushView();982983 expect(createButton.disabled).toBe(false);984 createButton.click();985 await flushView();986987 expect(sendMessageMock).toHaveBeenCalledWith({988 type: "CREATE_VAULT",989 payload: { password: "fresh-master-password" },990 });991 expect(sendMessageMock).toHaveBeenCalledWith({992 type: "BATCH_ADD_ENTRIES",993 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },994 });995 expect(onImport).toHaveBeenCalled();996 });997998 it("Import encrypted backups reparse with the backup password before import", async () => {999 parseImportSourceMock1000 .mockResolvedValueOnce(1001 sampleImportResult({1002 format: "peach-json",1003 label: "Peach encrypted backup",1004 entries: [],1005 totalItems: 0,1006 requiresPassword: true,1007 })1008 )1009 .mockResolvedValueOnce(1010 sampleImportResult({1011 format: "peach-json",1012 label: "Peach encrypted backup",1013 })1014 );1015 sendMessageMock.mockImplementation(async (msg: unknown) => {1016 const type = (msg as { type?: string }).type;1017 if (type === "GET_ENTRIES") return { ok: true, data: [] };1018 if (type === "BATCH_ADD_ENTRIES") {1019 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1020 }1021 return { ok: true };1022 });1023 const onImport = vi.fn(async () => undefined);1024 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1025 await flushView();1026 sendMessageMock.mockClear();10271028 await selectImportFile(container, '{"type":"peach-export"}');1029 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1030 inputText(password, "backup-passphrase");1031 pressEnter(password);1032 await flushView();1033 await flushView();10341035 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1036 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");10371038 findButton(container, "Import 1").click();1039 await flushView();10401041 expect(sendMessageMock).toHaveBeenCalledWith({1042 type: "BATCH_ADD_ENTRIES",1043 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1044 });1045 expect(onImport).toHaveBeenCalled();1046 });1047});