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 { AddView } from "./Add";16import { NotesEditor } from "./NotesEditor";17import { RecoveryWordEntryView } from "./RecoveryWordEntry";18import { VaultView } from "./Vault";19import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2021const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();22const triggerRingMock = vi.fn();23const triggerPipPulseMock = vi.fn();24const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();25const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2627vi.mock("../App", () => ({28 sendMessage: (msg: unknown) => sendMessageMock(msg),29 triggerRing: () => triggerRingMock(),30 triggerPipPulse: () => triggerPipPulseMock(),31}));3233vi.mock("../../shared/bridge", () => ({34 sendMessage: (msg: unknown) => sendMessageMock(msg),35}));3637vi.mock("../../shared/import", () => ({38 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),39}));4041vi.mock("../../shared/favicon", async (importOriginal) => {42 const actual = await importOriginal<typeof import("../../shared/favicon")>();43 return { ...actual, faviconUrl: () => null };44});4546vi.mock("../../crypto/totp", () => ({47 generateTOTP: () => ({ code: "123 456", remaining: 24 }),48 parseOtpauthURI: (value: string) =>49 value.startsWith("otpauth://")50 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }51 : null,52}));5354vi.mock("../components/GeneratorDrawer", () => ({55 GeneratorDrawer: () => null,56}));5758vi.mock("../components/RuneBackground", () => ({59 RuneBackground: () => null,60}));6162vi.mock("@zxcvbn-ts/core", () => ({63 zxcvbn: (value: string) => zxcvbnMock(value),64 zxcvbnOptions: { setOptions: vi.fn() },65}));66vi.mock("@zxcvbn-ts/language-common", () => ({67 adjacencyGraphs: {},68 dictionary: {},69}));70vi.mock("@zxcvbn-ts/language-en", () => ({71 translations: {},72 dictionary: {},73}));7475describe("popup contract tests", () => {76 const disposers: Array<() => void> = [];7778 beforeEach(() => {79 document.body.innerHTML = "";80 sendMessageMock.mockReset();81 triggerRingMock.mockReset();82 triggerPipPulseMock.mockReset();83 parseImportSourceMock.mockReset();84 zxcvbnMock.mockReset();85 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));86 parseImportSourceMock.mockResolvedValue({87 format: "unknown",88 label: "Unknown format",89 entries: [],90 totalItems: 0,91 skippedItems: 0,92 requiresPassword: false,93 });9495 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {96 return window.setTimeout(() => cb(0), 0) as unknown as number;97 });98 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));99 vi.stubGlobal("URL", {100 createObjectURL: vi.fn(() => "blob:peach-test"),101 revokeObjectURL: vi.fn(),102 });103 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});104105 (globalThis as { chrome?: typeof chrome }).chrome = {106 runtime: {107 getManifest: () => ({ version: "1.4.47" }),108 onMessage: { addListener: vi.fn() },109 },110 } as unknown as typeof chrome;111 });112113 afterEach(() => {114 while (disposers.length > 0) {115 disposers.pop()?.();116 }117 vi.restoreAllMocks();118 vi.unstubAllGlobals();119 });120121 function mount(view: JSX.Element | (() => JSX.Element)) {122 const mounted = renderView(view);123 disposers.push(mounted.dispose);124 return mounted.container;125 }126127 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {128 return {129 id: "entry-1",130 type: "login",131 title: "GitHub",132 username: "june",133 password: "secret123",134 url: "https://github.com",135 totp: "JBSWY3DPEHPK3PXP",136 canary: {137 webhookUrl: "https://example.com/hook",138 },139 createdAt: 1,140 modifiedAt: 1,141 ...overrides,142 };143 }144145 function sampleImportResult(146 overrides: Partial<ImportParseResult> = {}147 ): ImportParseResult {148 return {149 format: "csv",150 label: "CSV import",151 entries: [152 {153 type: "login",154 title: "Imported site",155 username: "imported-user",156 password: "imported-pass",157 url: "https://imported.test",158 },159 ],160 totalItems: 1,161 skippedItems: 0,162 requiresPassword: false,163 ...overrides,164 };165 }166167 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {168 return {169 entriesCount: 1,170 breachMonitoring: true,171 lockOnClose: true,172 autoLockMinutes: 5,173 trashRetentionDays: 30,174 theme: "dark" as const,175 accentColor: "peach" as const,176 textSize: "medium" as const,177 reduceMotion: false,178 onBreachMonitoringChange: vi.fn(),179 onLockOnCloseChange: vi.fn(),180 onAutoLockMinutesChange: vi.fn(),181 onTrashRetentionChange: vi.fn(),182 onThemeChange: vi.fn(),183 onAccentChange: vi.fn(),184 onTextSizeChange: vi.fn(),185 onReduceMotionChange: vi.fn(),186 shortcuts: DEFAULT_BINDINGS,187 onShortcutsChange: vi.fn(),188 onBack: vi.fn(),189 onImport: vi.fn(),190 onExport: vi.fn(),191 onBreachCheckDone: vi.fn(),192 ...overrides,193 };194 }195196 async function selectImportFile(197 container: HTMLElement,198 contents = '{"export":"test"}'199 ) {200 201 202 203 const fileCard = findButton(container, "Choose file →");204 if (fileCard) {205 fileCard.click();206 await flushView();207 }208 const input = container.querySelector('input[type="file"]') as HTMLInputElement;209 const file = new File([contents], "import.json", { type: "application/json" });210 Object.defineProperty(input, "files", {211 configurable: true,212 value: [file],213 });214 input.dispatchEvent(new Event("change", { bubbles: true }));215 await flushView();216 await flushView();217 }218219 function findButton(container: HTMLElement, text: string): HTMLButtonElement {220 return Array.from(container.querySelectorAll("button")).find(221 (candidate) => candidate.textContent?.trim() === text222 ) as HTMLButtonElement;223 }224225 it("Lock sends UNLOCK with the entered passphrase", async () => {226 sendMessageMock.mockResolvedValue({ ok: true });227 const onUnlock = vi.fn(async () => undefined);228 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);229230 const password = container.querySelector("#unlock-password") as HTMLInputElement;231 inputText(password, "correct horse battery staple");232 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();233 await flushView();234235 expect(sendMessageMock).toHaveBeenCalledWith({236 type: "UNLOCK",237 payload: { password: "correct horse battery staple" },238 });239 expect(onUnlock).toHaveBeenCalled();240 });241242 it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {243 sendMessageMock.mockImplementation(async (msg: unknown) => {244 return (msg as { type: string }).type === "GET_SETTINGS"245 ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }246 : { ok: true };247 });248 const onUnlock = vi.fn(async () => undefined);249 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);250 await flushView();251252 inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");253 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();254 await flushView();255256 expect(sendMessageMock).toHaveBeenCalledWith({257 type: "UNLOCK_WITH_PIN",258 payload: { pin: "1234" },259 });260 expect(onUnlock).toHaveBeenCalled();261 expect(container.textContent).toContain("use master password");262 });263264 it("places the PIN method switch below the unlock button", async () => {265 sendMessageMock.mockImplementation(async (msg: unknown) => {266 return (msg as { type: string }).type === "GET_SETTINGS"267 ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }268 : { ok: true };269 });270 const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);271 await flushView();272273 const buttons = Array.from(container.querySelectorAll("button"));274 const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");275 const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");276 expect(pinIndex).toBeGreaterThan(unlockIndex);277 });278279 it("keeps standalone note creation in Notes mode", async () => {280 const chromeApi = globalThis.chrome as unknown as {281 tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };282 };283 chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };284285 const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);286 await flushView();287288 expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))289 .toEqual(["login", "card"]);290 expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();291 });292293 it("gives Notes mode the full note-formatting toolbar", async () => {294 const container = mount(295 <NotesEditor296 note={null}297 onBack={vi.fn()}298 onSaved={vi.fn()}299 onDelete={vi.fn()}300 />301 );302 await flushView();303304 expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();305 expect(container.querySelector('[title="Divider"]')).toBeTruthy();306 expect(container.querySelector('[title="Quote"]')).toBeTruthy();307 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);308 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);309 expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");310 });311312 it("saves formatting applied by the Notes toolbar before leaving", async () => {313 const savedNote = {314 id: "note-1",315 type: "note" as const,316 title: "Untitled",317 notes: "<b>formatted</b>",318 createdAt: 1,319 modifiedAt: 2,320 };321 sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });322 const originalExecCommand = document.execCommand;323 const execCommandMock = vi.fn(() => {324 const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");325 if (body) body.innerHTML = "<b>formatted</b>";326 return true;327 });328 Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });329330 try {331 const container = mount(332 <NotesEditor333 note={null}334 onBack={vi.fn()}335 onSaved={vi.fn()}336 onDelete={vi.fn()}337 />338 );339 await flushView();340 container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(341 new MouseEvent("mousedown", { bubbles: true, cancelable: true })342 );343 expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);344 container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();345 await flushView();346347 expect(sendMessageMock).toHaveBeenCalledWith({348 type: "SAVE_NOTE",349 payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },350 });351 } finally {352 Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });353 }354 });355356 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {357 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);358 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;359 const password = container.querySelector("#master-password") as HTMLInputElement;360 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;361362 inputText(password, "short");363 inputText(confirm, "short");364 await flushView();365 expect(createButton.disabled).toBe(true);366367 zxcvbnMock.mockImplementation(() => ({ score: 1 }));368 inputText(password, "weakweakweak");369 inputText(confirm, "weakweakweak");370 await flushView();371 expect(createButton.disabled).toBe(true);372 });373374 it("Lock create-vault header pins the wordmark left with back on the right", async () => {375 const container = mount(376 <LockView377 hasVault={false}378 ready={true}379 onUnlock={vi.fn()}380 onBackToOnboarding={() => {}}381 />382 );383 const header = container.querySelector(".peach-newvault-header") as HTMLElement;384 const wordmark = header.querySelector(".peach-wordmark-stack");385 const back = header.querySelector(".peach-newvault-back");386 expect(wordmark).toBeTruthy();387 expect(back).toBeTruthy();388 389 390 expect(header.firstElementChild).toBe(wordmark);391 expect(header.lastElementChild).toBe(back);392 await flushView();393 });394395 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {396 397 sendMessageMock.mockImplementation(398 async (msg: unknown) => {399 const m = msg as { type: string; payload?: { password?: string } };400 if (m.type === "CREATE_VAULT") return { ok: true };401 if (m.type === "GET_RECOVERY_WORDS") {402 403 return {404 ok: true,405 data: [406 "abandon", "ability", "able", "about", "above", "absent",407 "absorb", "abstract", "absurd", "abuse", "access", "accident",408 ],409 };410 }411 return { ok: true };412 }413 );414 zxcvbnMock.mockImplementation(() => ({ score: 3 }));415 const onUnlock = vi.fn(async () => undefined);416 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);417418 const password = container.querySelector("#master-password") as HTMLInputElement;419 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;420 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;421422 inputText(password, "correct horse battery staple");423 inputText(confirm, "correct horse battery staple");424 await flushView();425 expect(createButton.disabled).toBe(false);426427 pressEnter(confirm);428 await flushView();429430 expect(sendMessageMock).toHaveBeenCalledWith({431 type: "CREATE_VAULT",432 payload: { password: "correct horse battery staple" },433 });434 435 436 expect(onUnlock).not.toHaveBeenCalled();437 });438439 it("Recovery words flow submits the new master password and completes", async () => {440 sendMessageMock.mockImplementation(async (msg: unknown) => {441 const type = (msg as { type?: string }).type;442 if (type === "VALIDATE_RECOVERY_WORDS") {443 return { ok: true, data: { valid: true, invalid: [] } };444 }445 if (type === "RECOVER_WITH_WORDS") return { ok: true };446 return { ok: true };447 });448 const canvasContext = {449 clearRect: vi.fn(),450 fillText: vi.fn(),451 beginPath: vi.fn(),452 closePath: vi.fn(),453 arc: vi.fn(),454 fill: vi.fn(),455 stroke: vi.fn(),456 save: vi.fn(),457 restore: vi.fn(),458 translate: vi.fn(),459 rotate: vi.fn(),460 scale: vi.fn(),461 drawImage: vi.fn(),462 measureText: vi.fn(() => ({ width: 0 })),463 setTransform: vi.fn(),464 fillRect: vi.fn(),465 strokeRect: vi.fn(),466 } as unknown as CanvasRenderingContext2D;467 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);468469 const onRecoveryComplete = vi.fn(async () => undefined);470 const container = mount(471 <RecoveryWordEntryView472 onCancel={vi.fn()}473 onRecoveryComplete={onRecoveryComplete}474 />475 );476477 const recoveryWords = WORD_LIST.slice(0, 12);478 for (let i = 0; i < recoveryWords.length; i++) {479 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;480 inputText(input, recoveryWords[i]!);481 }482 await flushView();483484 findButton(container, "verify words").click();485 await flushView();486487 expect(container.textContent).toContain("new master password");488489 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;490 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;491 inputText(newPassword, "fresh-recovery-password");492 inputText(confirmPassword, "fresh-recovery-password");493 await flushView();494495 findButton(container, "recover vault").click();496 await flushView();497498 expect(sendMessageMock).toHaveBeenCalledWith({499 type: "RECOVER_WITH_WORDS",500 payload: {501 words: recoveryWords,502 newPassword: "fresh-recovery-password",503 },504 });505 expect(onRecoveryComplete).toHaveBeenCalled();506 });507508 it("Export menu surfaces both backup formats", async () => {509 const container = mount(<ExportView onBack={vi.fn()} />);510 await flushView();511512 expect(container.textContent).toContain("PeachScript");513 expect(container.textContent).toContain("CSV");514 });515516 it("Lock can open directly on backup restore from onboarding", async () => {517 const container = mount(() => (518 <LockView519 hasVault={false}520 ready={true}521 onUnlock={vi.fn()}522 initialRestore={true}523 />524 ));525 await flushView();526527 expect(container.textContent).toContain("PeachScript");528 expect(container.textContent).toContain("Codex");529 expect(container.textContent).toContain("CSV");530 expect(container.querySelector("#master-password")).toBeNull();531 });532533 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {534 const device = {535 deviceId: "phone-1",536 deviceName: "June's Phone",537 deviceType: "android" as const,538 longTermPublicKeyHex: "a".repeat(64),539 signingPublicKeyHex: "b".repeat(64),540 pairedAt: 1,541 trustSource: "direct" as const,542 };543 sendMessageMock.mockImplementation(async (msg: unknown) => {544 const m = msg as { type: string };545 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };546 return { ok: true, data: null };547 });548549 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);550 for (let i = 0; i < 30; i++) await Promise.resolve();551552 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>553 /sync/i.test(b.textContent ?? ""),554 );555 openSync?.click();556 for (let i = 0; i < 30; i++) await Promise.resolve();557558 const remove = Array.from(container.querySelectorAll("button")).find(559 (b) => (b.textContent ?? "").trim() === "remove",560 );561 expect(remove).toBeTruthy();562563 564 565 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();566567 remove!.click();568 for (let i = 0; i < 10; i++) await Promise.resolve();569570 571 572 const note = container.querySelector(".peach-unpair-confirm-note");573 expect(note).toBeTruthy();574 const text = note!.textContent ?? "";575 expect(text).toContain("master password");576 expect(text).toContain("recovery words");577 expect(text).toContain("future changes");578 });579580 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {581 const device = {582 deviceId: "phone-1",583 deviceName: "June's Phone",584 deviceType: "android" as const,585 longTermPublicKeyHex: "a".repeat(64),586 signingPublicKeyHex: "b".repeat(64),587 pairedAt: 1,588 trustSource: "direct" as const,589 };590 let paired: typeof device[] = [device];591 sendMessageMock.mockImplementation(async (msg: any) => {592 if (msg?.type === "LIST_PAIRED_DEVICES") {593 return { ok: true, data: { devices: [...paired] } };594 }595 if (msg?.type === "UNPAIR_DEVICE") {596 597 598 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);599 return { ok: true };600 }601 if (msg?.type === "RENAME_PAIRED_DEVICE") {602 paired = paired.map((d) =>603 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,604 );605 return { ok: true };606 }607 return { ok: true, data: null };608 });609610 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);611 for (let i = 0; i < 30; i++) await Promise.resolve();612 Array.from(container.querySelectorAll("button"))613 .find((b) => /sync/i.test(b.textContent ?? ""))614 ?.click();615 for (let i = 0; i < 30; i++) await Promise.resolve();616617 618 expect(container.textContent).toContain("PAIRED DEVICES");619 expect(container.textContent).toContain("June's Phone");620 expect(container.textContent).toContain("Android");621 Array.from(container.querySelectorAll("button"))622 .find((b) => (b.textContent ?? "").trim() === "rename")!623 .click();624 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;625 renameInput.value = "Travel phone";626 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));627 Array.from(container.querySelectorAll("button"))628 .find((b) => (b.textContent ?? "").trim() === "save")!629 .click();630 for (let i = 0; i < 30; i++) await Promise.resolve();631 expect(sendMessageMock).toHaveBeenCalledWith({632 type: "RENAME_PAIRED_DEVICE",633 payload: { deviceId: "phone-1", name: "Travel phone" },634 });635 expect(container.textContent).toContain("Travel phone");636 const syncNowButton = container.querySelector<HTMLButtonElement>(637 'button[aria-label="Sync this vault now"]',638 )!;639 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));640 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;641 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;642 expect(643 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,644 ).toBeTruthy();645 expect(646 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,647 ).toBeTruthy();648649 Array.from(container.querySelectorAll("button"))650 .find((b) => (b.textContent ?? "").trim() === "remove")!651 .click();652 for (let i = 0; i < 10; i++) await Promise.resolve();653 const confirm = Array.from(654 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),655 ).find((b) => (b.textContent ?? "").trim() === "remove");656 confirm!.click();657 for (let i = 0; i < 30; i++) await Promise.resolve();658659 expect(sendMessageMock).toHaveBeenCalledWith({660 type: "UNPAIR_DEVICE",661 payload: { deviceId: "phone-1" },662 });663 664 665 666 expect(container.textContent).not.toContain("June's Phone");667 expect(container.textContent).not.toContain("PAIRED DEVICES");668 });669670 it("Settings keeps connection and recovery details collapsed until requested", async () => {671 sendMessageMock.mockImplementation(async (msg: any) => {672 if (msg?.type === "LIST_PAIRED_DEVICES") {673 return { ok: true, data: { devices: [] } };674 }675 return { ok: true, data: null };676 });677678 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);679 for (let i = 0; i < 30; i++) await Promise.resolve();680 Array.from(container.querySelectorAll("button"))681 .find((button) => /sync/i.test(button.textContent ?? ""))682 ?.click();683 for (let i = 0; i < 20; i++) await Promise.resolve();684685 expect(container.textContent).toContain("SYNC STATUS");686 expect(container.textContent).toContain("ADD A DEVICE");687 expect(container.textContent).not.toContain("Public relay");688 expect(container.textContent).not.toContain("Rotate sync identity");689690 const connection = Array.from(container.querySelectorAll("button"))691 .find((button) => button.textContent?.includes("CONNECTION"));692 connection?.click();693 await Promise.resolve();694 expect(container.textContent).toContain("Public relay");695696 const recovery = Array.from(container.querySelectorAll("button"))697 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));698 recovery?.click();699 await Promise.resolve();700 expect(container.textContent).toContain("Rotate sync identity");701 });702703 it("Settings requires current, new, and confirm passwords before enabling save", async () => {704 sendMessageMock.mockResolvedValue({ ok: true, data: null });705 const container = mount(706 <SettingsView707 entriesCount={1}708 breachMonitoring={true}709 lockOnClose={true}710 autoLockMinutes={5}711 trashRetentionDays={30}712 theme="dark"713 accentColor="peach"714 textSize="medium"715 reduceMotion={false}716 onBreachMonitoringChange={vi.fn()}717 onLockOnCloseChange={vi.fn()}718 onAutoLockMinutesChange={vi.fn()}719 onTrashRetentionChange={vi.fn()}720 onThemeChange={vi.fn()}721 onAccentChange={vi.fn()}722 onTextSizeChange={vi.fn()}723 onReduceMotionChange={vi.fn()}724 shortcuts={DEFAULT_BINDINGS}725 onShortcutsChange={vi.fn()}726 onBack={vi.fn()}727 onImport={vi.fn()}728 onExport={vi.fn()}729 onBreachCheckDone={vi.fn()}730 />731 );732 await flushView();733734 expect(container.querySelectorAll(".stk-card").length).toBe(7);735 736 737 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");738739 const changeButton = Array.from(container.querySelectorAll("button")).find(740 (candidate) => candidate.textContent?.trim() === "change"741 ) as HTMLButtonElement;742 changeButton.click();743 await flushView();744745 const saveButton = Array.from(container.querySelectorAll("button")).find(746 (candidate) => candidate.textContent?.trim() === "save new password"747 ) as HTMLButtonElement;748 expect(saveButton.disabled).toBe(true);749750 const inputs = container.querySelectorAll('input[type="password"]');751 inputText(inputs[0] as HTMLInputElement, "current-pass");752 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");753 await flushView();754 expect(saveButton.disabled).toBe(true);755756 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");757 await flushView();758 expect(saveButton.disabled).toBe(false);759 });760761 it("Settings feedback highlights the selected category and hides the form after submit", async () => {762 sendMessageMock.mockResolvedValue({ ok: true, data: null });763 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({764 ok: true,765 json: async () => ({ success: true }),766 }));767 vi.stubGlobal("fetch", fetchMock);768769 const container = mount(770 <SettingsView771 entriesCount={1}772 breachMonitoring={true}773 lockOnClose={true}774 autoLockMinutes={5}775 trashRetentionDays={30}776 theme="dark"777 accentColor="peach"778 textSize="medium"779 reduceMotion={false}780 initialSection="feedback"781 onBreachMonitoringChange={vi.fn()}782 onLockOnCloseChange={vi.fn()}783 onAutoLockMinutesChange={vi.fn()}784 onTrashRetentionChange={vi.fn()}785 onThemeChange={vi.fn()}786 onAccentChange={vi.fn()}787 onTextSizeChange={vi.fn()}788 onReduceMotionChange={vi.fn()}789 shortcuts={DEFAULT_BINDINGS}790 onShortcutsChange={vi.fn()}791 onBack={vi.fn()}792 onImport={vi.fn()}793 onExport={vi.fn()}794 onBreachCheckDone={vi.fn()}795 />796 );797 await flushView();798799 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;800 expect(feedbackCard.classList.contains("is-active")).toBe(true);801802 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];803 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;804 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;805 expect(bugButton.getAttribute("aria-pressed")).toBe("true");806 expect(featureButton.getAttribute("aria-pressed")).toBe("false");807808 featureButton.click();809 await flushView();810811 expect(bugButton.getAttribute("aria-pressed")).toBe("false");812 expect(featureButton.getAttribute("aria-pressed")).toBe("true");813 expect(featureButton.classList.contains("is-active")).toBe(true);814815 inputText(816 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,817 '<script>alert("xss")</script> Please make this easier to see & safer.'818 );819 const sendButton = Array.from(container.querySelectorAll("button")).find(820 (candidate) => candidate.textContent?.trim() === "Send"821 ) as HTMLButtonElement;822 sendButton.click();823824 await vi.waitFor(() => {825 expect(container.textContent).toContain("Thank you");826 });827828 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));829 expect(requestBody.subject).toContain("Feature request");830 expect(requestBody.message).toBe(831 "<script>alert("xss")</script> Please make this easier to see & safer."832 );833 expect(requestBody.message).not.toContain("<script>");834 expect(container.textContent).toContain("Someone will process it soon");835 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();836 expect(container.querySelector(".peach-feedback-submit")).toBeNull();837 });838839 it("Settings keeps license activation on the full-screen celebration path", async () => {840 const onLicenseChange = vi.fn(async () => undefined);841 sendMessageMock.mockImplementation(async (msg: unknown) => {842 const type = (msg as { type?: string }).type;843 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };844 if (type === "ACTIVATE_LICENSE") {845 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };846 }847 if (type === "GET_ENTRIES") return { ok: true, data: [] };848 return { ok: true, data: null };849 });850851 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);852 await flushView();853854 inputText(855 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,856 "PEACH-TEST-KEY",857 );858 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;859 apply.click();860861 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));862 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);863 expect(container.textContent).not.toContain("thank you ✦");864 });865866 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {867 sendMessageMock.mockImplementation(async (msg: unknown) => {868 const type = (msg as { type?: string }).type;869 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };870 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };871 if (type === "GET_ENTRIES") return { ok: true, data: [] };872 return { ok: true, data: null };873 });874875 const onLicenseChangeFromStar = vi.fn();876 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);877 await flushView();878 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();879 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);880 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();881882 const onLicenseChangeFromPeach = vi.fn();883 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);884 await flushView();885 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();886 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);887 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();888 });889890 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {891 sendMessageMock.mockResolvedValue({ ok: true, data: null });892 const fetchMock = vi.fn();893 const requestMock = vi.fn(async () => false);894 vi.stubGlobal("fetch", fetchMock);895 vi.stubGlobal("browser", {896 permissions: {897 getAll: vi.fn(async () => ({ data_collection: [] })),898 request: requestMock,899 },900 });901902 const container = mount(903 <SettingsView904 entriesCount={1}905 breachMonitoring={true}906 lockOnClose={true}907 autoLockMinutes={5}908 trashRetentionDays={30}909 theme="dark"910 accentColor="peach"911 textSize="medium"912 reduceMotion={false}913 initialSection="feedback"914 onBreachMonitoringChange={vi.fn()}915 onLockOnCloseChange={vi.fn()}916 onAutoLockMinutesChange={vi.fn()}917 onTrashRetentionChange={vi.fn()}918 onThemeChange={vi.fn()}919 onAccentChange={vi.fn()}920 onTextSizeChange={vi.fn()}921 onReduceMotionChange={vi.fn()}922 shortcuts={DEFAULT_BINDINGS}923 onShortcutsChange={vi.fn()}924 onBack={vi.fn()}925 onImport={vi.fn()}926 onExport={vi.fn()}927 onBreachCheckDone={vi.fn()}928 />929 );930 await flushView();931932 inputText(933 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,934 "Please improve the feedback flow."935 );936 const sendButton = Array.from(container.querySelectorAll("button")).find(937 (candidate) => candidate.textContent?.trim() === "Send"938 ) as HTMLButtonElement;939 sendButton.click();940941 await vi.waitFor(() => {942 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });943 });944 expect(fetchMock).not.toHaveBeenCalled();945 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();946 });947948 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {949 sendMessageMock.mockImplementation(async (msg: unknown) => {950 const m = msg as { type: string };951 if (m.type === "GET_RECOVERY_WORDS") {952 return {953 ok: true,954 data: [955 "abandon", "ability", "able", "about", "above", "absent",956 "absorb", "abstract", "absurd", "abuse", "access", "accident",957 ],958 };959 }960 return { ok: true, data: null };961 });962 const onAutoLockMinutesChange = vi.fn();963 const onThemeChange = vi.fn();964 const container = mount(965 <SettingsView966 entriesCount={1}967 breachMonitoring={true}968 lockOnClose={true}969 autoLockMinutes={5}970 trashRetentionDays={30}971 theme="dark"972 accentColor="peach"973 textSize="medium"974 reduceMotion={false}975 onBreachMonitoringChange={vi.fn()}976 onLockOnCloseChange={vi.fn()}977 onAutoLockMinutesChange={onAutoLockMinutesChange}978 onTrashRetentionChange={vi.fn()}979 onThemeChange={onThemeChange}980 onAccentChange={vi.fn()}981 onTextSizeChange={vi.fn()}982 onReduceMotionChange={vi.fn()}983 shortcuts={DEFAULT_BINDINGS}984 onShortcutsChange={vi.fn()}985 onBack={vi.fn()}986 onImport={vi.fn()}987 onExport={vi.fn()}988 onBreachCheckDone={vi.fn()}989 />990 );991 await flushView();992 sendMessageMock.mockClear();993994 995 const selects = container.querySelectorAll("select");996 changeSelect(selects[0] as HTMLSelectElement, "15");997 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);998999 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1000 appearanceCard.click();1001 await flushView();1002 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1003 const themeButton = Array.from(container.querySelectorAll("button")).find(1004 (candidate) => candidate.textContent?.trim() === "Switch to light"1005 ) as HTMLButtonElement;1006 themeButton.click();1007 expect(onThemeChange).toHaveBeenCalledWith("light");10081009 1010 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1011 protectionCard.click();1012 await flushView();10131014 const changeButton = Array.from(container.querySelectorAll("button")).find(1015 (candidate) => candidate.textContent?.trim() === "change"1016 ) as HTMLButtonElement;1017 changeButton.click();1018 await flushView();10191020 const inputs = container.querySelectorAll('input[type="password"]');1021 inputText(inputs[0] as HTMLInputElement, "current-pass");1022 inputText(inputs[1] as HTMLInputElement, "new-password-123");1023 inputText(inputs[2] as HTMLInputElement, "new-password-123");1024 await flushView();10251026 const saveButton = Array.from(container.querySelectorAll("button")).find(1027 (candidate) => candidate.textContent?.trim() === "save new password"1028 ) as HTMLButtonElement;1029 saveButton.click();1030 await flushView();10311032 expect(sendMessageMock).toHaveBeenCalledWith({1033 type: "CHANGE_PASSWORD",1034 payload: {1035 currentPassword: "current-pass",1036 newPassword: "new-password-123",1037 },1038 });10391040 1041 expect(container.textContent).toContain("save your recovery words");1042 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1043 });10441045 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1046 sendMessageMock.mockResolvedValue({ ok: true });1047 const container = mount(1048 <EntryView1049 entry={sampleEntry()}1050 onDelete={vi.fn()}1051 onFieldSaved={vi.fn(async () => undefined)}1052 />1053 );10541055 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1056 passwordButton.click();1057 await flushView();10581059 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1060 type: "COPY",1061 payload: { text: "secret123", label: "password", alreadyWritten: false },1062 });1063 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1064 type: "FIRE_CANARY_PING",1065 payload: {1066 webhookUrl: "https://example.com/hook",1067 entryTitle: "GitHub",1068 trigger: "password_copied",1069 },1070 });10711072 sendMessageMock.mockClear();1073 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1074 usernameButton.click();1075 await flushView();10761077 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1078 type: "COPY",1079 payload: { text: "june", label: "username", alreadyWritten: false },1080 });1081 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1082 type: "FIRE_CANARY_PING",1083 payload: {1084 webhookUrl: "https://example.com/hook",1085 entryTitle: "GitHub",1086 trigger: "username_copied",1087 },1088 });1089 });10901091 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1092 const container = mount(1093 <EntryView1094 entry={sampleEntry({1095 type: "passkey",1096 username: undefined,1097 password: undefined,1098 totp: undefined,1099 canary: undefined,1100 passkey: {1101 rpId: "vercel.com",1102 rpName: "Vercel",1103 credentialId: "cred-1",1104 publicKeyCose: "cose",1105 userHandle: "handle",1106 userName: "june",1107 userDisplayName: "June",1108 algorithm: -7,1109 signCount: 0,1110 aaguid: "",1111 backupEligible: false,1112 backupState: false,1113 createdAt: 1,1114 },1115 })}1116 onDelete={vi.fn()}1117 onFieldSaved={vi.fn(async () => undefined)}1118 />1119 );11201121 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1122 (button) => button.textContent?.includes("Advanced")1123 ) as HTMLButtonElement | undefined;1124 expect(advanced).toBeDefined();1125 expect(advanced?.textContent).toContain("+ Advanced");11261127 advanced!.click();1128 await flushView();11291130 expect(container.textContent).toContain("passkey · vercel.com");1131 });11321133 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1134 sendMessageMock.mockResolvedValue({ ok: true });1135 const onFieldSaved = vi.fn(async () => undefined);1136 const container = mount(1137 <EntryView1138 entry={sampleEntry()}1139 onDelete={vi.fn()}1140 onFieldSaved={onFieldSaved}1141 />1142 );11431144 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1145 passwordValue.click();1146 await flushView();11471148 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1149 inputText(passwordInput, "");1150 await flushView();11511152 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1153 saveButton.click();1154 await flushView();11551156 expect(sendMessageMock).not.toHaveBeenCalledWith(1157 expect.objectContaining({ type: "UPDATE_ENTRY" })1158 );1159 expect(onFieldSaved).not.toHaveBeenCalled();11601161 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1162 totpMain.click();11631164 await vi.waitFor(() => {1165 expect(sendMessageMock).toHaveBeenLastCalledWith({1166 type: "COPY",1167 payload: { text: "123456", label: "totp", alreadyWritten: false },1168 });1169 }, { timeout: 500 });1170 });11711172 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1173 sendMessageMock.mockResolvedValue({ ok: true });1174 const container = mount(1175 <EntryView1176 entry={sampleEntry()}1177 onDelete={vi.fn()}1178 onFieldSaved={vi.fn(async () => undefined)}1179 />1180 );11811182 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1183 usernameValue.click();1184 await flushView();11851186 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1187 usernameInput.blur();1188 await flushView();11891190 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1191 expect(sendMessageMock).not.toHaveBeenCalledWith(1192 expect.objectContaining({ type: "UPDATE_ENTRY" })1193 );1194 });11951196 it("Entry keeps the full used-email list available while editing an email", async () => {1197 const container = mount(1198 <EntryView1199 entry={sampleEntry({ email: "[email protected]" })}1200 entries={[1201 sampleEntry({ email: "[email protected]" }),1202 sampleEntry({ id: "entry-2", email: "[email protected]" }),1203 sampleEntry({ id: "entry-3", username: "duckuccino" }),1204 ]}1205 onDelete={vi.fn()}1206 onFieldSaved={vi.fn(async () => undefined)}1207 />1208 );12091210 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1211 (card) => card.querySelector(".peach-label")?.textContent === "email"1212 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1213 emailValue.click();1214 await flushView();12151216 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1217 .map((element) => element.textContent);1218 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1219 });12201221 it("Entry lets TOTP secrets be edited inline", async () => {1222 sendMessageMock.mockResolvedValue({ ok: true });1223 const onFieldSaved = vi.fn(async () => undefined);1224 const container = mount(1225 <EntryView1226 entry={sampleEntry()}1227 onDelete={vi.fn()}1228 onFieldSaved={onFieldSaved}1229 />1230 );12311232 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1233 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1234 await flushView();12351236 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1237 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1238 await flushView();12391240 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1241 saveButton.click();1242 await flushView();12431244 expect(sendMessageMock).toHaveBeenCalledWith({1245 type: "UPDATE_ENTRY",1246 payload: {1247 id: "entry-1",1248 patch: { totp: "NB2W45DFOIZA====" },1249 },1250 });1251 expect(onFieldSaved).toHaveBeenCalled();1252 });12531254 it("Vault seeds and focuses search when a search seed is provided", async () => {1255 const container = mount(1256 <VaultView1257 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1258 sortOrder="url-first"1259 searchSeed="g"1260 searchSeedNonce={1}1261 onSelectEntry={vi.fn()}1262 onSortOrderChange={vi.fn()}1263 onAdd={vi.fn()}1264 onClose={vi.fn()}1265 />1266 );12671268 await flushView();12691270 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1271 expect(search.value).toBe("g");1272 expect(document.activeElement).toBe(search);1273 });12741275 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1276 parseImportSourceMock.mockResolvedValue(1277 sampleImportResult({1278 entries: [1279 {1280 type: "login",1281 title: "Imported site",1282 username: "imported-user",1283 password: "imported-pass",1284 url: "https://imported.test",1285 },1286 ],1287 })1288 );1289 sendMessageMock.mockImplementation(async (msg: unknown) => {1290 const type = (msg as { type?: string }).type;1291 if (type === "GET_ENTRIES") {1292 return {1293 ok: true,1294 data: [1295 sampleEntry({1296 id: "existing-1",1297 title: "Imported site",1298 username: "imported-user",1299 url: "https://imported.test",1300 }),1301 ],1302 };1303 }1304 if (type === "BATCH_ADD_ENTRIES") {1305 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1306 }1307 return { ok: true };1308 });1309 const onImport = vi.fn(async () => undefined);1310 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1311 await flushView();1312 sendMessageMock.mockClear();13131314 await selectImportFile(container);1315 findButton(container, "Import 1").click();1316 await flushView();13171318 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1319 expect(sendMessageMock).toHaveBeenCalledWith({1320 type: "BATCH_ADD_ENTRIES",1321 payload: {1322 entries: [1323 expect.objectContaining({1324 title: "Imported site",1325 username: "imported-user",1326 password: "imported-pass",1327 url: "https://imported.test",1328 }),1329 ],1330 },1331 });1332 expect(onImport).toHaveBeenCalled();1333 expect(container.textContent).toContain("1 entry appears to be duplicates");1334 });13351336 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1337 parseImportSourceMock.mockResolvedValue(sampleImportResult());1338 sendMessageMock.mockImplementation(async (msg: unknown) => {1339 const type = (msg as { type?: string }).type;1340 if (type === "GET_ENTRIES") {1341 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1342 }1343 if (type === "CLEAR_VAULT_ENTRIES") {1344 return { ok: true };1345 }1346 if (type === "BATCH_ADD_ENTRIES") {1347 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1348 }1349 return { ok: true };1350 });1351 const onImport = vi.fn(async () => undefined);1352 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1353 await flushView();1354 sendMessageMock.mockClear();13551356 await selectImportFile(container);1357 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1358 await flushView();13591360 findButton(container, "Replace vault with 1").click();1361 await flushView();13621363 expect(sendMessageMock).not.toHaveBeenCalledWith(1364 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1365 );1366 expect(container.textContent).toContain("Replace forever? This cannot be undone.");13671368 findButton(container, "Yes, replace forever").click();1369 await flushView();13701371 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1372 expect(sendMessageMock).toHaveBeenCalledWith({1373 type: "BATCH_ADD_ENTRIES",1374 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1375 });1376 expect(onImport).toHaveBeenCalled();1377 });13781379 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1380 parseImportSourceMock.mockResolvedValue(sampleImportResult());1381 sendMessageMock.mockImplementation(async (msg: unknown) => {1382 const type = (msg as { type?: string }).type;1383 if (type === "CREATE_VAULT") return { ok: true };1384 if (type === "BATCH_ADD_ENTRIES") {1385 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1386 }1387 return { ok: true };1388 });1389 const onImport = vi.fn(async () => undefined);1390 const container = mount(1391 <ImportView1392 mode="onboarding"1393 replacesExistingVault={true}1394 onBack={vi.fn()}1395 onImport={onImport}1396 />1397 );13981399 await selectImportFile(container);14001401 const createButton = findButton(container, "Create vault + import 1");1402 expect(createButton.disabled).toBe(true);1403 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1404 expect(container.textContent).toContain("Use at least 8 characters");14051406 const masterInput = container.querySelector(1407 'input[placeholder="new master password"]'1408 ) as HTMLInputElement;1409 const confirmInput = container.querySelector(1410 'input[placeholder="confirm master password"]'1411 ) as HTMLInputElement;1412 const revealButton = container.querySelector(1413 'button[aria-label="Show passwords"]'1414 ) as HTMLButtonElement;1415 expect(revealButton).toBeTruthy();1416 revealButton.click();1417 await flushView();1418 expect(masterInput.type).toBe("text");1419 expect(confirmInput.type).toBe("text");14201421 inputText(masterInput, "fresh-master-password");1422 inputText(confirmInput, "fresh-master-password");1423 await flushView();14241425 expect(createButton.disabled).toBe(false);1426 createButton.click();1427 await flushView();14281429 expect(sendMessageMock).toHaveBeenCalledWith({1430 type: "CREATE_VAULT",1431 payload: { password: "fresh-master-password" },1432 });1433 expect(sendMessageMock).toHaveBeenCalledWith({1434 type: "BATCH_ADD_ENTRIES",1435 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1436 });1437 expect(onImport).toHaveBeenCalled();1438 });14391440 it("Import encrypted backups reparse with the backup password before import", async () => {1441 parseImportSourceMock1442 .mockResolvedValueOnce(1443 sampleImportResult({1444 format: "peach-json",1445 label: "Peach encrypted backup",1446 entries: [],1447 totalItems: 0,1448 requiresPassword: true,1449 })1450 )1451 .mockResolvedValueOnce(1452 sampleImportResult({1453 format: "peach-json",1454 label: "Peach encrypted backup",1455 })1456 );1457 sendMessageMock.mockImplementation(async (msg: unknown) => {1458 const type = (msg as { type?: string }).type;1459 if (type === "GET_ENTRIES") return { ok: true, data: [] };1460 if (type === "BATCH_ADD_ENTRIES") {1461 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1462 }1463 return { ok: true };1464 });1465 const onImport = vi.fn(async () => undefined);1466 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1467 await flushView();1468 sendMessageMock.mockClear();14691470 await selectImportFile(container, '{"type":"peach-export"}');1471 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1472 inputText(password, "backup-passphrase");1473 pressEnter(password);1474 await flushView();1475 await flushView();14761477 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1478 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");14791480 findButton(container, "Import 1").click();1481 await flushView();14821483 expect(sendMessageMock).toHaveBeenCalledWith({1484 type: "BATCH_ADD_ENTRIES",1485 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1486 });1487 expect(onImport).toHaveBeenCalled();1488 });1489});