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", () => ({34 faviconUrl: () => null,35}));3637vi.mock("../../crypto/totp", () => ({38 generateTOTP: () => ({ code: "123 456", remaining: 24 }),39 parseOtpauthURI: (value: string) =>40 value.startsWith("otpauth://")41 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }42 : null,43}));4445vi.mock("../components/GeneratorDrawer", () => ({46 GeneratorDrawer: () => null,47}));4849vi.mock("zxcvbn", () => ({50 default: (value: string) => zxcvbnMock(value),51}));5253describe("popup contract tests", () => {54 const disposers: Array<() => void> = [];5556 beforeEach(() => {57 document.body.innerHTML = "";58 sendMessageMock.mockReset();59 triggerRingMock.mockReset();60 triggerPipPulseMock.mockReset();61 parseImportSourceMock.mockReset();62 zxcvbnMock.mockReset();63 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));64 parseImportSourceMock.mockResolvedValue({65 format: "unknown",66 label: "Unknown format",67 entries: [],68 totalItems: 0,69 skippedItems: 0,70 requiresPassword: false,71 });7273 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {74 return window.setTimeout(() => cb(0), 0) as unknown as number;75 });76 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));77 vi.stubGlobal("URL", {78 createObjectURL: vi.fn(() => "blob:peach-test"),79 revokeObjectURL: vi.fn(),80 });81 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8283 (globalThis as { chrome?: typeof chrome }).chrome = {84 runtime: {85 getManifest: () => ({ version: "1.4.47" }),86 onMessage: { addListener: vi.fn() },87 },88 } as unknown as typeof chrome;89 });9091 afterEach(() => {92 while (disposers.length > 0) {93 disposers.pop()?.();94 }95 vi.unstubAllGlobals();96 });9798 function mount(view: JSX.Element) {99 const mounted = renderView(view);100 disposers.push(mounted.dispose);101 return mounted.container;102 }103104 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {105 return {106 id: "entry-1",107 type: "login",108 title: "GitHub",109 username: "june",110 password: "secret123",111 url: "https://github.com",112 totp: "JBSWY3DPEHPK3PXP",113 canary: {114 webhookUrl: "https://example.com/hook",115 },116 createdAt: 1,117 modifiedAt: 1,118 ...overrides,119 };120 }121122 function sampleImportResult(123 overrides: Partial<ImportParseResult> = {}124 ): ImportParseResult {125 return {126 format: "csv",127 label: "CSV import",128 entries: [129 {130 type: "login",131 title: "Imported site",132 username: "imported-user",133 password: "imported-pass",134 url: "https://imported.test",135 },136 ],137 totalItems: 1,138 skippedItems: 0,139 requiresPassword: false,140 ...overrides,141 };142 }143144 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {145 return {146 entriesCount: 1,147 breachMonitoring: true,148 lockOnClose: true,149 autoLockMinutes: 5,150 trashRetentionDays: 30,151 theme: "dark" as const,152 accentColor: "peach" as const,153 textSize: "medium" as const,154 reduceMotion: false,155 onBreachMonitoringChange: vi.fn(),156 onLockOnCloseChange: vi.fn(),157 onAutoLockMinutesChange: vi.fn(),158 onTrashRetentionChange: vi.fn(),159 onThemeChange: vi.fn(),160 onAccentChange: vi.fn(),161 onTextSizeChange: vi.fn(),162 onReduceMotionChange: vi.fn(),163 shortcuts: DEFAULT_BINDINGS,164 onShortcutsChange: vi.fn(),165 onBack: vi.fn(),166 onImport: vi.fn(),167 onExport: vi.fn(),168 onBreachCheckDone: vi.fn(),169 ...overrides,170 };171 }172173 async function selectImportFile(174 container: HTMLElement,175 contents = '{"export":"test"}'176 ) {177 178 179 180 const fileCard = findButton(container, "Choose file →");181 if (fileCard) {182 fileCard.click();183 await flushView();184 }185 const input = container.querySelector('input[type="file"]') as HTMLInputElement;186 const file = new File([contents], "import.json", { type: "application/json" });187 Object.defineProperty(input, "files", {188 configurable: true,189 value: [file],190 });191 input.dispatchEvent(new Event("change", { bubbles: true }));192 await flushView();193 await flushView();194 }195196 function findButton(container: HTMLElement, text: string): HTMLButtonElement {197 return Array.from(container.querySelectorAll("button")).find(198 (candidate) => candidate.textContent?.trim() === text199 ) as HTMLButtonElement;200 }201202 it("Lock sends UNLOCK with the entered passphrase", async () => {203 sendMessageMock.mockResolvedValue({ ok: true });204 const onUnlock = vi.fn(async () => undefined);205 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);206207 const password = container.querySelector("#unlock-password") as HTMLInputElement;208 inputText(password, "correct horse battery staple");209 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();210 await flushView();211212 expect(sendMessageMock).toHaveBeenCalledWith({213 type: "UNLOCK",214 payload: { password: "correct horse battery staple" },215 });216 expect(onUnlock).toHaveBeenCalled();217 });218219 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {220 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);221 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;222 const password = container.querySelector("#master-password") as HTMLInputElement;223 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;224225 inputText(password, "short");226 inputText(confirm, "short");227 await flushView();228 expect(createButton.disabled).toBe(true);229230 zxcvbnMock.mockImplementation(() => ({ score: 1 }));231 inputText(password, "weakweakweak");232 inputText(confirm, "weakweakweak");233 await flushView();234 expect(createButton.disabled).toBe(true);235 });236237 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {238 239 sendMessageMock.mockImplementation(240 async (msg: unknown) => {241 const m = msg as { type: string; payload?: { password?: string } };242 if (m.type === "CREATE_VAULT") return { ok: true };243 if (m.type === "GET_RECOVERY_WORDS") {244 245 return {246 ok: true,247 data: [248 "abandon", "ability", "able", "about", "above", "absent",249 "absorb", "abstract", "absurd", "abuse", "access", "accident",250 ],251 };252 }253 return { ok: true };254 }255 );256 zxcvbnMock.mockImplementation(() => ({ score: 3 }));257 const onUnlock = vi.fn(async () => undefined);258 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);259260 const password = container.querySelector("#master-password") as HTMLInputElement;261 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;262 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;263264 inputText(password, "correct horse battery staple");265 inputText(confirm, "correct horse battery staple");266 await flushView();267 expect(createButton.disabled).toBe(false);268269 pressEnter(confirm);270 await flushView();271272 expect(sendMessageMock).toHaveBeenCalledWith({273 type: "CREATE_VAULT",274 payload: { password: "correct horse battery staple" },275 });276 277 278 expect(onUnlock).not.toHaveBeenCalled();279 });280281 it("Export menu surfaces both backup formats", async () => {282 const container = mount(<ExportView onBack={vi.fn()} />);283 await flushView();284285 expect(container.textContent).toContain("PeachScript");286 expect(container.textContent).toContain("CSV");287 });288289 it("Lock onboarding restore options include Peach Codex", async () => {290 const onOpenCodexRestore = vi.fn();291 const container = mount(292 <LockView293 hasVault={false}294 ready={true}295 onUnlock={vi.fn()}296 onOpenCodexRestore={onOpenCodexRestore}297 />298 );299300 const restoreButton = Array.from(container.querySelectorAll("button")).find(301 (candidate) => candidate.textContent?.includes("restore from backup")302 ) as HTMLButtonElement;303 restoreButton.click();304 await flushView();305306 expect(container.textContent).toContain("PeachScript");307 expect(container.textContent).toContain("Codex");308 expect(container.textContent).toContain("CSV");309310 const codexTab = Array.from(container.querySelectorAll("button")).find(311 (candidate) => candidate.textContent?.trim() === "Codex"312 ) as HTMLButtonElement;313 codexTab.click();314 await flushView();315316 expect(container.textContent).toContain("Restore from Peach Codex");317 expect(container.textContent).toContain("Scan printed Codex pages");318319 const openCodexButton = Array.from(container.querySelectorAll("button")).find(320 (candidate) => candidate.textContent?.trim() === "open Codex restore →"321 ) as HTMLButtonElement;322 openCodexButton.click();323324 expect(onOpenCodexRestore).toHaveBeenCalledTimes(1);325 });326327 it("Settings requires current, new, and confirm passwords before enabling save", async () => {328 sendMessageMock.mockResolvedValue({ ok: true, data: null });329 const container = mount(330 <SettingsView331 entriesCount={1}332 breachMonitoring={true}333 lockOnClose={true}334 autoLockMinutes={5}335 trashRetentionDays={30}336 theme="dark"337 accentColor="peach"338 textSize="medium"339 reduceMotion={false}340 onBreachMonitoringChange={vi.fn()}341 onLockOnCloseChange={vi.fn()}342 onAutoLockMinutesChange={vi.fn()}343 onTrashRetentionChange={vi.fn()}344 onThemeChange={vi.fn()}345 onAccentChange={vi.fn()}346 onTextSizeChange={vi.fn()}347 onReduceMotionChange={vi.fn()}348 shortcuts={DEFAULT_BINDINGS}349 onShortcutsChange={vi.fn()}350 onBack={vi.fn()}351 onImport={vi.fn()}352 onExport={vi.fn()}353 onBreachCheckDone={vi.fn()}354 />355 );356 await flushView();357358 expect(container.querySelectorAll(".stk-card").length).toBe(7);359 360 361 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");362363 const changeButton = Array.from(container.querySelectorAll("button")).find(364 (candidate) => candidate.textContent?.trim() === "change"365 ) as HTMLButtonElement;366 changeButton.click();367 await flushView();368369 const saveButton = Array.from(container.querySelectorAll("button")).find(370 (candidate) => candidate.textContent?.trim() === "save new password"371 ) as HTMLButtonElement;372 expect(saveButton.disabled).toBe(true);373374 const inputs = container.querySelectorAll('input[type="password"]');375 inputText(inputs[0] as HTMLInputElement, "current-pass");376 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");377 await flushView();378 expect(saveButton.disabled).toBe(true);379380 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");381 await flushView();382 expect(saveButton.disabled).toBe(false);383 });384385 it("Settings feedback highlights the selected category and hides the form after submit", async () => {386 sendMessageMock.mockResolvedValue({ ok: true, data: null });387 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({388 ok: true,389 json: async () => ({ success: true }),390 }));391 vi.stubGlobal("fetch", fetchMock);392393 const container = mount(394 <SettingsView395 entriesCount={1}396 breachMonitoring={true}397 lockOnClose={true}398 autoLockMinutes={5}399 trashRetentionDays={30}400 theme="dark"401 accentColor="peach"402 textSize="medium"403 reduceMotion={false}404 initialSection="feedback"405 onBreachMonitoringChange={vi.fn()}406 onLockOnCloseChange={vi.fn()}407 onAutoLockMinutesChange={vi.fn()}408 onTrashRetentionChange={vi.fn()}409 onThemeChange={vi.fn()}410 onAccentChange={vi.fn()}411 onTextSizeChange={vi.fn()}412 onReduceMotionChange={vi.fn()}413 shortcuts={DEFAULT_BINDINGS}414 onShortcutsChange={vi.fn()}415 onBack={vi.fn()}416 onImport={vi.fn()}417 onExport={vi.fn()}418 onBreachCheckDone={vi.fn()}419 />420 );421 await flushView();422423 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;424 expect(feedbackCard.classList.contains("is-active")).toBe(true);425426 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];427 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;428 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;429 expect(bugButton.getAttribute("aria-pressed")).toBe("true");430 expect(featureButton.getAttribute("aria-pressed")).toBe("false");431432 featureButton.click();433 await flushView();434435 expect(bugButton.getAttribute("aria-pressed")).toBe("false");436 expect(featureButton.getAttribute("aria-pressed")).toBe("true");437 expect(featureButton.classList.contains("is-active")).toBe(true);438439 inputText(440 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,441 '<script>alert("xss")</script> Please make this easier to see & safer.'442 );443 const sendButton = Array.from(container.querySelectorAll("button")).find(444 (candidate) => candidate.textContent?.trim() === "Send"445 ) as HTMLButtonElement;446 sendButton.click();447448 await vi.waitFor(() => {449 expect(container.textContent).toContain("Thank you");450 });451452 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));453 expect(requestBody.subject).toContain("Feature request");454 expect(requestBody.message).toBe(455 "<script>alert("xss")</script> Please make this easier to see & safer."456 );457 expect(requestBody.message).not.toContain("<script>");458 expect(container.textContent).toContain("Someone will process it soon");459 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();460 expect(container.querySelector(".peach-feedback-submit")).toBeNull();461 });462463 it("Settings keeps license activation on the full-screen celebration path", async () => {464 const onLicenseChange = vi.fn(async () => undefined);465 sendMessageMock.mockImplementation(async (msg: unknown) => {466 const type = (msg as { type?: string }).type;467 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };468 if (type === "ACTIVATE_LICENSE") {469 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };470 }471 if (type === "GET_ENTRIES") return { ok: true, data: [] };472 return { ok: true, data: null };473 });474475 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);476 await flushView();477478 inputText(479 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,480 "PEACH-TEST-KEY",481 );482 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;483 apply.click();484485 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));486 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);487 expect(container.textContent).not.toContain("thank you ✦");488 });489490 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {491 sendMessageMock.mockImplementation(async (msg: unknown) => {492 const type = (msg as { type?: string }).type;493 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };494 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };495 if (type === "GET_ENTRIES") return { ok: true, data: [] };496 return { ok: true, data: null };497 });498499 const onLicenseChangeFromStar = vi.fn();500 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);501 await flushView();502 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();503 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);504 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();505506 const onLicenseChangeFromPeach = vi.fn();507 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);508 await flushView();509 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();510 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);511 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();512 });513514 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {515 sendMessageMock.mockResolvedValue({ ok: true, data: null });516 const fetchMock = vi.fn();517 const requestMock = vi.fn(async () => false);518 vi.stubGlobal("fetch", fetchMock);519 vi.stubGlobal("browser", {520 permissions: {521 getAll: vi.fn(async () => ({ data_collection: [] })),522 request: requestMock,523 },524 });525526 const container = mount(527 <SettingsView528 entriesCount={1}529 breachMonitoring={true}530 lockOnClose={true}531 autoLockMinutes={5}532 trashRetentionDays={30}533 theme="dark"534 accentColor="peach"535 textSize="medium"536 reduceMotion={false}537 initialSection="feedback"538 onBreachMonitoringChange={vi.fn()}539 onLockOnCloseChange={vi.fn()}540 onAutoLockMinutesChange={vi.fn()}541 onTrashRetentionChange={vi.fn()}542 onThemeChange={vi.fn()}543 onAccentChange={vi.fn()}544 onTextSizeChange={vi.fn()}545 onReduceMotionChange={vi.fn()}546 shortcuts={DEFAULT_BINDINGS}547 onShortcutsChange={vi.fn()}548 onBack={vi.fn()}549 onImport={vi.fn()}550 onExport={vi.fn()}551 onBreachCheckDone={vi.fn()}552 />553 );554 await flushView();555556 inputText(557 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,558 "Please improve the feedback flow."559 );560 const sendButton = Array.from(container.querySelectorAll("button")).find(561 (candidate) => candidate.textContent?.trim() === "Send"562 ) as HTMLButtonElement;563 sendButton.click();564565 await vi.waitFor(() => {566 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });567 });568 expect(fetchMock).not.toHaveBeenCalled();569 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();570 });571572 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {573 sendMessageMock.mockImplementation(async (msg: unknown) => {574 const m = msg as { type: string };575 if (m.type === "GET_RECOVERY_WORDS") {576 return {577 ok: true,578 data: [579 "abandon", "ability", "able", "about", "above", "absent",580 "absorb", "abstract", "absurd", "abuse", "access", "accident",581 ],582 };583 }584 return { ok: true, data: null };585 });586 const onAutoLockMinutesChange = vi.fn();587 const onThemeChange = vi.fn();588 const container = mount(589 <SettingsView590 entriesCount={1}591 breachMonitoring={true}592 lockOnClose={true}593 autoLockMinutes={5}594 trashRetentionDays={30}595 theme="dark"596 accentColor="peach"597 textSize="medium"598 reduceMotion={false}599 onBreachMonitoringChange={vi.fn()}600 onLockOnCloseChange={vi.fn()}601 onAutoLockMinutesChange={onAutoLockMinutesChange}602 onTrashRetentionChange={vi.fn()}603 onThemeChange={onThemeChange}604 onAccentChange={vi.fn()}605 onTextSizeChange={vi.fn()}606 onReduceMotionChange={vi.fn()}607 shortcuts={DEFAULT_BINDINGS}608 onShortcutsChange={vi.fn()}609 onBack={vi.fn()}610 onImport={vi.fn()}611 onExport={vi.fn()}612 onBreachCheckDone={vi.fn()}613 />614 );615 await flushView();616 sendMessageMock.mockClear();617618 619 const selects = container.querySelectorAll("select");620 changeSelect(selects[0] as HTMLSelectElement, "15");621 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);622623 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;624 appearanceCard.click();625 await flushView();626 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");627 const themeButton = Array.from(container.querySelectorAll("button")).find(628 (candidate) => candidate.textContent?.trim() === "Switch to light"629 ) as HTMLButtonElement;630 themeButton.click();631 expect(onThemeChange).toHaveBeenCalledWith("light");632633 634 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;635 protectionCard.click();636 await flushView();637638 const changeButton = Array.from(container.querySelectorAll("button")).find(639 (candidate) => candidate.textContent?.trim() === "change"640 ) as HTMLButtonElement;641 changeButton.click();642 await flushView();643644 const inputs = container.querySelectorAll('input[type="password"]');645 inputText(inputs[0] as HTMLInputElement, "current-pass");646 inputText(inputs[1] as HTMLInputElement, "new-password-123");647 inputText(inputs[2] as HTMLInputElement, "new-password-123");648 await flushView();649650 const saveButton = Array.from(container.querySelectorAll("button")).find(651 (candidate) => candidate.textContent?.trim() === "save new password"652 ) as HTMLButtonElement;653 saveButton.click();654 await flushView();655656 expect(sendMessageMock).toHaveBeenCalledWith({657 type: "CHANGE_PASSWORD",658 payload: {659 currentPassword: "current-pass",660 newPassword: "new-password-123",661 },662 });663664 665 expect(container.textContent).toContain("save your recovery words");666 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });667 });668669 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {670 sendMessageMock.mockResolvedValue({ ok: true });671 const container = mount(672 <EntryView673 entry={sampleEntry()}674 onDelete={vi.fn()}675 onFieldSaved={vi.fn(async () => undefined)}676 />677 );678679 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;680 passwordButton.click();681 await flushView();682683 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {684 type: "COPY",685 payload: { text: "secret123", label: "password", alreadyWritten: false },686 });687 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {688 type: "FIRE_CANARY_PING",689 payload: {690 webhookUrl: "https://example.com/hook",691 entryTitle: "GitHub",692 trigger: "password_copied",693 },694 });695696 sendMessageMock.mockClear();697 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;698 usernameButton.click();699 await flushView();700701 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {702 type: "COPY",703 payload: { text: "june", label: "username", alreadyWritten: false },704 });705 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {706 type: "FIRE_CANARY_PING",707 payload: {708 webhookUrl: "https://example.com/hook",709 entryTitle: "GitHub",710 trigger: "username_copied",711 },712 });713 });714715 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {716 sendMessageMock.mockResolvedValue({ ok: true });717 const onFieldSaved = vi.fn(async () => undefined);718 const container = mount(719 <EntryView720 entry={sampleEntry()}721 onDelete={vi.fn()}722 onFieldSaved={onFieldSaved}723 />724 );725726 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;727 passwordValue.click();728 await flushView();729730 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;731 inputText(passwordInput, "");732 await flushView();733734 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;735 saveButton.click();736 await flushView();737738 expect(sendMessageMock).not.toHaveBeenCalledWith(739 expect.objectContaining({ type: "UPDATE_ENTRY" })740 );741 expect(onFieldSaved).not.toHaveBeenCalled();742743 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;744 totpMain.click();745746 await vi.waitFor(() => {747 expect(sendMessageMock).toHaveBeenLastCalledWith({748 type: "COPY",749 payload: { text: "123456", label: "totp", alreadyWritten: false },750 });751 }, { timeout: 500 });752 });753754 it("Entry closes an unchanged inline editor when focus leaves it", async () => {755 sendMessageMock.mockResolvedValue({ ok: true });756 const container = mount(757 <EntryView758 entry={sampleEntry()}759 onDelete={vi.fn()}760 onFieldSaved={vi.fn(async () => undefined)}761 />762 );763764 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;765 usernameValue.click();766 await flushView();767768 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;769 usernameInput.blur();770 await flushView();771772 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();773 expect(sendMessageMock).not.toHaveBeenCalledWith(774 expect.objectContaining({ type: "UPDATE_ENTRY" })775 );776 });777778 it("Entry lets TOTP secrets be edited inline", async () => {779 sendMessageMock.mockResolvedValue({ ok: true });780 const onFieldSaved = vi.fn(async () => undefined);781 const container = mount(782 <EntryView783 entry={sampleEntry()}784 onDelete={vi.fn()}785 onFieldSaved={onFieldSaved}786 />787 );788789 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;790 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));791 await flushView();792793 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;794 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");795 await flushView();796797 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;798 saveButton.click();799 await flushView();800801 expect(sendMessageMock).toHaveBeenCalledWith({802 type: "UPDATE_ENTRY",803 payload: {804 id: "entry-1",805 patch: { totp: "NB2W45DFOIZA====" },806 },807 });808 expect(onFieldSaved).toHaveBeenCalled();809 });810811 it("Vault seeds and focuses search when a search seed is provided", async () => {812 const container = mount(813 <VaultView814 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}815 sortOrder="url-first"816 searchSeed="g"817 searchSeedNonce={1}818 onSelectEntry={vi.fn()}819 onSortOrderChange={vi.fn()}820 onAdd={vi.fn()}821 onClose={vi.fn()}822 />823 );824825 await flushView();826827 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;828 expect(search.value).toBe("g");829 expect(document.activeElement).toBe(search);830 });831832 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {833 parseImportSourceMock.mockResolvedValue(834 sampleImportResult({835 entries: [836 {837 type: "login",838 title: "Imported site",839 username: "imported-user",840 password: "imported-pass",841 url: "https://imported.test",842 },843 ],844 })845 );846 sendMessageMock.mockImplementation(async (msg: unknown) => {847 const type = (msg as { type?: string }).type;848 if (type === "GET_ENTRIES") {849 return {850 ok: true,851 data: [852 sampleEntry({853 id: "existing-1",854 title: "Imported site",855 username: "imported-user",856 url: "https://imported.test",857 }),858 ],859 };860 }861 if (type === "BATCH_ADD_ENTRIES") {862 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };863 }864 return { ok: true };865 });866 const onImport = vi.fn(async () => undefined);867 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);868 await flushView();869 sendMessageMock.mockClear();870871 await selectImportFile(container);872 findButton(container, "Import 1").click();873 await flushView();874875 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);876 expect(sendMessageMock).toHaveBeenCalledWith({877 type: "BATCH_ADD_ENTRIES",878 payload: {879 entries: [880 expect.objectContaining({881 title: "Imported site",882 username: "imported-user",883 password: "imported-pass",884 url: "https://imported.test",885 }),886 ],887 },888 });889 expect(onImport).toHaveBeenCalled();890 expect(container.textContent).toContain("1 entry appears to be duplicates");891 });892893 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {894 parseImportSourceMock.mockResolvedValue(sampleImportResult());895 sendMessageMock.mockImplementation(async (msg: unknown) => {896 const type = (msg as { type?: string }).type;897 if (type === "GET_ENTRIES") {898 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };899 }900 if (type === "CLEAR_VAULT_ENTRIES") {901 return { ok: true };902 }903 if (type === "BATCH_ADD_ENTRIES") {904 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };905 }906 return { ok: true };907 });908 const onImport = vi.fn(async () => undefined);909 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);910 await flushView();911 sendMessageMock.mockClear();912913 await selectImportFile(container);914 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();915 await flushView();916917 findButton(container, "Replace vault with 1").click();918 await flushView();919920 expect(sendMessageMock).not.toHaveBeenCalledWith(921 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })922 );923 expect(container.textContent).toContain("Replace forever? This cannot be undone.");924925 findButton(container, "Yes, replace forever").click();926 await flushView();927928 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });929 expect(sendMessageMock).toHaveBeenCalledWith({930 type: "BATCH_ADD_ENTRIES",931 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },932 });933 expect(onImport).toHaveBeenCalled();934 });935936 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {937 parseImportSourceMock.mockResolvedValue(sampleImportResult());938 sendMessageMock.mockImplementation(async (msg: unknown) => {939 const type = (msg as { type?: string }).type;940 if (type === "CREATE_VAULT") return { ok: true };941 if (type === "BATCH_ADD_ENTRIES") {942 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };943 }944 return { ok: true };945 });946 const onImport = vi.fn(async () => undefined);947 const container = mount(948 <ImportView949 mode="onboarding"950 replacesExistingVault={true}951 onBack={vi.fn()}952 onImport={onImport}953 />954 );955956 await selectImportFile(container);957958 const createButton = findButton(container, "Create vault + import 1");959 expect(createButton.disabled).toBe(true);960 expect(container.textContent).toContain("Restoring now replaces the locked local vault");961962 const masterInput = container.querySelector(963 'input[placeholder="new master password"]'964 ) as HTMLInputElement;965 const confirmInput = container.querySelector(966 'input[placeholder="confirm master password"]'967 ) as HTMLInputElement;968 inputText(masterInput, "fresh-master-password");969 inputText(confirmInput, "fresh-master-password");970 await flushView();971972 expect(createButton.disabled).toBe(false);973 createButton.click();974 await flushView();975976 expect(sendMessageMock).toHaveBeenCalledWith({977 type: "CREATE_VAULT",978 payload: { password: "fresh-master-password" },979 });980 expect(sendMessageMock).toHaveBeenCalledWith({981 type: "BATCH_ADD_ENTRIES",982 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },983 });984 expect(onImport).toHaveBeenCalled();985 });986987 it("Import encrypted backups reparse with the backup password before import", async () => {988 parseImportSourceMock989 .mockResolvedValueOnce(990 sampleImportResult({991 format: "peach-json",992 label: "Peach encrypted backup",993 entries: [],994 totalItems: 0,995 requiresPassword: true,996 })997 )998 .mockResolvedValueOnce(999 sampleImportResult({1000 format: "peach-json",1001 label: "Peach encrypted backup",1002 })1003 );1004 sendMessageMock.mockImplementation(async (msg: unknown) => {1005 const type = (msg as { type?: string }).type;1006 if (type === "GET_ENTRIES") return { ok: true, data: [] };1007 if (type === "BATCH_ADD_ENTRIES") {1008 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1009 }1010 return { ok: true };1011 });1012 const onImport = vi.fn(async () => undefined);1013 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1014 await flushView();1015 sendMessageMock.mockClear();10161017 await selectImportFile(container, '{"type":"peach-export"}');1018 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1019 inputText(password, "backup-passphrase");1020 pressEnter(password);1021 await flushView();1022 await flushView();10231024 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1025 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");10261027 findButton(container, "Import 1").click();1028 await flushView();10291030 expect(sendMessageMock).toHaveBeenCalledWith({1031 type: "BATCH_ADD_ENTRIES",1032 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1033 });1034 expect(onImport).toHaveBeenCalled();1035 });1036});