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 sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {891 sendMessageMock.mockResolvedValue({ ok: true, data: null });892 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({893 ok: true,894 json: async () => ({ success: true }),895 }));896 vi.stubGlobal("fetch", fetchMock);897 898 899 900 vi.stubGlobal("browser", {901 permissions: {902 getAll: vi.fn(async () => ({ data_collection: [] })),903 request: vi.fn(async () => false),904 },905 });906907 const container = mount(908 <SettingsView909 entriesCount={1}910 breachMonitoring={true}911 lockOnClose={true}912 autoLockMinutes={5}913 trashRetentionDays={30}914 theme="dark"915 accentColor="peach"916 textSize="medium"917 reduceMotion={false}918 initialSection="feedback"919 onBreachMonitoringChange={vi.fn()}920 onLockOnCloseChange={vi.fn()}921 onAutoLockMinutesChange={vi.fn()}922 onTrashRetentionChange={vi.fn()}923 onThemeChange={vi.fn()}924 onAccentChange={vi.fn()}925 onTextSizeChange={vi.fn()}926 onReduceMotionChange={vi.fn()}927 shortcuts={DEFAULT_BINDINGS}928 onShortcutsChange={vi.fn()}929 onBack={vi.fn()}930 onImport={vi.fn()}931 onExport={vi.fn()}932 onBreachCheckDone={vi.fn()}933 />934 );935 await flushView();936937 inputText(938 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,939 "Please improve the feedback flow."940 );941 const sendButton = Array.from(container.querySelectorAll("button")).find(942 (candidate) => candidate.textContent?.trim() === "Send"943 ) as HTMLButtonElement;944 sendButton.click();945946 await vi.waitFor(() => {947 expect(fetchMock).toHaveBeenCalledTimes(1);948 });949 await vi.waitFor(() => {950 expect(container.textContent).toContain("Thank you");951 });952 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();953 });954955 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {956 sendMessageMock.mockImplementation(async (msg: unknown) => {957 const m = msg as { type: string };958 if (m.type === "GET_RECOVERY_WORDS") {959 return {960 ok: true,961 data: [962 "abandon", "ability", "able", "about", "above", "absent",963 "absorb", "abstract", "absurd", "abuse", "access", "accident",964 ],965 };966 }967 return { ok: true, data: null };968 });969 const onAutoLockMinutesChange = vi.fn();970 const onThemeChange = vi.fn();971 const container = mount(972 <SettingsView973 entriesCount={1}974 breachMonitoring={true}975 lockOnClose={true}976 autoLockMinutes={5}977 trashRetentionDays={30}978 theme="dark"979 accentColor="peach"980 textSize="medium"981 reduceMotion={false}982 onBreachMonitoringChange={vi.fn()}983 onLockOnCloseChange={vi.fn()}984 onAutoLockMinutesChange={onAutoLockMinutesChange}985 onTrashRetentionChange={vi.fn()}986 onThemeChange={onThemeChange}987 onAccentChange={vi.fn()}988 onTextSizeChange={vi.fn()}989 onReduceMotionChange={vi.fn()}990 shortcuts={DEFAULT_BINDINGS}991 onShortcutsChange={vi.fn()}992 onBack={vi.fn()}993 onImport={vi.fn()}994 onExport={vi.fn()}995 onBreachCheckDone={vi.fn()}996 />997 );998 await flushView();999 sendMessageMock.mockClear();10001001 1002 const selects = container.querySelectorAll("select");1003 changeSelect(selects[0] as HTMLSelectElement, "15");1004 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);10051006 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1007 appearanceCard.click();1008 await flushView();1009 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1010 const themeButton = Array.from(container.querySelectorAll("button")).find(1011 (candidate) => candidate.textContent?.trim() === "Switch to light"1012 ) as HTMLButtonElement;1013 themeButton.click();1014 expect(onThemeChange).toHaveBeenCalledWith("light");10151016 1017 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1018 protectionCard.click();1019 await flushView();10201021 const changeButton = Array.from(container.querySelectorAll("button")).find(1022 (candidate) => candidate.textContent?.trim() === "change"1023 ) as HTMLButtonElement;1024 changeButton.click();1025 await flushView();10261027 const inputs = container.querySelectorAll('input[type="password"]');1028 inputText(inputs[0] as HTMLInputElement, "current-pass");1029 inputText(inputs[1] as HTMLInputElement, "new-password-123");1030 inputText(inputs[2] as HTMLInputElement, "new-password-123");1031 await flushView();10321033 const saveButton = Array.from(container.querySelectorAll("button")).find(1034 (candidate) => candidate.textContent?.trim() === "save new password"1035 ) as HTMLButtonElement;1036 saveButton.click();1037 await flushView();10381039 expect(sendMessageMock).toHaveBeenCalledWith({1040 type: "CHANGE_PASSWORD",1041 payload: {1042 currentPassword: "current-pass",1043 newPassword: "new-password-123",1044 },1045 });10461047 1048 expect(container.textContent).toContain("save your recovery words");1049 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1050 });10511052 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1053 sendMessageMock.mockResolvedValue({ ok: true });1054 const container = mount(1055 <EntryView1056 entry={sampleEntry()}1057 onDelete={vi.fn()}1058 onFieldSaved={vi.fn(async () => undefined)}1059 />1060 );10611062 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1063 passwordButton.click();1064 await flushView();10651066 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1067 type: "COPY",1068 payload: { text: "secret123", label: "password", alreadyWritten: false },1069 });1070 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1071 type: "FIRE_CANARY_PING",1072 payload: {1073 webhookUrl: "https://example.com/hook",1074 entryTitle: "GitHub",1075 trigger: "password_copied",1076 },1077 });10781079 sendMessageMock.mockClear();1080 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1081 usernameButton.click();1082 await flushView();10831084 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1085 type: "COPY",1086 payload: { text: "june", label: "username", alreadyWritten: false },1087 });1088 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1089 type: "FIRE_CANARY_PING",1090 payload: {1091 webhookUrl: "https://example.com/hook",1092 entryTitle: "GitHub",1093 trigger: "username_copied",1094 },1095 });1096 });10971098 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1099 const container = mount(1100 <EntryView1101 entry={sampleEntry({1102 type: "passkey",1103 username: undefined,1104 password: undefined,1105 totp: undefined,1106 canary: undefined,1107 passkey: {1108 rpId: "vercel.com",1109 rpName: "Vercel",1110 credentialId: "cred-1",1111 publicKeyCose: "cose",1112 userHandle: "handle",1113 userName: "june",1114 userDisplayName: "June",1115 algorithm: -7,1116 signCount: 0,1117 aaguid: "",1118 backupEligible: false,1119 backupState: false,1120 createdAt: 1,1121 },1122 })}1123 onDelete={vi.fn()}1124 onFieldSaved={vi.fn(async () => undefined)}1125 />1126 );11271128 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1129 (button) => button.textContent?.includes("Advanced")1130 ) as HTMLButtonElement | undefined;1131 expect(advanced).toBeDefined();1132 expect(advanced?.textContent).toContain("+ Advanced");11331134 advanced!.click();1135 await flushView();11361137 expect(container.textContent).toContain("passkey · vercel.com");1138 });11391140 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1141 sendMessageMock.mockResolvedValue({ ok: true });1142 const onFieldSaved = vi.fn(async () => undefined);1143 const container = mount(1144 <EntryView1145 entry={sampleEntry()}1146 onDelete={vi.fn()}1147 onFieldSaved={onFieldSaved}1148 />1149 );11501151 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1152 passwordValue.click();1153 await flushView();11541155 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1156 inputText(passwordInput, "");1157 await flushView();11581159 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1160 saveButton.click();1161 await flushView();11621163 expect(sendMessageMock).not.toHaveBeenCalledWith(1164 expect.objectContaining({ type: "UPDATE_ENTRY" })1165 );1166 expect(onFieldSaved).not.toHaveBeenCalled();11671168 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1169 totpMain.click();11701171 await vi.waitFor(() => {1172 expect(sendMessageMock).toHaveBeenLastCalledWith({1173 type: "COPY",1174 payload: { text: "123456", label: "totp", alreadyWritten: false },1175 });1176 }, { timeout: 500 });1177 });11781179 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1180 sendMessageMock.mockResolvedValue({ ok: true });1181 const container = mount(1182 <EntryView1183 entry={sampleEntry()}1184 onDelete={vi.fn()}1185 onFieldSaved={vi.fn(async () => undefined)}1186 />1187 );11881189 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1190 usernameValue.click();1191 await flushView();11921193 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1194 usernameInput.blur();1195 await flushView();11961197 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1198 expect(sendMessageMock).not.toHaveBeenCalledWith(1199 expect.objectContaining({ type: "UPDATE_ENTRY" })1200 );1201 });12021203 it("Entry keeps the full used-email list available while editing an email", async () => {1204 const container = mount(1205 <EntryView1206 entry={sampleEntry({ email: "[email protected]" })}1207 entries={[1208 sampleEntry({ email: "[email protected]" }),1209 sampleEntry({ id: "entry-2", email: "[email protected]" }),1210 sampleEntry({ id: "entry-3", username: "duckuccino" }),1211 ]}1212 onDelete={vi.fn()}1213 onFieldSaved={vi.fn(async () => undefined)}1214 />1215 );12161217 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1218 (card) => card.querySelector(".peach-label")?.textContent === "email"1219 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1220 emailValue.click();1221 await flushView();12221223 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1224 .map((element) => element.textContent);1225 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1226 });12271228 it("Entry lets TOTP secrets be edited inline", async () => {1229 sendMessageMock.mockResolvedValue({ ok: true });1230 const onFieldSaved = vi.fn(async () => undefined);1231 const container = mount(1232 <EntryView1233 entry={sampleEntry()}1234 onDelete={vi.fn()}1235 onFieldSaved={onFieldSaved}1236 />1237 );12381239 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1240 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1241 await flushView();12421243 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1244 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1245 await flushView();12461247 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1248 saveButton.click();1249 await flushView();12501251 expect(sendMessageMock).toHaveBeenCalledWith({1252 type: "UPDATE_ENTRY",1253 payload: {1254 id: "entry-1",1255 patch: { totp: "NB2W45DFOIZA====" },1256 },1257 });1258 expect(onFieldSaved).toHaveBeenCalled();1259 });12601261 it("Vault seeds and focuses search when a search seed is provided", async () => {1262 const container = mount(1263 <VaultView1264 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1265 sortOrder="url-first"1266 searchSeed="g"1267 searchSeedNonce={1}1268 onSelectEntry={vi.fn()}1269 onSortOrderChange={vi.fn()}1270 onAdd={vi.fn()}1271 onClose={vi.fn()}1272 />1273 );12741275 await flushView();12761277 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1278 expect(search.value).toBe("g");1279 expect(document.activeElement).toBe(search);1280 });12811282 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1283 parseImportSourceMock.mockResolvedValue(1284 sampleImportResult({1285 entries: [1286 {1287 type: "login",1288 title: "Imported site",1289 username: "imported-user",1290 password: "imported-pass",1291 url: "https://imported.test",1292 },1293 ],1294 })1295 );1296 sendMessageMock.mockImplementation(async (msg: unknown) => {1297 const type = (msg as { type?: string }).type;1298 if (type === "GET_ENTRIES") {1299 return {1300 ok: true,1301 data: [1302 sampleEntry({1303 id: "existing-1",1304 title: "Imported site",1305 username: "imported-user",1306 url: "https://imported.test",1307 }),1308 ],1309 };1310 }1311 if (type === "BATCH_ADD_ENTRIES") {1312 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1313 }1314 return { ok: true };1315 });1316 const onImport = vi.fn(async () => undefined);1317 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1318 await flushView();1319 sendMessageMock.mockClear();13201321 await selectImportFile(container);1322 findButton(container, "Import 1").click();1323 await flushView();13241325 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1326 expect(sendMessageMock).toHaveBeenCalledWith({1327 type: "BATCH_ADD_ENTRIES",1328 payload: {1329 entries: [1330 expect.objectContaining({1331 title: "Imported site",1332 username: "imported-user",1333 password: "imported-pass",1334 url: "https://imported.test",1335 }),1336 ],1337 },1338 });1339 expect(onImport).toHaveBeenCalled();1340 expect(container.textContent).toContain("1 entry appears to be duplicates");1341 });13421343 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1344 parseImportSourceMock.mockResolvedValue(sampleImportResult());1345 sendMessageMock.mockImplementation(async (msg: unknown) => {1346 const type = (msg as { type?: string }).type;1347 if (type === "GET_ENTRIES") {1348 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1349 }1350 if (type === "CLEAR_VAULT_ENTRIES") {1351 return { ok: true };1352 }1353 if (type === "BATCH_ADD_ENTRIES") {1354 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1355 }1356 return { ok: true };1357 });1358 const onImport = vi.fn(async () => undefined);1359 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1360 await flushView();1361 sendMessageMock.mockClear();13621363 await selectImportFile(container);1364 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1365 await flushView();13661367 findButton(container, "Replace vault with 1").click();1368 await flushView();13691370 expect(sendMessageMock).not.toHaveBeenCalledWith(1371 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1372 );1373 expect(container.textContent).toContain("Replace forever? This cannot be undone.");13741375 findButton(container, "Yes, replace forever").click();1376 await flushView();13771378 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1379 expect(sendMessageMock).toHaveBeenCalledWith({1380 type: "BATCH_ADD_ENTRIES",1381 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1382 });1383 expect(onImport).toHaveBeenCalled();1384 });13851386 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1387 parseImportSourceMock.mockResolvedValue(sampleImportResult());1388 sendMessageMock.mockImplementation(async (msg: unknown) => {1389 const type = (msg as { type?: string }).type;1390 if (type === "CREATE_VAULT") return { ok: true };1391 if (type === "BATCH_ADD_ENTRIES") {1392 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1393 }1394 return { ok: true };1395 });1396 const onImport = vi.fn(async () => undefined);1397 const container = mount(1398 <ImportView1399 mode="onboarding"1400 replacesExistingVault={true}1401 onBack={vi.fn()}1402 onImport={onImport}1403 />1404 );14051406 await selectImportFile(container);14071408 const createButton = findButton(container, "Create vault + import 1");1409 expect(createButton.disabled).toBe(true);1410 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1411 expect(container.textContent).toContain("Use at least 8 characters");14121413 const masterInput = container.querySelector(1414 'input[placeholder="new master password"]'1415 ) as HTMLInputElement;1416 const confirmInput = container.querySelector(1417 'input[placeholder="confirm master password"]'1418 ) as HTMLInputElement;1419 const revealButton = container.querySelector(1420 'button[aria-label="Show passwords"]'1421 ) as HTMLButtonElement;1422 expect(revealButton).toBeTruthy();1423 revealButton.click();1424 await flushView();1425 expect(masterInput.type).toBe("text");1426 expect(confirmInput.type).toBe("text");14271428 inputText(masterInput, "fresh-master-password");1429 inputText(confirmInput, "fresh-master-password");1430 await flushView();14311432 expect(createButton.disabled).toBe(false);1433 createButton.click();1434 await flushView();14351436 expect(sendMessageMock).toHaveBeenCalledWith({1437 type: "CREATE_VAULT",1438 payload: { password: "fresh-master-password" },1439 });1440 expect(sendMessageMock).toHaveBeenCalledWith({1441 type: "BATCH_ADD_ENTRIES",1442 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1443 });1444 expect(onImport).toHaveBeenCalled();1445 });14461447 it("Import encrypted backups reparse with the backup password before import", async () => {1448 parseImportSourceMock1449 .mockResolvedValueOnce(1450 sampleImportResult({1451 format: "peach-json",1452 label: "Peach encrypted backup",1453 entries: [],1454 totalItems: 0,1455 requiresPassword: true,1456 })1457 )1458 .mockResolvedValueOnce(1459 sampleImportResult({1460 format: "peach-json",1461 label: "Peach encrypted backup",1462 })1463 );1464 sendMessageMock.mockImplementation(async (msg: unknown) => {1465 const type = (msg as { type?: string }).type;1466 if (type === "GET_ENTRIES") return { ok: true, data: [] };1467 if (type === "BATCH_ADD_ENTRIES") {1468 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1469 }1470 return { ok: true };1471 });1472 const onImport = vi.fn(async () => undefined);1473 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1474 await flushView();1475 sendMessageMock.mockClear();14761477 await selectImportFile(container, '{"type":"peach-export"}');1478 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1479 inputText(password, "backup-passphrase");1480 pressEnter(password);1481 await flushView();1482 await flushView();14831484 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1485 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");14861487 findButton(container, "Import 1").click();1488 await flushView();14891490 expect(sendMessageMock).toHaveBeenCalledWith({1491 type: "BATCH_ADD_ENTRIES",1492 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1493 });1494 expect(onImport).toHaveBeenCalled();1495 });1496});