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 { RecoveryWordDisplayView } from "./RecoveryWordDisplay";19import { VaultView } from "./Vault";20import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2122const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();23const triggerRingMock = vi.fn();24const triggerPipPulseMock = vi.fn();25const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();26const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2728vi.mock("../App", () => ({29 sendMessage: (msg: unknown) => sendMessageMock(msg),30 triggerRing: () => triggerRingMock(),31 triggerPipPulse: () => triggerPipPulseMock(),32}));3334vi.mock("../../shared/bridge", () => ({35 sendMessage: (msg: unknown) => sendMessageMock(msg),36}));3738vi.mock("../../shared/import", () => ({39 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),40}));4142vi.mock("../../shared/favicon", async (importOriginal) => {43 const actual = await importOriginal<typeof import("../../shared/favicon")>();44 return { ...actual, faviconUrl: () => null };45});4647vi.mock("../../crypto/totp", () => ({48 generateTOTP: () => ({ code: "123 456", remaining: 24 }),49 parseOtpauthURI: (value: string) =>50 value.startsWith("otpauth://")51 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }52 : null,53}));5455vi.mock("../components/GeneratorDrawer", () => ({56 GeneratorDrawer: () => null,57}));5859vi.mock("../components/RuneBackground", () => ({60 RuneBackground: () => null,61}));6263vi.mock("@zxcvbn-ts/core", () => ({64 zxcvbn: (value: string) => zxcvbnMock(value),65 zxcvbnOptions: { setOptions: vi.fn() },66}));67vi.mock("@zxcvbn-ts/language-common", () => ({68 adjacencyGraphs: {},69 dictionary: {},70}));71vi.mock("@zxcvbn-ts/language-en", () => ({72 translations: {},73 dictionary: {},74}));7576describe("popup contract tests", () => {77 const disposers: Array<() => void> = [];7879 beforeEach(() => {80 document.body.innerHTML = "";81 sendMessageMock.mockReset();82 triggerRingMock.mockReset();83 triggerPipPulseMock.mockReset();84 parseImportSourceMock.mockReset();85 zxcvbnMock.mockReset();86 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));87 parseImportSourceMock.mockResolvedValue({88 format: "unknown",89 label: "Unknown format",90 entries: [],91 totalItems: 0,92 skippedItems: 0,93 requiresPassword: false,94 });9596 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {97 return window.setTimeout(() => cb(0), 0) as unknown as number;98 });99 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));100 vi.stubGlobal("URL", {101 createObjectURL: vi.fn(() => "blob:peach-test"),102 revokeObjectURL: vi.fn(),103 });104 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});105106 (globalThis as { chrome?: typeof chrome }).chrome = {107 runtime: {108 getManifest: () => ({ version: "1.4.47" }),109 onMessage: { addListener: vi.fn() },110 },111 } as unknown as typeof chrome;112 });113114 afterEach(() => {115 while (disposers.length > 0) {116 disposers.pop()?.();117 }118 vi.restoreAllMocks();119 vi.unstubAllGlobals();120 });121122 function mount(view: JSX.Element | (() => JSX.Element)) {123 const mounted = renderView(view);124 disposers.push(mounted.dispose);125 return mounted.container;126 }127128 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {129 return {130 id: "entry-1",131 type: "login",132 title: "GitHub",133 username: "june",134 password: "secret123",135 url: "https://github.com",136 totp: "JBSWY3DPEHPK3PXP",137 canary: {138 webhookUrl: "https://example.com/hook",139 },140 createdAt: 1,141 modifiedAt: 1,142 ...overrides,143 };144 }145146 function sampleImportResult(147 overrides: Partial<ImportParseResult> = {}148 ): ImportParseResult {149 return {150 format: "csv",151 label: "CSV import",152 entries: [153 {154 type: "login",155 title: "Imported site",156 username: "imported-user",157 password: "imported-pass",158 url: "https://imported.test",159 },160 ],161 totalItems: 1,162 skippedItems: 0,163 requiresPassword: false,164 ...overrides,165 };166 }167168 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {169 return {170 entriesCount: 1,171 breachMonitoring: true,172 lockOnClose: true,173 autoLockMinutes: 5,174 trashRetentionDays: 30,175 theme: "dark" as const,176 accentColor: "peach" as const,177 textSize: "medium" as const,178 reduceMotion: false,179 onBreachMonitoringChange: vi.fn(),180 onLockOnCloseChange: vi.fn(),181 onAutoLockMinutesChange: vi.fn(),182 onTrashRetentionChange: vi.fn(),183 onThemeChange: vi.fn(),184 onAccentChange: vi.fn(),185 onTextSizeChange: vi.fn(),186 onReduceMotionChange: vi.fn(),187 shortcuts: DEFAULT_BINDINGS,188 onShortcutsChange: vi.fn(),189 onBack: vi.fn(),190 onImport: vi.fn(),191 onExport: vi.fn(),192 onBreachCheckDone: vi.fn(),193 ...overrides,194 };195 }196197 async function selectImportFile(198 container: HTMLElement,199 contents = '{"export":"test"}'200 ) {201 202 203 204 const fileCard = findButton(container, "Choose file →");205 if (fileCard) {206 fileCard.click();207 await flushView();208 }209 const input = container.querySelector('input[type="file"]') as HTMLInputElement;210 const file = new File([contents], "import.json", { type: "application/json" });211 Object.defineProperty(input, "files", {212 configurable: true,213 value: [file],214 });215 input.dispatchEvent(new Event("change", { bubbles: true }));216 await flushView();217 await flushView();218 }219220 function findButton(container: HTMLElement, text: string): HTMLButtonElement {221 return Array.from(container.querySelectorAll("button")).find(222 (candidate) => candidate.textContent?.trim() === text223 ) as HTMLButtonElement;224 }225226 it("Lock sends UNLOCK with the entered passphrase", async () => {227 sendMessageMock.mockResolvedValue({ ok: true });228 const onUnlock = vi.fn(async () => undefined);229 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);230231 const password = container.querySelector("#unlock-password") as HTMLInputElement;232 inputText(password, "correct horse battery staple");233 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();234 await flushView();235236 expect(sendMessageMock).toHaveBeenCalledWith({237 type: "UNLOCK",238 payload: { password: "correct horse battery staple" },239 });240 expect(onUnlock).toHaveBeenCalled();241 });242243 it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {244 sendMessageMock.mockImplementation(async (msg: unknown) => {245 return (msg as { type: string }).type === "GET_SETTINGS"246 ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }247 : { ok: true };248 });249 const onUnlock = vi.fn(async () => undefined);250 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);251 await flushView();252253 inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");254 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();255 await flushView();256257 expect(sendMessageMock).toHaveBeenCalledWith({258 type: "UNLOCK_WITH_PIN",259 payload: { pin: "1234" },260 });261 expect(onUnlock).toHaveBeenCalled();262 expect(container.textContent).toContain("use master password");263 });264265 it("places the PIN method switch below the unlock button", async () => {266 sendMessageMock.mockImplementation(async (msg: unknown) => {267 return (msg as { type: string }).type === "GET_SETTINGS"268 ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }269 : { ok: true };270 });271 const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);272 await flushView();273274 const buttons = Array.from(container.querySelectorAll("button"));275 const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");276 const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");277 expect(pinIndex).toBeGreaterThan(unlockIndex);278 });279280 it("keeps standalone note creation in Notes mode", async () => {281 const chromeApi = globalThis.chrome as unknown as {282 tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };283 };284 chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };285286 const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);287 await flushView();288289 expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))290 .toEqual(["login", "card"]);291 expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();292 });293294 it("gives Notes mode the full note-formatting toolbar", async () => {295 const container = mount(296 <NotesEditor297 note={null}298 onBack={vi.fn()}299 onSaved={vi.fn()}300 onDelete={vi.fn()}301 />302 );303 await flushView();304305 expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();306 expect(container.querySelector('[title="Divider"]')).toBeTruthy();307 expect(container.querySelector('[title="Quote"]')).toBeTruthy();308 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);309 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);310 expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");311 });312313 it("saves formatting applied by the Notes toolbar before leaving", async () => {314 const savedNote = {315 id: "note-1",316 type: "note" as const,317 title: "Untitled",318 notes: "<b>formatted</b>",319 createdAt: 1,320 modifiedAt: 2,321 };322 sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });323 const originalExecCommand = document.execCommand;324 const execCommandMock = vi.fn(() => {325 const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");326 if (body) body.innerHTML = "<b>formatted</b>";327 return true;328 });329 Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });330331 try {332 const container = mount(333 <NotesEditor334 note={null}335 onBack={vi.fn()}336 onSaved={vi.fn()}337 onDelete={vi.fn()}338 />339 );340 await flushView();341 container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(342 new MouseEvent("mousedown", { bubbles: true, cancelable: true })343 );344 expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);345 container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();346 await flushView();347348 expect(sendMessageMock).toHaveBeenCalledWith({349 type: "SAVE_NOTE",350 payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },351 });352 } finally {353 Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });354 }355 });356357 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {358 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);359 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;360 const password = container.querySelector("#master-password") as HTMLInputElement;361 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;362363 inputText(password, "short");364 inputText(confirm, "short");365 await flushView();366 expect(createButton.disabled).toBe(true);367368 zxcvbnMock.mockImplementation(() => ({ score: 1 }));369 inputText(password, "weakweakweak");370 inputText(confirm, "weakweakweak");371 await flushView();372 expect(createButton.disabled).toBe(true);373 });374375 it("Lock create-vault header pins the wordmark left with back on the right", async () => {376 const container = mount(377 <LockView378 hasVault={false}379 ready={true}380 onUnlock={vi.fn()}381 onBackToOnboarding={() => {}}382 />383 );384 const header = container.querySelector(".peach-newvault-header") as HTMLElement;385 const wordmark = header.querySelector(".peach-wordmark-stack");386 const back = header.querySelector(".peach-newvault-back");387 expect(wordmark).toBeTruthy();388 expect(back).toBeTruthy();389 390 391 expect(header.firstElementChild).toBe(wordmark);392 expect(header.lastElementChild).toBe(back);393 await flushView();394 });395396 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {397 398 sendMessageMock.mockImplementation(399 async (msg: unknown) => {400 const m = msg as { type: string; payload?: { password?: string } };401 if (m.type === "CREATE_VAULT") return { ok: true };402 if (m.type === "GET_RECOVERY_WORDS") {403 404 return {405 ok: true,406 data: [407 "abandon", "ability", "able", "about", "above", "absent",408 "absorb", "abstract", "absurd", "abuse", "access", "accident",409 ],410 };411 }412 return { ok: true };413 }414 );415 zxcvbnMock.mockImplementation(() => ({ score: 3 }));416 const onUnlock = vi.fn(async () => undefined);417 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);418419 const password = container.querySelector("#master-password") as HTMLInputElement;420 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;421 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;422423 inputText(password, "correct horse battery staple");424 inputText(confirm, "correct horse battery staple");425 await flushView();426 expect(createButton.disabled).toBe(false);427428 pressEnter(confirm);429 await flushView();430431 expect(sendMessageMock).toHaveBeenCalledWith({432 type: "CREATE_VAULT",433 payload: { password: "correct horse battery staple" },434 });435 436 437 expect(onUnlock).not.toHaveBeenCalled();438 });439440 it("recovery display asks for three distinct randomized word positions and confirms", async () => {441 const words12 = [442 "abandon", "ability", "able", "about", "above", "absent",443 "absorb", "abstract", "absurd", "abuse", "access", "accident",444 ];445 sendMessageMock.mockImplementation(async (msg: unknown) => {446 const type = (msg as { type?: string }).type;447 if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };448 return { ok: true };449 });450451 vi.useFakeTimers();452 const onDone = vi.fn();453 try {454 const container = mount(<RecoveryWordDisplayView onDone={onDone} />);455 456 await vi.advanceTimersByTimeAsync(0);457458 const prompts = Array.from(459 container.querySelectorAll(".peach-recovery-confirm-num")460 ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));461 expect(prompts).toHaveLength(3);462 expect(new Set(prompts).size).toBe(3);463 for (const p of prompts) {464 expect(p).toBeGreaterThanOrEqual(1);465 expect(p).toBeLessThanOrEqual(12);466 }467468 469 const inputs = Array.from(470 container.querySelectorAll(".peach-recovery-confirm-input")471 ) as HTMLInputElement[];472 expect(inputs).toHaveLength(3);473 expect(inputs[0]!.disabled).toBe(true);474 await vi.advanceTimersByTimeAsync(5000);475 expect(inputs[0]!.disabled).toBe(false);476477 prompts.forEach((pos, i) => {478 inputText(inputs[i]!, words12[pos - 1]!);479 });480481 const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;482 expect(confirmBtn.disabled).toBe(false);483 confirmBtn.click();484 await vi.advanceTimersByTimeAsync(700); 485486 expect(onDone).toHaveBeenCalledTimes(1);487 } finally {488 vi.useRealTimers();489 }490 });491492 it("Recovery words flow submits the new master password and completes", async () => {493 sendMessageMock.mockImplementation(async (msg: unknown) => {494 const type = (msg as { type?: string }).type;495 if (type === "VALIDATE_RECOVERY_WORDS") {496 return { ok: true, data: { valid: true, invalid: [] } };497 }498 if (type === "RECOVER_WITH_WORDS") return { ok: true };499 return { ok: true };500 });501 const canvasContext = {502 clearRect: vi.fn(),503 fillText: vi.fn(),504 beginPath: vi.fn(),505 closePath: vi.fn(),506 arc: vi.fn(),507 fill: vi.fn(),508 stroke: vi.fn(),509 save: vi.fn(),510 restore: vi.fn(),511 translate: vi.fn(),512 rotate: vi.fn(),513 scale: vi.fn(),514 drawImage: vi.fn(),515 measureText: vi.fn(() => ({ width: 0 })),516 setTransform: vi.fn(),517 fillRect: vi.fn(),518 strokeRect: vi.fn(),519 } as unknown as CanvasRenderingContext2D;520 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);521522 const onRecoveryComplete = vi.fn(async () => undefined);523 const container = mount(524 <RecoveryWordEntryView525 onCancel={vi.fn()}526 onRecoveryComplete={onRecoveryComplete}527 />528 );529530 const recoveryWords = WORD_LIST.slice(0, 12);531 for (let i = 0; i < recoveryWords.length; i++) {532 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;533 inputText(input, recoveryWords[i]!);534 }535 await flushView();536537 findButton(container, "verify words").click();538 await flushView();539540 expect(container.textContent).toContain("new master password");541542 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;543 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;544 inputText(newPassword, "fresh-recovery-password");545 inputText(confirmPassword, "fresh-recovery-password");546 await flushView();547548 findButton(container, "recover vault").click();549 await flushView();550551 expect(sendMessageMock).toHaveBeenCalledWith({552 type: "RECOVER_WITH_WORDS",553 payload: {554 words: recoveryWords,555 newPassword: "fresh-recovery-password",556 },557 });558 expect(onRecoveryComplete).toHaveBeenCalled();559 });560561 it("Export menu surfaces both backup formats", async () => {562 const container = mount(<ExportView onBack={vi.fn()} />);563 await flushView();564565 expect(container.textContent).toContain("PeachScript");566 expect(container.textContent).toContain("CSV");567 });568569 it("Lock can open directly on backup restore from onboarding", async () => {570 const container = mount(() => (571 <LockView572 hasVault={false}573 ready={true}574 onUnlock={vi.fn()}575 initialRestore={true}576 />577 ));578 await flushView();579580 expect(container.textContent).toContain("PeachScript");581 expect(container.textContent).toContain("Codex");582 expect(container.textContent).toContain("CSV");583 expect(container.querySelector("#master-password")).toBeNull();584 });585586 it("Lock restore back returns to onboarding, not the create-vault screen", async () => {587 const onBackToOnboarding = vi.fn();588 const container = mount(() => (589 <LockView590 hasVault={false}591 ready={true}592 onUnlock={vi.fn()}593 initialRestore={true}594 onBackToOnboarding={onBackToOnboarding}595 />596 ));597 await flushView();598599 (container.querySelector(".peach-restore-back") as HTMLButtonElement).click();600 await flushView();601 expect(onBackToOnboarding).toHaveBeenCalledTimes(1);602 });603604 it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {605 const container = mount(() => (606 <LockView607 hasVault={false}608 ready={true}609 onUnlock={vi.fn()}610 initialRestore={true}611 initialRestoreSource="symbols"612 />613 ));614 await flushView();615616 expect(container.querySelector(".peach-restore-script")).toBeTruthy();617 expect(container.querySelector("#restore-backup-pass")).toBeTruthy();618 expect(container.querySelector(".peach-restore-sources")).toBeNull();619 });620621 it("Lock restore resumes on the picked CSV source, not the picker", async () => {622 const container = mount(() => (623 <LockView624 hasVault={false}625 ready={true}626 onUnlock={vi.fn()}627 initialRestore={true}628 initialRestoreSource="csv"629 />630 ));631 await flushView();632633 expect(container.querySelector("#restore-csv-pass")).toBeTruthy();634 expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();635 expect(container.querySelector(".peach-restore-sources")).toBeNull();636 });637638 it("Lock restore PeachScript form loads a dropped .peach file", async () => {639 const container = mount(() => (640 <LockView641 hasVault={false}642 ready={true}643 onUnlock={vi.fn()}644 initialRestore={true}645 initialRestoreSource="symbols"646 />647 ));648 await flushView();649650 const form = container.querySelector(".peach-restore-form") as HTMLElement;651 expect(form).toBeTruthy();652653 654 655 const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });656 const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {657 dataTransfer?: { files: File[] };658 };659 drop.dataTransfer = { files: [file] };660 form.dispatchEvent(drop);661 await flushView();662663 const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;664 expect(script.value).toBe("dropped-peach-backup");665 });666667 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {668 const device = {669 deviceId: "phone-1",670 deviceName: "June's Phone",671 deviceType: "android" as const,672 longTermPublicKeyHex: "a".repeat(64),673 signingPublicKeyHex: "b".repeat(64),674 pairedAt: 1,675 trustSource: "direct" as const,676 };677 sendMessageMock.mockImplementation(async (msg: unknown) => {678 const m = msg as { type: string };679 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };680 return { ok: true, data: null };681 });682683 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);684 for (let i = 0; i < 30; i++) await Promise.resolve();685686 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>687 /sync/i.test(b.textContent ?? ""),688 );689 openSync?.click();690 for (let i = 0; i < 30; i++) await Promise.resolve();691692 const remove = Array.from(container.querySelectorAll("button")).find(693 (b) => (b.textContent ?? "").trim() === "remove",694 );695 expect(remove).toBeTruthy();696697 698 699 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();700701 remove!.click();702 for (let i = 0; i < 10; i++) await Promise.resolve();703704 705 706 const note = container.querySelector(".peach-unpair-confirm-note");707 expect(note).toBeTruthy();708 const text = note!.textContent ?? "";709 expect(text).toContain("master password");710 expect(text).toContain("recovery words");711 expect(text).toContain("future changes");712 });713714 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {715 const device = {716 deviceId: "phone-1",717 deviceName: "June's Phone",718 deviceType: "android" as const,719 longTermPublicKeyHex: "a".repeat(64),720 signingPublicKeyHex: "b".repeat(64),721 pairedAt: 1,722 trustSource: "direct" as const,723 };724 let paired: typeof device[] = [device];725 sendMessageMock.mockImplementation(async (msg: any) => {726 if (msg?.type === "LIST_PAIRED_DEVICES") {727 return { ok: true, data: { devices: [...paired] } };728 }729 if (msg?.type === "UNPAIR_DEVICE") {730 731 732 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);733 return { ok: true };734 }735 if (msg?.type === "RENAME_PAIRED_DEVICE") {736 paired = paired.map((d) =>737 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,738 );739 return { ok: true };740 }741 return { ok: true, data: null };742 });743744 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);745 for (let i = 0; i < 30; i++) await Promise.resolve();746 Array.from(container.querySelectorAll("button"))747 .find((b) => /sync/i.test(b.textContent ?? ""))748 ?.click();749 for (let i = 0; i < 30; i++) await Promise.resolve();750751 752 expect(container.textContent).toContain("PAIRED DEVICES");753 expect(container.textContent).toContain("June's Phone");754 expect(container.textContent).toContain("Android");755 Array.from(container.querySelectorAll("button"))756 .find((b) => (b.textContent ?? "").trim() === "rename")!757 .click();758 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;759 renameInput.value = "Travel phone";760 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));761 Array.from(container.querySelectorAll("button"))762 .find((b) => (b.textContent ?? "").trim() === "save")!763 .click();764 for (let i = 0; i < 30; i++) await Promise.resolve();765 expect(sendMessageMock).toHaveBeenCalledWith({766 type: "RENAME_PAIRED_DEVICE",767 payload: { deviceId: "phone-1", name: "Travel phone" },768 });769 expect(container.textContent).toContain("Travel phone");770 const syncNowButton = container.querySelector<HTMLButtonElement>(771 'button[aria-label="Sync this vault now"]',772 )!;773 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));774 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;775 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;776 expect(777 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,778 ).toBeTruthy();779 expect(780 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,781 ).toBeTruthy();782783 Array.from(container.querySelectorAll("button"))784 .find((b) => (b.textContent ?? "").trim() === "remove")!785 .click();786 for (let i = 0; i < 10; i++) await Promise.resolve();787 const confirm = Array.from(788 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),789 ).find((b) => (b.textContent ?? "").trim() === "remove");790 confirm!.click();791 for (let i = 0; i < 30; i++) await Promise.resolve();792793 expect(sendMessageMock).toHaveBeenCalledWith({794 type: "UNPAIR_DEVICE",795 payload: { deviceId: "phone-1" },796 });797 798 799 800 expect(container.textContent).not.toContain("June's Phone");801 expect(container.textContent).not.toContain("PAIRED DEVICES");802 });803804 it("Settings keeps connection and recovery details collapsed until requested", async () => {805 sendMessageMock.mockImplementation(async (msg: any) => {806 if (msg?.type === "LIST_PAIRED_DEVICES") {807 return { ok: true, data: { devices: [] } };808 }809 return { ok: true, data: null };810 });811812 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);813 for (let i = 0; i < 30; i++) await Promise.resolve();814 Array.from(container.querySelectorAll("button"))815 .find((button) => /sync/i.test(button.textContent ?? ""))816 ?.click();817 for (let i = 0; i < 20; i++) await Promise.resolve();818819 expect(container.textContent).toContain("SYNC STATUS");820 expect(container.textContent).toContain("ADD A DEVICE");821 expect(container.textContent).not.toContain("Public relay");822 expect(container.textContent).not.toContain("Rotate sync identity");823824 const connection = Array.from(container.querySelectorAll("button"))825 .find((button) => button.textContent?.includes("CONNECTION"));826 connection?.click();827 await Promise.resolve();828 expect(container.textContent).toContain("Public relay");829830 const recovery = Array.from(container.querySelectorAll("button"))831 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));832 recovery?.click();833 await Promise.resolve();834 expect(container.textContent).toContain("Rotate sync identity");835 });836837 it("Settings requires current, new, and confirm passwords before enabling save", async () => {838 sendMessageMock.mockResolvedValue({ ok: true, data: null });839 const container = mount(840 <SettingsView841 entriesCount={1}842 breachMonitoring={true}843 lockOnClose={true}844 autoLockMinutes={5}845 trashRetentionDays={30}846 theme="dark"847 accentColor="peach"848 textSize="medium"849 reduceMotion={false}850 onBreachMonitoringChange={vi.fn()}851 onLockOnCloseChange={vi.fn()}852 onAutoLockMinutesChange={vi.fn()}853 onTrashRetentionChange={vi.fn()}854 onThemeChange={vi.fn()}855 onAccentChange={vi.fn()}856 onTextSizeChange={vi.fn()}857 onReduceMotionChange={vi.fn()}858 shortcuts={DEFAULT_BINDINGS}859 onShortcutsChange={vi.fn()}860 onBack={vi.fn()}861 onImport={vi.fn()}862 onExport={vi.fn()}863 onBreachCheckDone={vi.fn()}864 />865 );866 await flushView();867868 expect(container.querySelectorAll(".stk-card").length).toBe(7);869 870 871 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");872873 const changeButton = Array.from(container.querySelectorAll("button")).find(874 (candidate) => candidate.textContent?.trim() === "change"875 ) as HTMLButtonElement;876 changeButton.click();877 await flushView();878879 const saveButton = Array.from(container.querySelectorAll("button")).find(880 (candidate) => candidate.textContent?.trim() === "save new password"881 ) as HTMLButtonElement;882 expect(saveButton.disabled).toBe(true);883884 const inputs = container.querySelectorAll('input[type="password"]');885 inputText(inputs[0] as HTMLInputElement, "current-pass");886 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");887 await flushView();888 expect(saveButton.disabled).toBe(true);889890 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");891 await flushView();892 expect(saveButton.disabled).toBe(false);893 });894895 it("Settings feedback highlights the selected category and hides the form after submit", async () => {896 sendMessageMock.mockResolvedValue({ ok: true, data: null });897 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({898 ok: true,899 json: async () => ({ success: true }),900 }));901 vi.stubGlobal("fetch", fetchMock);902903 const container = mount(904 <SettingsView905 entriesCount={1}906 breachMonitoring={true}907 lockOnClose={true}908 autoLockMinutes={5}909 trashRetentionDays={30}910 theme="dark"911 accentColor="peach"912 textSize="medium"913 reduceMotion={false}914 initialSection="feedback"915 onBreachMonitoringChange={vi.fn()}916 onLockOnCloseChange={vi.fn()}917 onAutoLockMinutesChange={vi.fn()}918 onTrashRetentionChange={vi.fn()}919 onThemeChange={vi.fn()}920 onAccentChange={vi.fn()}921 onTextSizeChange={vi.fn()}922 onReduceMotionChange={vi.fn()}923 shortcuts={DEFAULT_BINDINGS}924 onShortcutsChange={vi.fn()}925 onBack={vi.fn()}926 onImport={vi.fn()}927 onExport={vi.fn()}928 onBreachCheckDone={vi.fn()}929 />930 );931 await flushView();932933 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;934 expect(feedbackCard.classList.contains("is-active")).toBe(true);935936 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];937 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;938 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;939 expect(bugButton.getAttribute("aria-pressed")).toBe("true");940 expect(featureButton.getAttribute("aria-pressed")).toBe("false");941942 featureButton.click();943 await flushView();944945 expect(bugButton.getAttribute("aria-pressed")).toBe("false");946 expect(featureButton.getAttribute("aria-pressed")).toBe("true");947 expect(featureButton.classList.contains("is-active")).toBe(true);948949 inputText(950 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,951 '<script>alert("xss")</script> Please make this easier to see & safer.'952 );953 const sendButton = Array.from(container.querySelectorAll("button")).find(954 (candidate) => candidate.textContent?.trim() === "Send"955 ) as HTMLButtonElement;956 sendButton.click();957958 await vi.waitFor(() => {959 expect(container.textContent).toContain("Thank you");960 });961962 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));963 expect(requestBody.subject).toContain("Feature request");964 expect(requestBody.message).toBe(965 "<script>alert("xss")</script> Please make this easier to see & safer."966 );967 expect(requestBody.message).not.toContain("<script>");968 expect(container.textContent).toContain("Someone will process it soon");969 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();970 expect(container.querySelector(".peach-feedback-submit")).toBeNull();971 });972973 it("Settings keeps license activation on the full-screen celebration path", async () => {974 const onLicenseChange = vi.fn(async () => undefined);975 sendMessageMock.mockImplementation(async (msg: unknown) => {976 const type = (msg as { type?: string }).type;977 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };978 if (type === "ACTIVATE_LICENSE") {979 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };980 }981 if (type === "GET_ENTRIES") return { ok: true, data: [] };982 return { ok: true, data: null };983 });984985 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);986 await flushView();987988 inputText(989 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,990 "PEACH-TEST-KEY",991 );992 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;993 apply.click();994995 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));996 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);997 expect(container.textContent).not.toContain("thank you ✦");998 });9991000 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1001 sendMessageMock.mockImplementation(async (msg: unknown) => {1002 const type = (msg as { type?: string }).type;1003 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1004 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1005 if (type === "GET_ENTRIES") return { ok: true, data: [] };1006 return { ok: true, data: null };1007 });10081009 const onLicenseChangeFromStar = vi.fn();1010 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1011 await flushView();1012 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1013 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1014 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();10151016 const onLicenseChangeFromPeach = vi.fn();1017 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1018 await flushView();1019 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1020 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1021 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1022 });10231024 it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1025 sendMessageMock.mockResolvedValue({ ok: true, data: null });1026 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1027 ok: true,1028 json: async () => ({ success: true }),1029 }));1030 vi.stubGlobal("fetch", fetchMock);1031 1032 1033 1034 vi.stubGlobal("browser", {1035 permissions: {1036 getAll: vi.fn(async () => ({ data_collection: [] })),1037 request: vi.fn(async () => false),1038 },1039 });10401041 const container = mount(1042 <SettingsView1043 entriesCount={1}1044 breachMonitoring={true}1045 lockOnClose={true}1046 autoLockMinutes={5}1047 trashRetentionDays={30}1048 theme="dark"1049 accentColor="peach"1050 textSize="medium"1051 reduceMotion={false}1052 initialSection="feedback"1053 onBreachMonitoringChange={vi.fn()}1054 onLockOnCloseChange={vi.fn()}1055 onAutoLockMinutesChange={vi.fn()}1056 onTrashRetentionChange={vi.fn()}1057 onThemeChange={vi.fn()}1058 onAccentChange={vi.fn()}1059 onTextSizeChange={vi.fn()}1060 onReduceMotionChange={vi.fn()}1061 shortcuts={DEFAULT_BINDINGS}1062 onShortcutsChange={vi.fn()}1063 onBack={vi.fn()}1064 onImport={vi.fn()}1065 onExport={vi.fn()}1066 onBreachCheckDone={vi.fn()}1067 />1068 );1069 await flushView();10701071 inputText(1072 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1073 "Please improve the feedback flow."1074 );1075 const sendButton = Array.from(container.querySelectorAll("button")).find(1076 (candidate) => candidate.textContent?.trim() === "Send"1077 ) as HTMLButtonElement;1078 sendButton.click();10791080 await vi.waitFor(() => {1081 expect(fetchMock).toHaveBeenCalledTimes(1);1082 });1083 await vi.waitFor(() => {1084 expect(container.textContent).toContain("Thank you");1085 });1086 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1087 });10881089 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1090 sendMessageMock.mockImplementation(async (msg: unknown) => {1091 const m = msg as { type: string };1092 if (m.type === "GET_RECOVERY_WORDS") {1093 return {1094 ok: true,1095 data: [1096 "abandon", "ability", "able", "about", "above", "absent",1097 "absorb", "abstract", "absurd", "abuse", "access", "accident",1098 ],1099 };1100 }1101 return { ok: true, data: null };1102 });1103 const onAutoLockMinutesChange = vi.fn();1104 const onThemeChange = vi.fn();1105 const container = mount(1106 <SettingsView1107 entriesCount={1}1108 breachMonitoring={true}1109 lockOnClose={true}1110 autoLockMinutes={5}1111 trashRetentionDays={30}1112 theme="dark"1113 accentColor="peach"1114 textSize="medium"1115 reduceMotion={false}1116 onBreachMonitoringChange={vi.fn()}1117 onLockOnCloseChange={vi.fn()}1118 onAutoLockMinutesChange={onAutoLockMinutesChange}1119 onTrashRetentionChange={vi.fn()}1120 onThemeChange={onThemeChange}1121 onAccentChange={vi.fn()}1122 onTextSizeChange={vi.fn()}1123 onReduceMotionChange={vi.fn()}1124 shortcuts={DEFAULT_BINDINGS}1125 onShortcutsChange={vi.fn()}1126 onBack={vi.fn()}1127 onImport={vi.fn()}1128 onExport={vi.fn()}1129 onBreachCheckDone={vi.fn()}1130 />1131 );1132 await flushView();1133 sendMessageMock.mockClear();11341135 1136 const selects = container.querySelectorAll("select");1137 changeSelect(selects[0] as HTMLSelectElement, "15");1138 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);11391140 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1141 appearanceCard.click();1142 await flushView();1143 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1144 const themeButton = Array.from(container.querySelectorAll("button")).find(1145 (candidate) => candidate.textContent?.trim() === "Switch to light"1146 ) as HTMLButtonElement;1147 themeButton.click();1148 expect(onThemeChange).toHaveBeenCalledWith("light");11491150 1151 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1152 protectionCard.click();1153 await flushView();11541155 const changeButton = Array.from(container.querySelectorAll("button")).find(1156 (candidate) => candidate.textContent?.trim() === "change"1157 ) as HTMLButtonElement;1158 changeButton.click();1159 await flushView();11601161 const inputs = container.querySelectorAll('input[type="password"]');1162 inputText(inputs[0] as HTMLInputElement, "current-pass");1163 inputText(inputs[1] as HTMLInputElement, "new-password-123");1164 inputText(inputs[2] as HTMLInputElement, "new-password-123");1165 await flushView();11661167 const saveButton = Array.from(container.querySelectorAll("button")).find(1168 (candidate) => candidate.textContent?.trim() === "save new password"1169 ) as HTMLButtonElement;1170 saveButton.click();1171 await flushView();11721173 expect(sendMessageMock).toHaveBeenCalledWith({1174 type: "CHANGE_PASSWORD",1175 payload: {1176 currentPassword: "current-pass",1177 newPassword: "new-password-123",1178 },1179 });11801181 1182 expect(container.textContent).toContain("save your recovery words");1183 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1184 });11851186 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1187 sendMessageMock.mockResolvedValue({ ok: true });1188 const container = mount(1189 <EntryView1190 entry={sampleEntry()}1191 onDelete={vi.fn()}1192 onFieldSaved={vi.fn(async () => undefined)}1193 />1194 );11951196 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1197 passwordButton.click();1198 await flushView();11991200 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1201 type: "COPY",1202 payload: { text: "secret123", label: "password", alreadyWritten: false },1203 });1204 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1205 type: "FIRE_CANARY_PING",1206 payload: {1207 webhookUrl: "https://example.com/hook",1208 entryTitle: "GitHub",1209 trigger: "password_copied",1210 },1211 });12121213 sendMessageMock.mockClear();1214 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1215 usernameButton.click();1216 await flushView();12171218 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1219 type: "COPY",1220 payload: { text: "june", label: "username", alreadyWritten: false },1221 });1222 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1223 type: "FIRE_CANARY_PING",1224 payload: {1225 webhookUrl: "https://example.com/hook",1226 entryTitle: "GitHub",1227 trigger: "username_copied",1228 },1229 });1230 });12311232 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1233 const container = mount(1234 <EntryView1235 entry={sampleEntry({1236 type: "passkey",1237 username: undefined,1238 password: undefined,1239 totp: undefined,1240 canary: undefined,1241 passkey: {1242 rpId: "vercel.com",1243 rpName: "Vercel",1244 credentialId: "cred-1",1245 publicKeyCose: "cose",1246 userHandle: "handle",1247 userName: "june",1248 userDisplayName: "June",1249 algorithm: -7,1250 signCount: 0,1251 aaguid: "",1252 backupEligible: false,1253 backupState: false,1254 createdAt: 1,1255 },1256 })}1257 onDelete={vi.fn()}1258 onFieldSaved={vi.fn(async () => undefined)}1259 />1260 );12611262 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1263 (button) => button.textContent?.includes("Advanced")1264 ) as HTMLButtonElement | undefined;1265 expect(advanced).toBeDefined();1266 expect(advanced?.textContent).toContain("+ Advanced");12671268 advanced!.click();1269 await flushView();12701271 expect(container.textContent).toContain("passkey · vercel.com");1272 });12731274 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1275 sendMessageMock.mockResolvedValue({ ok: true });1276 const onFieldSaved = vi.fn(async () => undefined);1277 const container = mount(1278 <EntryView1279 entry={sampleEntry()}1280 onDelete={vi.fn()}1281 onFieldSaved={onFieldSaved}1282 />1283 );12841285 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1286 passwordValue.click();1287 await flushView();12881289 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1290 inputText(passwordInput, "");1291 await flushView();12921293 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1294 saveButton.click();1295 await flushView();12961297 expect(sendMessageMock).not.toHaveBeenCalledWith(1298 expect.objectContaining({ type: "UPDATE_ENTRY" })1299 );1300 expect(onFieldSaved).not.toHaveBeenCalled();13011302 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1303 totpMain.click();13041305 await vi.waitFor(() => {1306 expect(sendMessageMock).toHaveBeenLastCalledWith({1307 type: "COPY",1308 payload: { text: "123456", label: "totp", alreadyWritten: false },1309 });1310 }, { timeout: 500 });1311 });13121313 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1314 sendMessageMock.mockResolvedValue({ ok: true });1315 const container = mount(1316 <EntryView1317 entry={sampleEntry()}1318 onDelete={vi.fn()}1319 onFieldSaved={vi.fn(async () => undefined)}1320 />1321 );13221323 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1324 usernameValue.click();1325 await flushView();13261327 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1328 usernameInput.blur();1329 await flushView();13301331 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1332 expect(sendMessageMock).not.toHaveBeenCalledWith(1333 expect.objectContaining({ type: "UPDATE_ENTRY" })1334 );1335 });13361337 it("Entry keeps the full used-email list available while editing an email", async () => {1338 const container = mount(1339 <EntryView1340 entry={sampleEntry({ email: "[email protected]" })}1341 entries={[1342 sampleEntry({ email: "[email protected]" }),1343 sampleEntry({ id: "entry-2", email: "[email protected]" }),1344 sampleEntry({ id: "entry-3", username: "duckuccino" }),1345 ]}1346 onDelete={vi.fn()}1347 onFieldSaved={vi.fn(async () => undefined)}1348 />1349 );13501351 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1352 (card) => card.querySelector(".peach-label")?.textContent === "email"1353 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1354 emailValue.click();1355 await flushView();13561357 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1358 .map((element) => element.textContent);1359 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1360 });13611362 it("Entry lets TOTP secrets be edited inline", async () => {1363 sendMessageMock.mockResolvedValue({ ok: true });1364 const onFieldSaved = vi.fn(async () => undefined);1365 const container = mount(1366 <EntryView1367 entry={sampleEntry()}1368 onDelete={vi.fn()}1369 onFieldSaved={onFieldSaved}1370 />1371 );13721373 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1374 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1375 await flushView();13761377 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1378 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1379 await flushView();13801381 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1382 saveButton.click();1383 await flushView();13841385 expect(sendMessageMock).toHaveBeenCalledWith({1386 type: "UPDATE_ENTRY",1387 payload: {1388 id: "entry-1",1389 patch: { totp: "NB2W45DFOIZA====" },1390 },1391 });1392 expect(onFieldSaved).toHaveBeenCalled();1393 });13941395 it("Vault seeds and focuses search when a search seed is provided", async () => {1396 const container = mount(1397 <VaultView1398 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1399 sortOrder="url-first"1400 searchSeed="g"1401 searchSeedNonce={1}1402 onSelectEntry={vi.fn()}1403 onSortOrderChange={vi.fn()}1404 onAdd={vi.fn()}1405 onClose={vi.fn()}1406 />1407 );14081409 await flushView();14101411 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1412 expect(search.value).toBe("g");1413 expect(document.activeElement).toBe(search);1414 });14151416 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1417 parseImportSourceMock.mockResolvedValue(1418 sampleImportResult({1419 entries: [1420 {1421 type: "login",1422 title: "Imported site",1423 username: "imported-user",1424 password: "imported-pass",1425 url: "https://imported.test",1426 },1427 ],1428 })1429 );1430 sendMessageMock.mockImplementation(async (msg: unknown) => {1431 const type = (msg as { type?: string }).type;1432 if (type === "GET_ENTRIES") {1433 return {1434 ok: true,1435 data: [1436 sampleEntry({1437 id: "existing-1",1438 title: "Imported site",1439 username: "imported-user",1440 url: "https://imported.test",1441 }),1442 ],1443 };1444 }1445 if (type === "BATCH_ADD_ENTRIES") {1446 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1447 }1448 return { ok: true };1449 });1450 const onImport = vi.fn(async () => undefined);1451 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1452 await flushView();1453 sendMessageMock.mockClear();14541455 await selectImportFile(container);1456 findButton(container, "Import 1").click();1457 await flushView();14581459 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1460 expect(sendMessageMock).toHaveBeenCalledWith({1461 type: "BATCH_ADD_ENTRIES",1462 payload: {1463 entries: [1464 expect.objectContaining({1465 title: "Imported site",1466 username: "imported-user",1467 password: "imported-pass",1468 url: "https://imported.test",1469 }),1470 ],1471 },1472 });1473 expect(onImport).toHaveBeenCalled();1474 expect(container.textContent).toContain("1 entry appears to be duplicates");1475 });14761477 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1478 parseImportSourceMock.mockResolvedValue(sampleImportResult());1479 sendMessageMock.mockImplementation(async (msg: unknown) => {1480 const type = (msg as { type?: string }).type;1481 if (type === "GET_ENTRIES") {1482 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1483 }1484 if (type === "CLEAR_VAULT_ENTRIES") {1485 return { ok: true };1486 }1487 if (type === "BATCH_ADD_ENTRIES") {1488 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1489 }1490 return { ok: true };1491 });1492 const onImport = vi.fn(async () => undefined);1493 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1494 await flushView();1495 sendMessageMock.mockClear();14961497 await selectImportFile(container);1498 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1499 await flushView();15001501 findButton(container, "Replace vault with 1").click();1502 await flushView();15031504 expect(sendMessageMock).not.toHaveBeenCalledWith(1505 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1506 );1507 expect(container.textContent).toContain("Replace forever? This cannot be undone.");15081509 findButton(container, "Yes, replace forever").click();1510 await flushView();15111512 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1513 expect(sendMessageMock).toHaveBeenCalledWith({1514 type: "BATCH_ADD_ENTRIES",1515 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1516 });1517 expect(onImport).toHaveBeenCalled();1518 });15191520 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1521 parseImportSourceMock.mockResolvedValue(sampleImportResult());1522 sendMessageMock.mockImplementation(async (msg: unknown) => {1523 const type = (msg as { type?: string }).type;1524 if (type === "CREATE_VAULT") return { ok: true };1525 if (type === "BATCH_ADD_ENTRIES") {1526 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1527 }1528 return { ok: true };1529 });1530 const onImport = vi.fn(async () => undefined);1531 const container = mount(1532 <ImportView1533 mode="onboarding"1534 replacesExistingVault={true}1535 onBack={vi.fn()}1536 onImport={onImport}1537 />1538 );15391540 await selectImportFile(container);15411542 const createButton = findButton(container, "Create vault + import 1");1543 expect(createButton.disabled).toBe(true);1544 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1545 expect(container.textContent).toContain("Use at least 8 characters");15461547 const masterInput = container.querySelector(1548 'input[placeholder="new master password"]'1549 ) as HTMLInputElement;1550 const confirmInput = container.querySelector(1551 'input[placeholder="confirm master password"]'1552 ) as HTMLInputElement;1553 const revealButton = container.querySelector(1554 'button[aria-label="Show passwords"]'1555 ) as HTMLButtonElement;1556 expect(revealButton).toBeTruthy();1557 revealButton.click();1558 await flushView();1559 expect(masterInput.type).toBe("text");1560 expect(confirmInput.type).toBe("text");15611562 inputText(masterInput, "fresh-master-password");1563 inputText(confirmInput, "fresh-master-password");1564 await flushView();15651566 expect(createButton.disabled).toBe(false);1567 createButton.click();1568 await flushView();15691570 expect(sendMessageMock).toHaveBeenCalledWith({1571 type: "CREATE_VAULT",1572 payload: { password: "fresh-master-password" },1573 });1574 expect(sendMessageMock).toHaveBeenCalledWith({1575 type: "BATCH_ADD_ENTRIES",1576 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1577 });1578 expect(onImport).toHaveBeenCalled();1579 });15801581 it("Import encrypted backups reparse with the backup password before import", async () => {1582 parseImportSourceMock1583 .mockResolvedValueOnce(1584 sampleImportResult({1585 format: "peach-json",1586 label: "Peach encrypted backup",1587 entries: [],1588 totalItems: 0,1589 requiresPassword: true,1590 })1591 )1592 .mockResolvedValueOnce(1593 sampleImportResult({1594 format: "peach-json",1595 label: "Peach encrypted backup",1596 })1597 );1598 sendMessageMock.mockImplementation(async (msg: unknown) => {1599 const type = (msg as { type?: string }).type;1600 if (type === "GET_ENTRIES") return { ok: true, data: [] };1601 if (type === "BATCH_ADD_ENTRIES") {1602 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1603 }1604 return { ok: true };1605 });1606 const onImport = vi.fn(async () => undefined);1607 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1608 await flushView();1609 sendMessageMock.mockClear();16101611 await selectImportFile(container, '{"type":"peach-export"}');1612 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1613 inputText(password, "backup-passphrase");1614 pressEnter(password);1615 await flushView();1616 await flushView();16171618 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1619 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");16201621 findButton(container, "Import 1").click();1622 await flushView();16231624 expect(sendMessageMock).toHaveBeenCalledWith({1625 type: "BATCH_ADD_ENTRIES",1626 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1627 });1628 expect(onImport).toHaveBeenCalled();1629 });1630});