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("../components/RuneBackground", () => ({53 RuneBackground: () => null,54}));5556vi.mock("@zxcvbn-ts/core", () => ({57 zxcvbn: (value: string) => zxcvbnMock(value),58 zxcvbnOptions: { setOptions: vi.fn() },59}));60vi.mock("@zxcvbn-ts/language-common", () => ({61 adjacencyGraphs: {},62 dictionary: {},63}));64vi.mock("@zxcvbn-ts/language-en", () => ({65 translations: {},66 dictionary: {},67}));6869describe("popup contract tests", () => {70 const disposers: Array<() => void> = [];7172 beforeEach(() => {73 document.body.innerHTML = "";74 sendMessageMock.mockReset();75 triggerRingMock.mockReset();76 triggerPipPulseMock.mockReset();77 parseImportSourceMock.mockReset();78 zxcvbnMock.mockReset();79 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));80 parseImportSourceMock.mockResolvedValue({81 format: "unknown",82 label: "Unknown format",83 entries: [],84 totalItems: 0,85 skippedItems: 0,86 requiresPassword: false,87 });8889 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {90 return window.setTimeout(() => cb(0), 0) as unknown as number;91 });92 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));93 vi.stubGlobal("URL", {94 createObjectURL: vi.fn(() => "blob:peach-test"),95 revokeObjectURL: vi.fn(),96 });97 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9899 (globalThis as { chrome?: typeof chrome }).chrome = {100 runtime: {101 getManifest: () => ({ version: "1.4.47" }),102 onMessage: { addListener: vi.fn() },103 },104 } as unknown as typeof chrome;105 });106107 afterEach(() => {108 while (disposers.length > 0) {109 disposers.pop()?.();110 }111 vi.restoreAllMocks();112 vi.unstubAllGlobals();113 });114115 function mount(view: JSX.Element | (() => JSX.Element)) {116 const mounted = renderView(view);117 disposers.push(mounted.dispose);118 return mounted.container;119 }120121 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {122 return {123 id: "entry-1",124 type: "login",125 title: "GitHub",126 username: "june",127 password: "secret123",128 url: "https://github.com",129 totp: "JBSWY3DPEHPK3PXP",130 canary: {131 webhookUrl: "https://example.com/hook",132 },133 createdAt: 1,134 modifiedAt: 1,135 ...overrides,136 };137 }138139 function sampleImportResult(140 overrides: Partial<ImportParseResult> = {}141 ): ImportParseResult {142 return {143 format: "csv",144 label: "CSV import",145 entries: [146 {147 type: "login",148 title: "Imported site",149 username: "imported-user",150 password: "imported-pass",151 url: "https://imported.test",152 },153 ],154 totalItems: 1,155 skippedItems: 0,156 requiresPassword: false,157 ...overrides,158 };159 }160161 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {162 return {163 entriesCount: 1,164 breachMonitoring: true,165 lockOnClose: true,166 autoLockMinutes: 5,167 trashRetentionDays: 30,168 theme: "dark" as const,169 accentColor: "peach" as const,170 textSize: "medium" as const,171 reduceMotion: false,172 onBreachMonitoringChange: vi.fn(),173 onLockOnCloseChange: vi.fn(),174 onAutoLockMinutesChange: vi.fn(),175 onTrashRetentionChange: vi.fn(),176 onThemeChange: vi.fn(),177 onAccentChange: vi.fn(),178 onTextSizeChange: vi.fn(),179 onReduceMotionChange: vi.fn(),180 shortcuts: DEFAULT_BINDINGS,181 onShortcutsChange: vi.fn(),182 onBack: vi.fn(),183 onImport: vi.fn(),184 onExport: vi.fn(),185 onBreachCheckDone: vi.fn(),186 ...overrides,187 };188 }189190 async function selectImportFile(191 container: HTMLElement,192 contents = '{"export":"test"}'193 ) {194 195 196 197 const fileCard = findButton(container, "Choose file →");198 if (fileCard) {199 fileCard.click();200 await flushView();201 }202 const input = container.querySelector('input[type="file"]') as HTMLInputElement;203 const file = new File([contents], "import.json", { type: "application/json" });204 Object.defineProperty(input, "files", {205 configurable: true,206 value: [file],207 });208 input.dispatchEvent(new Event("change", { bubbles: true }));209 await flushView();210 await flushView();211 }212213 function findButton(container: HTMLElement, text: string): HTMLButtonElement {214 return Array.from(container.querySelectorAll("button")).find(215 (candidate) => candidate.textContent?.trim() === text216 ) as HTMLButtonElement;217 }218219 it("Lock sends UNLOCK with the entered passphrase", async () => {220 sendMessageMock.mockResolvedValue({ ok: true });221 const onUnlock = vi.fn(async () => undefined);222 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);223224 const password = container.querySelector("#unlock-password") as HTMLInputElement;225 inputText(password, "correct horse battery staple");226 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();227 await flushView();228229 expect(sendMessageMock).toHaveBeenCalledWith({230 type: "UNLOCK",231 payload: { password: "correct horse battery staple" },232 });233 expect(onUnlock).toHaveBeenCalled();234 });235236 it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {237 sendMessageMock.mockImplementation(async (msg: unknown) => {238 return (msg as { type: string }).type === "GET_SETTINGS"239 ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }240 : { ok: true };241 });242 const onUnlock = vi.fn(async () => undefined);243 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);244 await flushView();245246 inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");247 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();248 await flushView();249250 expect(sendMessageMock).toHaveBeenCalledWith({251 type: "UNLOCK_WITH_PIN",252 payload: { pin: "1234" },253 });254 expect(onUnlock).toHaveBeenCalled();255 expect(container.textContent).toContain("use master password");256 });257258 it("places the PIN method switch below the unlock button", async () => {259 sendMessageMock.mockImplementation(async (msg: unknown) => {260 return (msg as { type: string }).type === "GET_SETTINGS"261 ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }262 : { ok: true };263 });264 const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);265 await flushView();266267 const buttons = Array.from(container.querySelectorAll("button"));268 const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");269 const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");270 expect(pinIndex).toBeGreaterThan(unlockIndex);271 });272273 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {274 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);275 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;276 const password = container.querySelector("#master-password") as HTMLInputElement;277 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;278279 inputText(password, "short");280 inputText(confirm, "short");281 await flushView();282 expect(createButton.disabled).toBe(true);283284 zxcvbnMock.mockImplementation(() => ({ score: 1 }));285 inputText(password, "weakweakweak");286 inputText(confirm, "weakweakweak");287 await flushView();288 expect(createButton.disabled).toBe(true);289 });290291 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {292 293 sendMessageMock.mockImplementation(294 async (msg: unknown) => {295 const m = msg as { type: string; payload?: { password?: string } };296 if (m.type === "CREATE_VAULT") return { ok: true };297 if (m.type === "GET_RECOVERY_WORDS") {298 299 return {300 ok: true,301 data: [302 "abandon", "ability", "able", "about", "above", "absent",303 "absorb", "abstract", "absurd", "abuse", "access", "accident",304 ],305 };306 }307 return { ok: true };308 }309 );310 zxcvbnMock.mockImplementation(() => ({ score: 3 }));311 const onUnlock = vi.fn(async () => undefined);312 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);313314 const password = container.querySelector("#master-password") as HTMLInputElement;315 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;316 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;317318 inputText(password, "correct horse battery staple");319 inputText(confirm, "correct horse battery staple");320 await flushView();321 expect(createButton.disabled).toBe(false);322323 pressEnter(confirm);324 await flushView();325326 expect(sendMessageMock).toHaveBeenCalledWith({327 type: "CREATE_VAULT",328 payload: { password: "correct horse battery staple" },329 });330 331 332 expect(onUnlock).not.toHaveBeenCalled();333 });334335 it("Recovery words flow submits the new master password and completes", async () => {336 sendMessageMock.mockImplementation(async (msg: unknown) => {337 const type = (msg as { type?: string }).type;338 if (type === "VALIDATE_RECOVERY_WORDS") {339 return { ok: true, data: { valid: true, invalid: [] } };340 }341 if (type === "RECOVER_WITH_WORDS") return { ok: true };342 return { ok: true };343 });344 const canvasContext = {345 clearRect: vi.fn(),346 fillText: vi.fn(),347 beginPath: vi.fn(),348 closePath: vi.fn(),349 arc: vi.fn(),350 fill: vi.fn(),351 stroke: vi.fn(),352 save: vi.fn(),353 restore: vi.fn(),354 translate: vi.fn(),355 rotate: vi.fn(),356 scale: vi.fn(),357 drawImage: vi.fn(),358 measureText: vi.fn(() => ({ width: 0 })),359 setTransform: vi.fn(),360 fillRect: vi.fn(),361 strokeRect: vi.fn(),362 } as unknown as CanvasRenderingContext2D;363 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);364365 const onRecoveryComplete = vi.fn(async () => undefined);366 const container = mount(367 <RecoveryWordEntryView368 onCancel={vi.fn()}369 onRecoveryComplete={onRecoveryComplete}370 />371 );372373 const recoveryWords = WORD_LIST.slice(0, 12);374 for (let i = 0; i < recoveryWords.length; i++) {375 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;376 inputText(input, recoveryWords[i]!);377 }378 await flushView();379380 findButton(container, "verify words").click();381 await flushView();382383 expect(container.textContent).toContain("new master password");384385 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;386 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;387 inputText(newPassword, "fresh-recovery-password");388 inputText(confirmPassword, "fresh-recovery-password");389 await flushView();390391 findButton(container, "recover vault").click();392 await flushView();393394 expect(sendMessageMock).toHaveBeenCalledWith({395 type: "RECOVER_WITH_WORDS",396 payload: {397 words: recoveryWords,398 newPassword: "fresh-recovery-password",399 },400 });401 expect(onRecoveryComplete).toHaveBeenCalled();402 });403404 it("Export menu surfaces both backup formats", async () => {405 const container = mount(<ExportView onBack={vi.fn()} />);406 await flushView();407408 expect(container.textContent).toContain("PeachScript");409 expect(container.textContent).toContain("CSV");410 });411412 it("Lock can open directly on backup restore from onboarding", async () => {413 const container = mount(() => (414 <LockView415 hasVault={false}416 ready={true}417 onUnlock={vi.fn()}418 initialRestore={true}419 />420 ));421 await flushView();422423 expect(container.textContent).toContain("PeachScript");424 expect(container.textContent).toContain("Codex");425 expect(container.textContent).toContain("CSV");426 expect(container.querySelector("#master-password")).toBeNull();427 });428429 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {430 const device = {431 deviceId: "phone-1",432 deviceName: "June's Phone",433 deviceType: "android" as const,434 longTermPublicKeyHex: "a".repeat(64),435 signingPublicKeyHex: "b".repeat(64),436 pairedAt: 1,437 trustSource: "direct" as const,438 };439 sendMessageMock.mockImplementation(async (msg: unknown) => {440 const m = msg as { type: string };441 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };442 return { ok: true, data: null };443 });444445 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);446 for (let i = 0; i < 30; i++) await Promise.resolve();447448 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>449 /sync/i.test(b.textContent ?? ""),450 );451 openSync?.click();452 for (let i = 0; i < 30; i++) await Promise.resolve();453454 const remove = Array.from(container.querySelectorAll("button")).find(455 (b) => (b.textContent ?? "").trim() === "remove",456 );457 expect(remove).toBeTruthy();458459 460 461 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();462463 remove!.click();464 for (let i = 0; i < 10; i++) await Promise.resolve();465466 467 468 const note = container.querySelector(".peach-unpair-confirm-note");469 expect(note).toBeTruthy();470 const text = note!.textContent ?? "";471 expect(text).toContain("master password");472 expect(text).toContain("recovery words");473 expect(text).toContain("future changes");474 });475476 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {477 const device = {478 deviceId: "phone-1",479 deviceName: "June's Phone",480 deviceType: "android" as const,481 longTermPublicKeyHex: "a".repeat(64),482 signingPublicKeyHex: "b".repeat(64),483 pairedAt: 1,484 trustSource: "direct" as const,485 };486 let paired: typeof device[] = [device];487 sendMessageMock.mockImplementation(async (msg: any) => {488 if (msg?.type === "LIST_PAIRED_DEVICES") {489 return { ok: true, data: { devices: [...paired] } };490 }491 if (msg?.type === "UNPAIR_DEVICE") {492 493 494 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);495 return { ok: true };496 }497 if (msg?.type === "RENAME_PAIRED_DEVICE") {498 paired = paired.map((d) =>499 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,500 );501 return { ok: true };502 }503 return { ok: true, data: null };504 });505506 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);507 for (let i = 0; i < 30; i++) await Promise.resolve();508 Array.from(container.querySelectorAll("button"))509 .find((b) => /sync/i.test(b.textContent ?? ""))510 ?.click();511 for (let i = 0; i < 30; i++) await Promise.resolve();512513 514 expect(container.textContent).toContain("PAIRED DEVICES");515 expect(container.textContent).toContain("June's Phone");516 expect(container.textContent).toContain("Android");517 Array.from(container.querySelectorAll("button"))518 .find((b) => (b.textContent ?? "").trim() === "rename")!519 .click();520 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;521 renameInput.value = "Travel phone";522 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));523 Array.from(container.querySelectorAll("button"))524 .find((b) => (b.textContent ?? "").trim() === "save")!525 .click();526 for (let i = 0; i < 30; i++) await Promise.resolve();527 expect(sendMessageMock).toHaveBeenCalledWith({528 type: "RENAME_PAIRED_DEVICE",529 payload: { deviceId: "phone-1", name: "Travel phone" },530 });531 expect(container.textContent).toContain("Travel phone");532 const syncNowButton = container.querySelector<HTMLButtonElement>(533 'button[aria-label="Sync this vault now"]',534 )!;535 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));536 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;537 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;538 expect(539 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,540 ).toBeTruthy();541 expect(542 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,543 ).toBeTruthy();544545 Array.from(container.querySelectorAll("button"))546 .find((b) => (b.textContent ?? "").trim() === "remove")!547 .click();548 for (let i = 0; i < 10; i++) await Promise.resolve();549 const confirm = Array.from(550 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),551 ).find((b) => (b.textContent ?? "").trim() === "remove");552 confirm!.click();553 for (let i = 0; i < 30; i++) await Promise.resolve();554555 expect(sendMessageMock).toHaveBeenCalledWith({556 type: "UNPAIR_DEVICE",557 payload: { deviceId: "phone-1" },558 });559 560 561 562 expect(container.textContent).not.toContain("June's Phone");563 expect(container.textContent).not.toContain("PAIRED DEVICES");564 });565566 it("Settings keeps connection and recovery details collapsed until requested", async () => {567 sendMessageMock.mockImplementation(async (msg: any) => {568 if (msg?.type === "LIST_PAIRED_DEVICES") {569 return { ok: true, data: { devices: [] } };570 }571 return { ok: true, data: null };572 });573574 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);575 for (let i = 0; i < 30; i++) await Promise.resolve();576 Array.from(container.querySelectorAll("button"))577 .find((button) => /sync/i.test(button.textContent ?? ""))578 ?.click();579 for (let i = 0; i < 20; i++) await Promise.resolve();580581 expect(container.textContent).toContain("SYNC STATUS");582 expect(container.textContent).toContain("ADD A DEVICE");583 expect(container.textContent).not.toContain("Public relay");584 expect(container.textContent).not.toContain("Rotate sync identity");585586 const connection = Array.from(container.querySelectorAll("button"))587 .find((button) => button.textContent?.includes("CONNECTION"));588 connection?.click();589 await Promise.resolve();590 expect(container.textContent).toContain("Public relay");591592 const recovery = Array.from(container.querySelectorAll("button"))593 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));594 recovery?.click();595 await Promise.resolve();596 expect(container.textContent).toContain("Rotate sync identity");597 });598599 it("Settings requires current, new, and confirm passwords before enabling save", async () => {600 sendMessageMock.mockResolvedValue({ ok: true, data: null });601 const container = mount(602 <SettingsView603 entriesCount={1}604 breachMonitoring={true}605 lockOnClose={true}606 autoLockMinutes={5}607 trashRetentionDays={30}608 theme="dark"609 accentColor="peach"610 textSize="medium"611 reduceMotion={false}612 onBreachMonitoringChange={vi.fn()}613 onLockOnCloseChange={vi.fn()}614 onAutoLockMinutesChange={vi.fn()}615 onTrashRetentionChange={vi.fn()}616 onThemeChange={vi.fn()}617 onAccentChange={vi.fn()}618 onTextSizeChange={vi.fn()}619 onReduceMotionChange={vi.fn()}620 shortcuts={DEFAULT_BINDINGS}621 onShortcutsChange={vi.fn()}622 onBack={vi.fn()}623 onImport={vi.fn()}624 onExport={vi.fn()}625 onBreachCheckDone={vi.fn()}626 />627 );628 await flushView();629630 expect(container.querySelectorAll(".stk-card").length).toBe(7);631 632 633 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");634635 const changeButton = Array.from(container.querySelectorAll("button")).find(636 (candidate) => candidate.textContent?.trim() === "change"637 ) as HTMLButtonElement;638 changeButton.click();639 await flushView();640641 const saveButton = Array.from(container.querySelectorAll("button")).find(642 (candidate) => candidate.textContent?.trim() === "save new password"643 ) as HTMLButtonElement;644 expect(saveButton.disabled).toBe(true);645646 const inputs = container.querySelectorAll('input[type="password"]');647 inputText(inputs[0] as HTMLInputElement, "current-pass");648 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");649 await flushView();650 expect(saveButton.disabled).toBe(true);651652 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");653 await flushView();654 expect(saveButton.disabled).toBe(false);655 });656657 it("Settings feedback highlights the selected category and hides the form after submit", async () => {658 sendMessageMock.mockResolvedValue({ ok: true, data: null });659 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({660 ok: true,661 json: async () => ({ success: true }),662 }));663 vi.stubGlobal("fetch", fetchMock);664665 const container = mount(666 <SettingsView667 entriesCount={1}668 breachMonitoring={true}669 lockOnClose={true}670 autoLockMinutes={5}671 trashRetentionDays={30}672 theme="dark"673 accentColor="peach"674 textSize="medium"675 reduceMotion={false}676 initialSection="feedback"677 onBreachMonitoringChange={vi.fn()}678 onLockOnCloseChange={vi.fn()}679 onAutoLockMinutesChange={vi.fn()}680 onTrashRetentionChange={vi.fn()}681 onThemeChange={vi.fn()}682 onAccentChange={vi.fn()}683 onTextSizeChange={vi.fn()}684 onReduceMotionChange={vi.fn()}685 shortcuts={DEFAULT_BINDINGS}686 onShortcutsChange={vi.fn()}687 onBack={vi.fn()}688 onImport={vi.fn()}689 onExport={vi.fn()}690 onBreachCheckDone={vi.fn()}691 />692 );693 await flushView();694695 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;696 expect(feedbackCard.classList.contains("is-active")).toBe(true);697698 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];699 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;700 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;701 expect(bugButton.getAttribute("aria-pressed")).toBe("true");702 expect(featureButton.getAttribute("aria-pressed")).toBe("false");703704 featureButton.click();705 await flushView();706707 expect(bugButton.getAttribute("aria-pressed")).toBe("false");708 expect(featureButton.getAttribute("aria-pressed")).toBe("true");709 expect(featureButton.classList.contains("is-active")).toBe(true);710711 inputText(712 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,713 '<script>alert("xss")</script> Please make this easier to see & safer.'714 );715 const sendButton = Array.from(container.querySelectorAll("button")).find(716 (candidate) => candidate.textContent?.trim() === "Send"717 ) as HTMLButtonElement;718 sendButton.click();719720 await vi.waitFor(() => {721 expect(container.textContent).toContain("Thank you");722 });723724 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));725 expect(requestBody.subject).toContain("Feature request");726 expect(requestBody.message).toBe(727 "<script>alert("xss")</script> Please make this easier to see & safer."728 );729 expect(requestBody.message).not.toContain("<script>");730 expect(container.textContent).toContain("Someone will process it soon");731 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();732 expect(container.querySelector(".peach-feedback-submit")).toBeNull();733 });734735 it("Settings keeps license activation on the full-screen celebration path", async () => {736 const onLicenseChange = vi.fn(async () => undefined);737 sendMessageMock.mockImplementation(async (msg: unknown) => {738 const type = (msg as { type?: string }).type;739 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };740 if (type === "ACTIVATE_LICENSE") {741 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };742 }743 if (type === "GET_ENTRIES") return { ok: true, data: [] };744 return { ok: true, data: null };745 });746747 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);748 await flushView();749750 inputText(751 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,752 "PEACH-TEST-KEY",753 );754 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;755 apply.click();756757 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));758 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);759 expect(container.textContent).not.toContain("thank you ✦");760 });761762 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {763 sendMessageMock.mockImplementation(async (msg: unknown) => {764 const type = (msg as { type?: string }).type;765 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };766 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };767 if (type === "GET_ENTRIES") return { ok: true, data: [] };768 return { ok: true, data: null };769 });770771 const onLicenseChangeFromStar = vi.fn();772 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);773 await flushView();774 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();775 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);776 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();777778 const onLicenseChangeFromPeach = vi.fn();779 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);780 await flushView();781 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();782 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);783 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();784 });785786 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {787 sendMessageMock.mockResolvedValue({ ok: true, data: null });788 const fetchMock = vi.fn();789 const requestMock = vi.fn(async () => false);790 vi.stubGlobal("fetch", fetchMock);791 vi.stubGlobal("browser", {792 permissions: {793 getAll: vi.fn(async () => ({ data_collection: [] })),794 request: requestMock,795 },796 });797798 const container = mount(799 <SettingsView800 entriesCount={1}801 breachMonitoring={true}802 lockOnClose={true}803 autoLockMinutes={5}804 trashRetentionDays={30}805 theme="dark"806 accentColor="peach"807 textSize="medium"808 reduceMotion={false}809 initialSection="feedback"810 onBreachMonitoringChange={vi.fn()}811 onLockOnCloseChange={vi.fn()}812 onAutoLockMinutesChange={vi.fn()}813 onTrashRetentionChange={vi.fn()}814 onThemeChange={vi.fn()}815 onAccentChange={vi.fn()}816 onTextSizeChange={vi.fn()}817 onReduceMotionChange={vi.fn()}818 shortcuts={DEFAULT_BINDINGS}819 onShortcutsChange={vi.fn()}820 onBack={vi.fn()}821 onImport={vi.fn()}822 onExport={vi.fn()}823 onBreachCheckDone={vi.fn()}824 />825 );826 await flushView();827828 inputText(829 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,830 "Please improve the feedback flow."831 );832 const sendButton = Array.from(container.querySelectorAll("button")).find(833 (candidate) => candidate.textContent?.trim() === "Send"834 ) as HTMLButtonElement;835 sendButton.click();836837 await vi.waitFor(() => {838 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });839 });840 expect(fetchMock).not.toHaveBeenCalled();841 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();842 });843844 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {845 sendMessageMock.mockImplementation(async (msg: unknown) => {846 const m = msg as { type: string };847 if (m.type === "GET_RECOVERY_WORDS") {848 return {849 ok: true,850 data: [851 "abandon", "ability", "able", "about", "above", "absent",852 "absorb", "abstract", "absurd", "abuse", "access", "accident",853 ],854 };855 }856 return { ok: true, data: null };857 });858 const onAutoLockMinutesChange = vi.fn();859 const onThemeChange = vi.fn();860 const container = mount(861 <SettingsView862 entriesCount={1}863 breachMonitoring={true}864 lockOnClose={true}865 autoLockMinutes={5}866 trashRetentionDays={30}867 theme="dark"868 accentColor="peach"869 textSize="medium"870 reduceMotion={false}871 onBreachMonitoringChange={vi.fn()}872 onLockOnCloseChange={vi.fn()}873 onAutoLockMinutesChange={onAutoLockMinutesChange}874 onTrashRetentionChange={vi.fn()}875 onThemeChange={onThemeChange}876 onAccentChange={vi.fn()}877 onTextSizeChange={vi.fn()}878 onReduceMotionChange={vi.fn()}879 shortcuts={DEFAULT_BINDINGS}880 onShortcutsChange={vi.fn()}881 onBack={vi.fn()}882 onImport={vi.fn()}883 onExport={vi.fn()}884 onBreachCheckDone={vi.fn()}885 />886 );887 await flushView();888 sendMessageMock.mockClear();889890 891 const selects = container.querySelectorAll("select");892 changeSelect(selects[0] as HTMLSelectElement, "15");893 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);894895 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;896 appearanceCard.click();897 await flushView();898 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");899 const themeButton = Array.from(container.querySelectorAll("button")).find(900 (candidate) => candidate.textContent?.trim() === "Switch to light"901 ) as HTMLButtonElement;902 themeButton.click();903 expect(onThemeChange).toHaveBeenCalledWith("light");904905 906 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;907 protectionCard.click();908 await flushView();909910 const changeButton = Array.from(container.querySelectorAll("button")).find(911 (candidate) => candidate.textContent?.trim() === "change"912 ) as HTMLButtonElement;913 changeButton.click();914 await flushView();915916 const inputs = container.querySelectorAll('input[type="password"]');917 inputText(inputs[0] as HTMLInputElement, "current-pass");918 inputText(inputs[1] as HTMLInputElement, "new-password-123");919 inputText(inputs[2] as HTMLInputElement, "new-password-123");920 await flushView();921922 const saveButton = Array.from(container.querySelectorAll("button")).find(923 (candidate) => candidate.textContent?.trim() === "save new password"924 ) as HTMLButtonElement;925 saveButton.click();926 await flushView();927928 expect(sendMessageMock).toHaveBeenCalledWith({929 type: "CHANGE_PASSWORD",930 payload: {931 currentPassword: "current-pass",932 newPassword: "new-password-123",933 },934 });935936 937 expect(container.textContent).toContain("save your recovery words");938 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });939 });940941 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {942 sendMessageMock.mockResolvedValue({ ok: true });943 const container = mount(944 <EntryView945 entry={sampleEntry()}946 onDelete={vi.fn()}947 onFieldSaved={vi.fn(async () => undefined)}948 />949 );950951 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;952 passwordButton.click();953 await flushView();954955 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {956 type: "COPY",957 payload: { text: "secret123", label: "password", alreadyWritten: false },958 });959 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {960 type: "FIRE_CANARY_PING",961 payload: {962 webhookUrl: "https://example.com/hook",963 entryTitle: "GitHub",964 trigger: "password_copied",965 },966 });967968 sendMessageMock.mockClear();969 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;970 usernameButton.click();971 await flushView();972973 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {974 type: "COPY",975 payload: { text: "june", label: "username", alreadyWritten: false },976 });977 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {978 type: "FIRE_CANARY_PING",979 payload: {980 webhookUrl: "https://example.com/hook",981 entryTitle: "GitHub",982 trigger: "username_copied",983 },984 });985 });986987 it("Entry shows Advanced for passkey-bearing credential entries", async () => {988 const container = mount(989 <EntryView990 entry={sampleEntry({991 type: "passkey",992 username: undefined,993 password: undefined,994 totp: undefined,995 canary: undefined,996 passkey: {997 rpId: "vercel.com",998 rpName: "Vercel",999 credentialId: "cred-1",1000 publicKeyCose: "cose",1001 userHandle: "handle",1002 userName: "june",1003 userDisplayName: "June",1004 algorithm: -7,1005 signCount: 0,1006 aaguid: "",1007 backupEligible: false,1008 backupState: false,1009 createdAt: 1,1010 },1011 })}1012 onDelete={vi.fn()}1013 onFieldSaved={vi.fn(async () => undefined)}1014 />1015 );10161017 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1018 (button) => button.textContent?.includes("Advanced")1019 ) as HTMLButtonElement | undefined;1020 expect(advanced).toBeDefined();1021 expect(advanced?.textContent).toContain("+ Advanced");10221023 advanced!.click();1024 await flushView();10251026 expect(container.textContent).toContain("passkey · vercel.com");1027 });10281029 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1030 sendMessageMock.mockResolvedValue({ ok: true });1031 const onFieldSaved = vi.fn(async () => undefined);1032 const container = mount(1033 <EntryView1034 entry={sampleEntry()}1035 onDelete={vi.fn()}1036 onFieldSaved={onFieldSaved}1037 />1038 );10391040 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1041 passwordValue.click();1042 await flushView();10431044 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1045 inputText(passwordInput, "");1046 await flushView();10471048 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1049 saveButton.click();1050 await flushView();10511052 expect(sendMessageMock).not.toHaveBeenCalledWith(1053 expect.objectContaining({ type: "UPDATE_ENTRY" })1054 );1055 expect(onFieldSaved).not.toHaveBeenCalled();10561057 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1058 totpMain.click();10591060 await vi.waitFor(() => {1061 expect(sendMessageMock).toHaveBeenLastCalledWith({1062 type: "COPY",1063 payload: { text: "123456", label: "totp", alreadyWritten: false },1064 });1065 }, { timeout: 500 });1066 });10671068 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1069 sendMessageMock.mockResolvedValue({ ok: true });1070 const container = mount(1071 <EntryView1072 entry={sampleEntry()}1073 onDelete={vi.fn()}1074 onFieldSaved={vi.fn(async () => undefined)}1075 />1076 );10771078 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1079 usernameValue.click();1080 await flushView();10811082 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1083 usernameInput.blur();1084 await flushView();10851086 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1087 expect(sendMessageMock).not.toHaveBeenCalledWith(1088 expect.objectContaining({ type: "UPDATE_ENTRY" })1089 );1090 });10911092 it("Entry keeps the full used-email list available while editing an email", async () => {1093 const container = mount(1094 <EntryView1095 entry={sampleEntry({ email: "[email protected]" })}1096 entries={[1097 sampleEntry({ email: "[email protected]" }),1098 sampleEntry({ id: "entry-2", email: "[email protected]" }),1099 sampleEntry({ id: "entry-3", username: "duckuccino" }),1100 ]}1101 onDelete={vi.fn()}1102 onFieldSaved={vi.fn(async () => undefined)}1103 />1104 );11051106 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1107 (card) => card.querySelector(".peach-label")?.textContent === "email"1108 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1109 emailValue.click();1110 await flushView();11111112 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1113 .map((element) => element.textContent);1114 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1115 });11161117 it("Entry lets TOTP secrets be edited inline", async () => {1118 sendMessageMock.mockResolvedValue({ ok: true });1119 const onFieldSaved = vi.fn(async () => undefined);1120 const container = mount(1121 <EntryView1122 entry={sampleEntry()}1123 onDelete={vi.fn()}1124 onFieldSaved={onFieldSaved}1125 />1126 );11271128 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1129 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1130 await flushView();11311132 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1133 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1134 await flushView();11351136 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1137 saveButton.click();1138 await flushView();11391140 expect(sendMessageMock).toHaveBeenCalledWith({1141 type: "UPDATE_ENTRY",1142 payload: {1143 id: "entry-1",1144 patch: { totp: "NB2W45DFOIZA====" },1145 },1146 });1147 expect(onFieldSaved).toHaveBeenCalled();1148 });11491150 it("Vault seeds and focuses search when a search seed is provided", async () => {1151 const container = mount(1152 <VaultView1153 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1154 sortOrder="url-first"1155 searchSeed="g"1156 searchSeedNonce={1}1157 onSelectEntry={vi.fn()}1158 onSortOrderChange={vi.fn()}1159 onAdd={vi.fn()}1160 onClose={vi.fn()}1161 />1162 );11631164 await flushView();11651166 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1167 expect(search.value).toBe("g");1168 expect(document.activeElement).toBe(search);1169 });11701171 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1172 parseImportSourceMock.mockResolvedValue(1173 sampleImportResult({1174 entries: [1175 {1176 type: "login",1177 title: "Imported site",1178 username: "imported-user",1179 password: "imported-pass",1180 url: "https://imported.test",1181 },1182 ],1183 })1184 );1185 sendMessageMock.mockImplementation(async (msg: unknown) => {1186 const type = (msg as { type?: string }).type;1187 if (type === "GET_ENTRIES") {1188 return {1189 ok: true,1190 data: [1191 sampleEntry({1192 id: "existing-1",1193 title: "Imported site",1194 username: "imported-user",1195 url: "https://imported.test",1196 }),1197 ],1198 };1199 }1200 if (type === "BATCH_ADD_ENTRIES") {1201 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1202 }1203 return { ok: true };1204 });1205 const onImport = vi.fn(async () => undefined);1206 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1207 await flushView();1208 sendMessageMock.mockClear();12091210 await selectImportFile(container);1211 findButton(container, "Import 1").click();1212 await flushView();12131214 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1215 expect(sendMessageMock).toHaveBeenCalledWith({1216 type: "BATCH_ADD_ENTRIES",1217 payload: {1218 entries: [1219 expect.objectContaining({1220 title: "Imported site",1221 username: "imported-user",1222 password: "imported-pass",1223 url: "https://imported.test",1224 }),1225 ],1226 },1227 });1228 expect(onImport).toHaveBeenCalled();1229 expect(container.textContent).toContain("1 entry appears to be duplicates");1230 });12311232 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1233 parseImportSourceMock.mockResolvedValue(sampleImportResult());1234 sendMessageMock.mockImplementation(async (msg: unknown) => {1235 const type = (msg as { type?: string }).type;1236 if (type === "GET_ENTRIES") {1237 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1238 }1239 if (type === "CLEAR_VAULT_ENTRIES") {1240 return { ok: true };1241 }1242 if (type === "BATCH_ADD_ENTRIES") {1243 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1244 }1245 return { ok: true };1246 });1247 const onImport = vi.fn(async () => undefined);1248 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1249 await flushView();1250 sendMessageMock.mockClear();12511252 await selectImportFile(container);1253 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1254 await flushView();12551256 findButton(container, "Replace vault with 1").click();1257 await flushView();12581259 expect(sendMessageMock).not.toHaveBeenCalledWith(1260 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1261 );1262 expect(container.textContent).toContain("Replace forever? This cannot be undone.");12631264 findButton(container, "Yes, replace forever").click();1265 await flushView();12661267 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1268 expect(sendMessageMock).toHaveBeenCalledWith({1269 type: "BATCH_ADD_ENTRIES",1270 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1271 });1272 expect(onImport).toHaveBeenCalled();1273 });12741275 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1276 parseImportSourceMock.mockResolvedValue(sampleImportResult());1277 sendMessageMock.mockImplementation(async (msg: unknown) => {1278 const type = (msg as { type?: string }).type;1279 if (type === "CREATE_VAULT") return { ok: true };1280 if (type === "BATCH_ADD_ENTRIES") {1281 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1282 }1283 return { ok: true };1284 });1285 const onImport = vi.fn(async () => undefined);1286 const container = mount(1287 <ImportView1288 mode="onboarding"1289 replacesExistingVault={true}1290 onBack={vi.fn()}1291 onImport={onImport}1292 />1293 );12941295 await selectImportFile(container);12961297 const createButton = findButton(container, "Create vault + import 1");1298 expect(createButton.disabled).toBe(true);1299 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1300 expect(container.textContent).toContain("Use at least 8 characters");13011302 const masterInput = container.querySelector(1303 'input[placeholder="new master password"]'1304 ) as HTMLInputElement;1305 const confirmInput = container.querySelector(1306 'input[placeholder="confirm master password"]'1307 ) as HTMLInputElement;1308 const revealButton = container.querySelector(1309 'button[aria-label="Show passwords"]'1310 ) as HTMLButtonElement;1311 expect(revealButton).toBeTruthy();1312 revealButton.click();1313 await flushView();1314 expect(masterInput.type).toBe("text");1315 expect(confirmInput.type).toBe("text");13161317 inputText(masterInput, "fresh-master-password");1318 inputText(confirmInput, "fresh-master-password");1319 await flushView();13201321 expect(createButton.disabled).toBe(false);1322 createButton.click();1323 await flushView();13241325 expect(sendMessageMock).toHaveBeenCalledWith({1326 type: "CREATE_VAULT",1327 payload: { password: "fresh-master-password" },1328 });1329 expect(sendMessageMock).toHaveBeenCalledWith({1330 type: "BATCH_ADD_ENTRIES",1331 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1332 });1333 expect(onImport).toHaveBeenCalled();1334 });13351336 it("Import encrypted backups reparse with the backup password before import", async () => {1337 parseImportSourceMock1338 .mockResolvedValueOnce(1339 sampleImportResult({1340 format: "peach-json",1341 label: "Peach encrypted backup",1342 entries: [],1343 totalItems: 0,1344 requiresPassword: true,1345 })1346 )1347 .mockResolvedValueOnce(1348 sampleImportResult({1349 format: "peach-json",1350 label: "Peach encrypted backup",1351 })1352 );1353 sendMessageMock.mockImplementation(async (msg: unknown) => {1354 const type = (msg as { type?: string }).type;1355 if (type === "GET_ENTRIES") return { ok: true, data: [] };1356 if (type === "BATCH_ADD_ENTRIES") {1357 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1358 }1359 return { ok: true };1360 });1361 const onImport = vi.fn(async () => undefined);1362 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1363 await flushView();1364 sendMessageMock.mockClear();13651366 await selectImportFile(container, '{"type":"peach-export"}');1367 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1368 inputText(password, "backup-passphrase");1369 pressEnter(password);1370 await flushView();1371 await flushView();13721373 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1374 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");13751376 findButton(container, "Import 1").click();1377 await flushView();13781379 expect(sendMessageMock).toHaveBeenCalledWith({1380 type: "BATCH_ADD_ENTRIES",1381 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1382 });1383 expect(onImport).toHaveBeenCalled();1384 });1385});