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 });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 ...overrides,163 };164 }165166 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {167 return {168 entriesCount: 1,169 breachMonitoring: true,170 lockOnClose: true,171 autoLockMinutes: 5,172 trashRetentionDays: 30,173 theme: "dark" as const,174 accentColor: "peach" as const,175 textSize: "medium" as const,176 reduceMotion: false,177 onBreachMonitoringChange: vi.fn(),178 onLockOnCloseChange: vi.fn(),179 onAutoLockMinutesChange: vi.fn(),180 onTrashRetentionChange: vi.fn(),181 onThemeChange: vi.fn(),182 onAccentChange: vi.fn(),183 onTextSizeChange: vi.fn(),184 onReduceMotionChange: vi.fn(),185 shortcuts: DEFAULT_BINDINGS,186 onShortcutsChange: vi.fn(),187 onBack: vi.fn(),188 onImport: vi.fn(),189 onExport: vi.fn(),190 onBreachCheckDone: vi.fn(),191 ...overrides,192 };193 }194195 async function selectImportFile(196 container: HTMLElement,197 contents = '{"export":"test"}'198 ) {199 200 201 202 const fileCard = findButton(container, "Choose file →");203 if (fileCard) {204 fileCard.click();205 await flushView();206 }207 const input = container.querySelector('input[type="file"]') as HTMLInputElement;208 const file = new File([contents], "import.json", { type: "application/json" });209 Object.defineProperty(input, "files", {210 configurable: true,211 value: [file],212 });213 input.dispatchEvent(new Event("change", { bubbles: true }));214 await flushView();215 await flushView();216 }217218 function findButton(container: HTMLElement, text: string): HTMLButtonElement {219 return Array.from(container.querySelectorAll("button")).find(220 (candidate) => candidate.textContent?.trim() === text221 ) as HTMLButtonElement;222 }223224 it("Lock sends UNLOCK with the entered passphrase", async () => {225 sendMessageMock.mockResolvedValue({ ok: true });226 const onUnlock = vi.fn(async () => undefined);227 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);228229 const password = container.querySelector("#unlock-password") as HTMLInputElement;230 inputText(password, "correct horse battery staple");231 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();232 await flushView();233234 expect(sendMessageMock).toHaveBeenCalledWith({235 type: "UNLOCK",236 payload: { password: "correct horse battery staple" },237 });238 expect(onUnlock).toHaveBeenCalled();239 });240241 it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {242 sendMessageMock.mockImplementation(async (msg: unknown) => {243 return (msg as { type: string }).type === "GET_SETTINGS"244 ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }245 : { ok: true };246 });247 const onUnlock = vi.fn(async () => undefined);248 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);249 await flushView();250251 inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");252 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();253 await flushView();254255 expect(sendMessageMock).toHaveBeenCalledWith({256 type: "UNLOCK_WITH_PIN",257 payload: { pin: "1234" },258 });259 expect(onUnlock).toHaveBeenCalled();260 expect(container.textContent).toContain("use master password");261 });262263 it("places the PIN method switch below the unlock button", async () => {264 sendMessageMock.mockImplementation(async (msg: unknown) => {265 return (msg as { type: string }).type === "GET_SETTINGS"266 ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }267 : { ok: true };268 });269 const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);270 await flushView();271272 const buttons = Array.from(container.querySelectorAll("button"));273 const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");274 const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");275 expect(pinIndex).toBeGreaterThan(unlockIndex);276 });277278 it("keeps standalone note creation in Notes mode", async () => {279 const chromeApi = globalThis.chrome as unknown as {280 tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };281 };282 chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };283284 const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);285 await flushView();286287 expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))288 .toEqual(["login", "card"]);289 expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();290 });291292 it("gives Notes mode the full note-formatting toolbar", async () => {293 const container = mount(294 <NotesEditor295 note={null}296 onBack={vi.fn()}297 onSaved={vi.fn()}298 onDelete={vi.fn()}299 />300 );301 await flushView();302303 expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();304 expect(container.querySelector('[title="Divider"]')).toBeTruthy();305 expect(container.querySelector('[title="Quote"]')).toBeTruthy();306 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);307 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);308 expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");309 });310311 it("saves formatting applied by the Notes toolbar before leaving", async () => {312 const savedNote = {313 id: "note-1",314 type: "note" as const,315 title: "Untitled",316 notes: "<b>formatted</b>",317 createdAt: 1,318 modifiedAt: 2,319 };320 sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });321 const originalExecCommand = document.execCommand;322 const execCommandMock = vi.fn(() => {323 const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");324 if (body) body.innerHTML = "<b>formatted</b>";325 return true;326 });327 Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });328329 try {330 const container = mount(331 <NotesEditor332 note={null}333 onBack={vi.fn()}334 onSaved={vi.fn()}335 onDelete={vi.fn()}336 />337 );338 await flushView();339 container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(340 new MouseEvent("mousedown", { bubbles: true, cancelable: true })341 );342 expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);343 container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();344 await flushView();345346 expect(sendMessageMock).toHaveBeenCalledWith({347 type: "SAVE_NOTE",348 payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },349 });350 } finally {351 Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });352 }353 });354355 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {356 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);357 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;358 const password = container.querySelector("#master-password") as HTMLInputElement;359 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;360361 inputText(password, "short");362 inputText(confirm, "short");363 await flushView();364 expect(createButton.disabled).toBe(true);365366 zxcvbnMock.mockImplementation(() => ({ score: 1 }));367 inputText(password, "weakweakweak");368 inputText(confirm, "weakweakweak");369 await flushView();370 expect(createButton.disabled).toBe(true);371 });372373 it("Lock create-vault header pins the wordmark left with back on the right", async () => {374 const container = mount(375 <LockView376 hasVault={false}377 ready={true}378 onUnlock={vi.fn()}379 onBackToOnboarding={() => {}}380 />381 );382 const header = container.querySelector(".peach-newvault-header") as HTMLElement;383 const wordmark = header.querySelector(".peach-wordmark-stack");384 const back = header.querySelector(".peach-newvault-back");385 expect(wordmark).toBeTruthy();386 expect(back).toBeTruthy();387 388 389 expect(header.firstElementChild).toBe(wordmark);390 expect(header.lastElementChild).toBe(back);391 await flushView();392 });393394 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {395 396 sendMessageMock.mockImplementation(397 async (msg: unknown) => {398 const m = msg as { type: string; payload?: { password?: string } };399 if (m.type === "CREATE_VAULT") return { ok: true };400 if (m.type === "GET_RECOVERY_WORDS") {401 402 return {403 ok: true,404 data: [405 "abandon", "ability", "able", "about", "above", "absent",406 "absorb", "abstract", "absurd", "abuse", "access", "accident",407 ],408 };409 }410 return { ok: true };411 }412 );413 zxcvbnMock.mockImplementation(() => ({ score: 3 }));414 const onUnlock = vi.fn(async () => undefined);415 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);416417 const password = container.querySelector("#master-password") as HTMLInputElement;418 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;419 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;420421 inputText(password, "correct horse battery staple");422 inputText(confirm, "correct horse battery staple");423 await flushView();424 expect(createButton.disabled).toBe(false);425426 pressEnter(confirm);427 await flushView();428429 expect(sendMessageMock).toHaveBeenCalledWith({430 type: "CREATE_VAULT",431 payload: { password: "correct horse battery staple" },432 });433 434 435 expect(onUnlock).not.toHaveBeenCalled();436 });437438 it("recovery display asks for three distinct randomized word positions and confirms", async () => {439 const words12 = [440 "abandon", "ability", "able", "about", "above", "absent",441 "absorb", "abstract", "absurd", "abuse", "access", "accident",442 ];443 sendMessageMock.mockImplementation(async (msg: unknown) => {444 const type = (msg as { type?: string }).type;445 if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };446 return { ok: true };447 });448449 vi.useFakeTimers();450 const onDone = vi.fn();451 try {452 const container = mount(<RecoveryWordDisplayView onDone={onDone} />);453 454 await vi.advanceTimersByTimeAsync(0);455456 const prompts = Array.from(457 container.querySelectorAll(".peach-recovery-confirm-num")458 ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));459 expect(prompts).toHaveLength(3);460 expect(new Set(prompts).size).toBe(3);461 for (const p of prompts) {462 expect(p).toBeGreaterThanOrEqual(1);463 expect(p).toBeLessThanOrEqual(12);464 }465466 467 const inputs = Array.from(468 container.querySelectorAll(".peach-recovery-confirm-input")469 ) as HTMLInputElement[];470 expect(inputs).toHaveLength(3);471 expect(inputs[0]!.disabled).toBe(true);472 await vi.advanceTimersByTimeAsync(5000);473 expect(inputs[0]!.disabled).toBe(false);474475 prompts.forEach((pos, i) => {476 inputText(inputs[i]!, words12[pos - 1]!);477 });478479 const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;480 expect(confirmBtn.disabled).toBe(false);481 confirmBtn.click();482 await vi.advanceTimersByTimeAsync(700); 483484 expect(onDone).toHaveBeenCalledTimes(1);485 } finally {486 vi.useRealTimers();487 }488 });489490 it("Recovery words flow submits the new master password and completes", async () => {491 sendMessageMock.mockImplementation(async (msg: unknown) => {492 const type = (msg as { type?: string }).type;493 if (type === "VALIDATE_RECOVERY_WORDS") {494 return { ok: true, data: { valid: true, invalid: [] } };495 }496 if (type === "RECOVER_WITH_WORDS") return { ok: true };497 return { ok: true };498 });499 const canvasContext = {500 clearRect: vi.fn(),501 fillText: vi.fn(),502 beginPath: vi.fn(),503 closePath: vi.fn(),504 arc: vi.fn(),505 fill: vi.fn(),506 stroke: vi.fn(),507 save: vi.fn(),508 restore: vi.fn(),509 translate: vi.fn(),510 rotate: vi.fn(),511 scale: vi.fn(),512 drawImage: vi.fn(),513 measureText: vi.fn(() => ({ width: 0 })),514 setTransform: vi.fn(),515 fillRect: vi.fn(),516 strokeRect: vi.fn(),517 } as unknown as CanvasRenderingContext2D;518 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);519520 const onRecoveryComplete = vi.fn(async () => undefined);521 const container = mount(522 <RecoveryWordEntryView523 onCancel={vi.fn()}524 onRecoveryComplete={onRecoveryComplete}525 />526 );527528 const recoveryWords = WORD_LIST.slice(0, 12);529 for (let i = 0; i < recoveryWords.length; i++) {530 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;531 inputText(input, recoveryWords[i]!);532 }533 await flushView();534535 findButton(container, "verify words").click();536 await flushView();537538 expect(container.textContent).toContain("new master password");539540 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;541 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;542 inputText(newPassword, "fresh-recovery-password");543 inputText(confirmPassword, "fresh-recovery-password");544 await flushView();545546 findButton(container, "recover vault").click();547 await flushView();548549 expect(sendMessageMock).toHaveBeenCalledWith({550 type: "RECOVER_WITH_WORDS",551 payload: {552 words: recoveryWords,553 newPassword: "fresh-recovery-password",554 },555 });556 expect(onRecoveryComplete).toHaveBeenCalled();557 });558559 it("Export menu surfaces both backup formats", async () => {560 const container = mount(<ExportView onBack={vi.fn()} />);561 await flushView();562563 expect(container.textContent).toContain("PeachScript");564 expect(container.textContent).toContain("CSV");565 });566567 it("Lock can open directly on backup restore from onboarding", async () => {568 const container = mount(() => (569 <LockView570 hasVault={false}571 ready={true}572 onUnlock={vi.fn()}573 initialRestore={true}574 />575 ));576 await flushView();577578 expect(container.textContent).toContain("PeachScript");579 expect(container.textContent).toContain("Codex");580 expect(container.textContent).toContain("CSV");581 expect(container.querySelector("#master-password")).toBeNull();582 });583584 it("Lock restore back returns to onboarding, not the create-vault screen", async () => {585 const onBackToOnboarding = vi.fn();586 const container = mount(() => (587 <LockView588 hasVault={false}589 ready={true}590 onUnlock={vi.fn()}591 initialRestore={true}592 onBackToOnboarding={onBackToOnboarding}593 />594 ));595 await flushView();596597 (container.querySelector(".peach-restore-back") as HTMLButtonElement).click();598 await flushView();599 expect(onBackToOnboarding).toHaveBeenCalledTimes(1);600 });601602 it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {603 const container = mount(() => (604 <LockView605 hasVault={false}606 ready={true}607 onUnlock={vi.fn()}608 initialRestore={true}609 initialRestoreSource="symbols"610 />611 ));612 await flushView();613614 expect(container.querySelector(".peach-restore-script")).toBeTruthy();615 expect(container.querySelector("#restore-backup-pass")).toBeTruthy();616 expect(container.querySelector(".peach-restore-sources")).toBeNull();617 });618619 it("Lock restore resumes on the picked CSV source, not the picker", async () => {620 const container = mount(() => (621 <LockView622 hasVault={false}623 ready={true}624 onUnlock={vi.fn()}625 initialRestore={true}626 initialRestoreSource="csv"627 />628 ));629 await flushView();630631 expect(container.querySelector("#restore-csv-pass")).toBeTruthy();632 expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();633 expect(container.querySelector(".peach-restore-sources")).toBeNull();634 });635636 it("Lock restore PeachScript form loads a dropped .peach file", async () => {637 const container = mount(() => (638 <LockView639 hasVault={false}640 ready={true}641 onUnlock={vi.fn()}642 initialRestore={true}643 initialRestoreSource="symbols"644 />645 ));646 await flushView();647648 const form = container.querySelector(".peach-restore-form") as HTMLElement;649 expect(form).toBeTruthy();650651 652 653 const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });654 const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {655 dataTransfer?: { files: File[] };656 };657 drop.dataTransfer = { files: [file] };658 form.dispatchEvent(drop);659 await flushView();660661 const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;662 expect(script.value).toBe("dropped-peach-backup");663 });664665 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {666 const device = {667 deviceId: "phone-1",668 deviceName: "June's Phone",669 deviceType: "android" as const,670 longTermPublicKeyHex: "a".repeat(64),671 signingPublicKeyHex: "b".repeat(64),672 pairedAt: 1,673 trustSource: "direct" as const,674 };675 sendMessageMock.mockImplementation(async (msg: unknown) => {676 const m = msg as { type: string };677 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };678 return { ok: true, data: null };679 });680681 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);682 for (let i = 0; i < 30; i++) await Promise.resolve();683684 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>685 /sync/i.test(b.textContent ?? ""),686 );687 openSync?.click();688 for (let i = 0; i < 30; i++) await Promise.resolve();689690 const remove = Array.from(container.querySelectorAll("button")).find(691 (b) => (b.textContent ?? "").trim() === "remove",692 );693 expect(remove).toBeTruthy();694695 696 697 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();698699 remove!.click();700 for (let i = 0; i < 10; i++) await Promise.resolve();701702 703 704 const note = container.querySelector(".peach-unpair-confirm-note");705 expect(note).toBeTruthy();706 const text = note!.textContent ?? "";707 expect(text).toContain("master password");708 expect(text).toContain("recovery words");709 expect(text).toContain("future changes");710 });711712 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {713 const device = {714 deviceId: "phone-1",715 deviceName: "June's Phone",716 deviceType: "android" as const,717 longTermPublicKeyHex: "a".repeat(64),718 signingPublicKeyHex: "b".repeat(64),719 pairedAt: 1,720 trustSource: "direct" as const,721 };722 let paired: typeof device[] = [device];723 sendMessageMock.mockImplementation(async (msg: any) => {724 if (msg?.type === "LIST_PAIRED_DEVICES") {725 return { ok: true, data: { devices: [...paired] } };726 }727 if (msg?.type === "UNPAIR_DEVICE") {728 729 730 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);731 return { ok: true };732 }733 if (msg?.type === "RENAME_PAIRED_DEVICE") {734 paired = paired.map((d) =>735 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,736 );737 return { ok: true };738 }739 return { ok: true, data: null };740 });741742 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);743 for (let i = 0; i < 30; i++) await Promise.resolve();744 Array.from(container.querySelectorAll("button"))745 .find((b) => /sync/i.test(b.textContent ?? ""))746 ?.click();747 for (let i = 0; i < 30; i++) await Promise.resolve();748749 750 expect(container.textContent).toContain("PAIRED DEVICES");751 expect(container.textContent).toContain("June's Phone");752 expect(container.textContent).toContain("Android");753 Array.from(container.querySelectorAll("button"))754 .find((b) => (b.textContent ?? "").trim() === "rename")!755 .click();756 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;757 renameInput.value = "Travel phone";758 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));759 Array.from(container.querySelectorAll("button"))760 .find((b) => (b.textContent ?? "").trim() === "save")!761 .click();762 for (let i = 0; i < 30; i++) await Promise.resolve();763 expect(sendMessageMock).toHaveBeenCalledWith({764 type: "RENAME_PAIRED_DEVICE",765 payload: { deviceId: "phone-1", name: "Travel phone" },766 });767 expect(container.textContent).toContain("Travel phone");768 const syncNowButton = container.querySelector<HTMLButtonElement>(769 'button[aria-label="Sync this vault now"]',770 )!;771 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));772 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;773 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;774 expect(775 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,776 ).toBeTruthy();777 expect(778 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,779 ).toBeTruthy();780781 Array.from(container.querySelectorAll("button"))782 .find((b) => (b.textContent ?? "").trim() === "remove")!783 .click();784 for (let i = 0; i < 10; i++) await Promise.resolve();785 const confirm = Array.from(786 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),787 ).find((b) => (b.textContent ?? "").trim() === "remove");788 confirm!.click();789 for (let i = 0; i < 30; i++) await Promise.resolve();790791 expect(sendMessageMock).toHaveBeenCalledWith({792 type: "UNPAIR_DEVICE",793 payload: { deviceId: "phone-1" },794 });795 796 797 798 expect(container.textContent).not.toContain("June's Phone");799 expect(container.textContent).not.toContain("PAIRED DEVICES");800 });801802 it("Settings keeps connection and recovery details collapsed until requested", async () => {803 sendMessageMock.mockImplementation(async (msg: any) => {804 if (msg?.type === "LIST_PAIRED_DEVICES") {805 return { ok: true, data: { devices: [] } };806 }807 return { ok: true, data: null };808 });809810 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);811 for (let i = 0; i < 30; i++) await Promise.resolve();812 Array.from(container.querySelectorAll("button"))813 .find((button) => /sync/i.test(button.textContent ?? ""))814 ?.click();815 for (let i = 0; i < 20; i++) await Promise.resolve();816817 expect(container.textContent).toContain("SYNC STATUS");818 expect(container.textContent).toContain("ADD A DEVICE");819 expect(container.textContent).not.toContain("Public relay");820 expect(container.textContent).not.toContain("Rotate sync identity");821822 const connection = Array.from(container.querySelectorAll("button"))823 .find((button) => button.textContent?.includes("CONNECTION"));824 connection?.click();825 await Promise.resolve();826 expect(container.textContent).toContain("Public relay");827828 const recovery = Array.from(container.querySelectorAll("button"))829 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));830 recovery?.click();831 await Promise.resolve();832 expect(container.textContent).toContain("Rotate sync identity");833 });834835 it("Settings requires current, new, and confirm passwords before enabling save", async () => {836 sendMessageMock.mockResolvedValue({ ok: true, data: null });837 const container = mount(838 <SettingsView839 entriesCount={1}840 breachMonitoring={true}841 lockOnClose={true}842 autoLockMinutes={5}843 trashRetentionDays={30}844 theme="dark"845 accentColor="peach"846 textSize="medium"847 reduceMotion={false}848 onBreachMonitoringChange={vi.fn()}849 onLockOnCloseChange={vi.fn()}850 onAutoLockMinutesChange={vi.fn()}851 onTrashRetentionChange={vi.fn()}852 onThemeChange={vi.fn()}853 onAccentChange={vi.fn()}854 onTextSizeChange={vi.fn()}855 onReduceMotionChange={vi.fn()}856 shortcuts={DEFAULT_BINDINGS}857 onShortcutsChange={vi.fn()}858 onBack={vi.fn()}859 onImport={vi.fn()}860 onExport={vi.fn()}861 onBreachCheckDone={vi.fn()}862 />863 );864 await flushView();865866 expect(container.querySelectorAll(".stk-card").length).toBe(7);867 868 869 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");870871 const changeButton = Array.from(container.querySelectorAll("button")).find(872 (candidate) => candidate.textContent?.trim() === "change"873 ) as HTMLButtonElement;874 changeButton.click();875 await flushView();876877 const saveButton = Array.from(container.querySelectorAll("button")).find(878 (candidate) => candidate.textContent?.trim() === "save new password"879 ) as HTMLButtonElement;880 expect(saveButton.disabled).toBe(true);881882 const inputs = container.querySelectorAll('input[type="password"]');883 inputText(inputs[0] as HTMLInputElement, "current-pass");884 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");885 await flushView();886 expect(saveButton.disabled).toBe(true);887888 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");889 await flushView();890 expect(saveButton.disabled).toBe(false);891 });892893 it("Settings feedback highlights the selected category and hides the form after submit", async () => {894 sendMessageMock.mockResolvedValue({ ok: true, data: null });895 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({896 ok: true,897 json: async () => ({ success: true }),898 }));899 vi.stubGlobal("fetch", fetchMock);900901 const container = mount(902 <SettingsView903 entriesCount={1}904 breachMonitoring={true}905 lockOnClose={true}906 autoLockMinutes={5}907 trashRetentionDays={30}908 theme="dark"909 accentColor="peach"910 textSize="medium"911 reduceMotion={false}912 initialSection="feedback"913 onBreachMonitoringChange={vi.fn()}914 onLockOnCloseChange={vi.fn()}915 onAutoLockMinutesChange={vi.fn()}916 onTrashRetentionChange={vi.fn()}917 onThemeChange={vi.fn()}918 onAccentChange={vi.fn()}919 onTextSizeChange={vi.fn()}920 onReduceMotionChange={vi.fn()}921 shortcuts={DEFAULT_BINDINGS}922 onShortcutsChange={vi.fn()}923 onBack={vi.fn()}924 onImport={vi.fn()}925 onExport={vi.fn()}926 onBreachCheckDone={vi.fn()}927 />928 );929 await flushView();930931 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;932 expect(feedbackCard.classList.contains("is-active")).toBe(true);933934 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];935 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;936 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;937 expect(bugButton.getAttribute("aria-pressed")).toBe("true");938 expect(featureButton.getAttribute("aria-pressed")).toBe("false");939940 featureButton.click();941 await flushView();942943 expect(bugButton.getAttribute("aria-pressed")).toBe("false");944 expect(featureButton.getAttribute("aria-pressed")).toBe("true");945 expect(featureButton.classList.contains("is-active")).toBe(true);946947 inputText(948 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,949 '<script>alert("xss")</script> Please make this easier to see & safer.'950 );951 const sendButton = Array.from(container.querySelectorAll("button")).find(952 (candidate) => candidate.textContent?.trim() === "Send"953 ) as HTMLButtonElement;954 sendButton.click();955956 await vi.waitFor(() => {957 expect(container.textContent).toContain("Thank you");958 });959960 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));961 expect(requestBody.subject).toContain("Feature request");962 expect(requestBody.message).toBe(963 "<script>alert("xss")</script> Please make this easier to see & safer."964 );965 expect(requestBody.message).not.toContain("<script>");966 expect(container.textContent).toContain("Someone will process it soon");967 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();968 expect(container.querySelector(".peach-feedback-submit")).toBeNull();969 });970971 it("Settings keeps license activation on the full-screen celebration path", async () => {972 const onLicenseChange = vi.fn(async () => undefined);973 sendMessageMock.mockImplementation(async (msg: unknown) => {974 const type = (msg as { type?: string }).type;975 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };976 if (type === "ACTIVATE_LICENSE") {977 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };978 }979 if (type === "GET_ENTRIES") return { ok: true, data: [] };980 return { ok: true, data: null };981 });982983 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);984 await flushView();985986 inputText(987 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,988 "PEACH-TEST-KEY",989 );990 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;991 apply.click();992993 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));994 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);995 expect(container.textContent).not.toContain("thank you ✦");996 });997998 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {999 sendMessageMock.mockImplementation(async (msg: unknown) => {1000 const type = (msg as { type?: string }).type;1001 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1002 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1003 if (type === "GET_ENTRIES") return { ok: true, data: [] };1004 return { ok: true, data: null };1005 });10061007 const onLicenseChangeFromStar = vi.fn();1008 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1009 await flushView();1010 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1011 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1012 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();10131014 const onLicenseChangeFromPeach = vi.fn();1015 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1016 await flushView();1017 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1018 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1019 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1020 });10211022 it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1023 sendMessageMock.mockResolvedValue({ ok: true, data: null });1024 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1025 ok: true,1026 json: async () => ({ success: true }),1027 }));1028 vi.stubGlobal("fetch", fetchMock);1029 1030 1031 1032 vi.stubGlobal("browser", {1033 permissions: {1034 getAll: vi.fn(async () => ({ data_collection: [] })),1035 request: vi.fn(async () => false),1036 },1037 });10381039 const container = mount(1040 <SettingsView1041 entriesCount={1}1042 breachMonitoring={true}1043 lockOnClose={true}1044 autoLockMinutes={5}1045 trashRetentionDays={30}1046 theme="dark"1047 accentColor="peach"1048 textSize="medium"1049 reduceMotion={false}1050 initialSection="feedback"1051 onBreachMonitoringChange={vi.fn()}1052 onLockOnCloseChange={vi.fn()}1053 onAutoLockMinutesChange={vi.fn()}1054 onTrashRetentionChange={vi.fn()}1055 onThemeChange={vi.fn()}1056 onAccentChange={vi.fn()}1057 onTextSizeChange={vi.fn()}1058 onReduceMotionChange={vi.fn()}1059 shortcuts={DEFAULT_BINDINGS}1060 onShortcutsChange={vi.fn()}1061 onBack={vi.fn()}1062 onImport={vi.fn()}1063 onExport={vi.fn()}1064 onBreachCheckDone={vi.fn()}1065 />1066 );1067 await flushView();10681069 inputText(1070 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1071 "Please improve the feedback flow."1072 );1073 const sendButton = Array.from(container.querySelectorAll("button")).find(1074 (candidate) => candidate.textContent?.trim() === "Send"1075 ) as HTMLButtonElement;1076 sendButton.click();10771078 await vi.waitFor(() => {1079 expect(fetchMock).toHaveBeenCalledTimes(1);1080 });1081 await vi.waitFor(() => {1082 expect(container.textContent).toContain("Thank you");1083 });1084 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1085 });10861087 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1088 sendMessageMock.mockImplementation(async (msg: unknown) => {1089 const m = msg as { type: string };1090 if (m.type === "GET_RECOVERY_WORDS") {1091 return {1092 ok: true,1093 data: [1094 "abandon", "ability", "able", "about", "above", "absent",1095 "absorb", "abstract", "absurd", "abuse", "access", "accident",1096 ],1097 };1098 }1099 return { ok: true, data: null };1100 });1101 const onAutoLockMinutesChange = vi.fn();1102 const onThemeChange = vi.fn();1103 const container = mount(1104 <SettingsView1105 entriesCount={1}1106 breachMonitoring={true}1107 lockOnClose={true}1108 autoLockMinutes={5}1109 trashRetentionDays={30}1110 theme="dark"1111 accentColor="peach"1112 textSize="medium"1113 reduceMotion={false}1114 onBreachMonitoringChange={vi.fn()}1115 onLockOnCloseChange={vi.fn()}1116 onAutoLockMinutesChange={onAutoLockMinutesChange}1117 onTrashRetentionChange={vi.fn()}1118 onThemeChange={onThemeChange}1119 onAccentChange={vi.fn()}1120 onTextSizeChange={vi.fn()}1121 onReduceMotionChange={vi.fn()}1122 shortcuts={DEFAULT_BINDINGS}1123 onShortcutsChange={vi.fn()}1124 onBack={vi.fn()}1125 onImport={vi.fn()}1126 onExport={vi.fn()}1127 onBreachCheckDone={vi.fn()}1128 />1129 );1130 await flushView();1131 sendMessageMock.mockClear();11321133 1134 const selects = container.querySelectorAll("select");1135 changeSelect(selects[0] as HTMLSelectElement, "15");1136 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);11371138 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1139 appearanceCard.click();1140 await flushView();1141 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1142 const themeButton = Array.from(container.querySelectorAll("button")).find(1143 (candidate) => candidate.textContent?.trim() === "Switch to light"1144 ) as HTMLButtonElement;1145 themeButton.click();1146 expect(onThemeChange).toHaveBeenCalledWith("light");11471148 1149 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1150 protectionCard.click();1151 await flushView();11521153 const changeButton = Array.from(container.querySelectorAll("button")).find(1154 (candidate) => candidate.textContent?.trim() === "change"1155 ) as HTMLButtonElement;1156 changeButton.click();1157 await flushView();11581159 const inputs = container.querySelectorAll('input[type="password"]');1160 inputText(inputs[0] as HTMLInputElement, "current-pass");1161 inputText(inputs[1] as HTMLInputElement, "new-password-123");1162 inputText(inputs[2] as HTMLInputElement, "new-password-123");1163 await flushView();11641165 const saveButton = Array.from(container.querySelectorAll("button")).find(1166 (candidate) => candidate.textContent?.trim() === "save new password"1167 ) as HTMLButtonElement;1168 saveButton.click();1169 await flushView();11701171 expect(sendMessageMock).toHaveBeenCalledWith({1172 type: "CHANGE_PASSWORD",1173 payload: {1174 currentPassword: "current-pass",1175 newPassword: "new-password-123",1176 },1177 });11781179 1180 expect(container.textContent).toContain("save your recovery words");1181 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1182 });11831184 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1185 sendMessageMock.mockResolvedValue({ ok: true });1186 const container = mount(1187 <EntryView1188 entry={sampleEntry()}1189 onDelete={vi.fn()}1190 onFieldSaved={vi.fn(async () => undefined)}1191 />1192 );11931194 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1195 passwordButton.click();1196 await flushView();11971198 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1199 type: "COPY",1200 payload: { text: "secret123", label: "password", alreadyWritten: false },1201 });1202 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1203 type: "FIRE_CANARY_PING",1204 payload: {1205 webhookUrl: "https://example.com/hook",1206 entryTitle: "GitHub",1207 trigger: "password_copied",1208 },1209 });12101211 sendMessageMock.mockClear();1212 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1213 usernameButton.click();1214 await flushView();12151216 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1217 type: "COPY",1218 payload: { text: "june", label: "username", alreadyWritten: false },1219 });1220 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1221 type: "FIRE_CANARY_PING",1222 payload: {1223 webhookUrl: "https://example.com/hook",1224 entryTitle: "GitHub",1225 trigger: "username_copied",1226 },1227 });1228 });12291230 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1231 const container = mount(1232 <EntryView1233 entry={sampleEntry({1234 type: "passkey",1235 username: undefined,1236 password: undefined,1237 totp: undefined,1238 canary: undefined,1239 passkey: {1240 rpId: "vercel.com",1241 rpName: "Vercel",1242 credentialId: "cred-1",1243 publicKeyCose: "cose",1244 userHandle: "handle",1245 userName: "june",1246 userDisplayName: "June",1247 algorithm: -7,1248 signCount: 0,1249 aaguid: "",1250 backupEligible: false,1251 backupState: false,1252 createdAt: 1,1253 },1254 })}1255 onDelete={vi.fn()}1256 onFieldSaved={vi.fn(async () => undefined)}1257 />1258 );12591260 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1261 (button) => button.textContent?.includes("Advanced")1262 ) as HTMLButtonElement | undefined;1263 expect(advanced).toBeDefined();1264 expect(advanced?.textContent).toContain("+ Advanced");12651266 advanced!.click();1267 await flushView();12681269 expect(container.textContent).toContain("passkey · vercel.com");1270 });12711272 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1273 sendMessageMock.mockResolvedValue({ ok: true });1274 const onFieldSaved = vi.fn(async () => undefined);1275 const container = mount(1276 <EntryView1277 entry={sampleEntry()}1278 onDelete={vi.fn()}1279 onFieldSaved={onFieldSaved}1280 />1281 );12821283 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1284 passwordValue.click();1285 await flushView();12861287 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1288 inputText(passwordInput, "");1289 await flushView();12901291 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1292 saveButton.click();1293 await flushView();12941295 expect(sendMessageMock).not.toHaveBeenCalledWith(1296 expect.objectContaining({ type: "UPDATE_ENTRY" })1297 );1298 expect(onFieldSaved).not.toHaveBeenCalled();12991300 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1301 totpMain.click();13021303 await vi.waitFor(() => {1304 expect(sendMessageMock).toHaveBeenLastCalledWith({1305 type: "COPY",1306 payload: { text: "123456", label: "totp", alreadyWritten: false },1307 });1308 }, { timeout: 500 });1309 });13101311 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1312 sendMessageMock.mockResolvedValue({ ok: true });1313 const container = mount(1314 <EntryView1315 entry={sampleEntry()}1316 onDelete={vi.fn()}1317 onFieldSaved={vi.fn(async () => undefined)}1318 />1319 );13201321 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1322 usernameValue.click();1323 await flushView();13241325 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1326 usernameInput.blur();1327 await flushView();13281329 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1330 expect(sendMessageMock).not.toHaveBeenCalledWith(1331 expect.objectContaining({ type: "UPDATE_ENTRY" })1332 );1333 });13341335 it("Entry keeps the full used-email list available while editing an email", async () => {1336 const container = mount(1337 <EntryView1338 entry={sampleEntry({ email: "[email protected]" })}1339 entries={[1340 sampleEntry({ email: "[email protected]" }),1341 sampleEntry({ id: "entry-2", email: "[email protected]" }),1342 sampleEntry({ id: "entry-3", username: "duckuccino" }),1343 ]}1344 onDelete={vi.fn()}1345 onFieldSaved={vi.fn(async () => undefined)}1346 />1347 );13481349 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1350 (card) => card.querySelector(".peach-label")?.textContent === "email"1351 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1352 emailValue.click();1353 await flushView();13541355 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1356 .map((element) => element.textContent);1357 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1358 });13591360 it("Entry lets TOTP secrets be edited inline", async () => {1361 sendMessageMock.mockResolvedValue({ ok: true });1362 const onFieldSaved = vi.fn(async () => undefined);1363 const container = mount(1364 <EntryView1365 entry={sampleEntry()}1366 onDelete={vi.fn()}1367 onFieldSaved={onFieldSaved}1368 />1369 );13701371 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1372 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1373 await flushView();13741375 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1376 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1377 await flushView();13781379 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1380 saveButton.click();1381 await flushView();13821383 expect(sendMessageMock).toHaveBeenCalledWith({1384 type: "UPDATE_ENTRY",1385 payload: {1386 id: "entry-1",1387 patch: { totp: "NB2W45DFOIZA====" },1388 },1389 });1390 expect(onFieldSaved).toHaveBeenCalled();1391 });13921393 it("Vault seeds and focuses search when a search seed is provided", async () => {1394 const container = mount(1395 <VaultView1396 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1397 sortOrder="url-first"1398 searchSeed="g"1399 searchSeedNonce={1}1400 onSelectEntry={vi.fn()}1401 onSortOrderChange={vi.fn()}1402 onAdd={vi.fn()}1403 onClose={vi.fn()}1404 />1405 );14061407 await flushView();14081409 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1410 expect(search.value).toBe("g");1411 expect(document.activeElement).toBe(search);1412 });14131414 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1415 parseImportSourceMock.mockResolvedValue(1416 sampleImportResult({1417 entries: [1418 {1419 type: "login",1420 title: "Imported site",1421 username: "imported-user",1422 password: "imported-pass",1423 url: "https://imported.test",1424 },1425 ],1426 })1427 );1428 sendMessageMock.mockImplementation(async (msg: unknown) => {1429 const type = (msg as { type?: string }).type;1430 if (type === "GET_ENTRIES") {1431 return {1432 ok: true,1433 data: [1434 sampleEntry({1435 id: "existing-1",1436 title: "Imported site",1437 username: "imported-user",1438 url: "https://imported.test",1439 }),1440 ],1441 };1442 }1443 if (type === "BATCH_ADD_ENTRIES") {1444 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1445 }1446 return { ok: true };1447 });1448 const onImport = vi.fn(async () => undefined);1449 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1450 await flushView();1451 sendMessageMock.mockClear();14521453 await selectImportFile(container);1454 findButton(container, "Import 1").click();1455 await flushView();14561457 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1458 expect(sendMessageMock).toHaveBeenCalledWith({1459 type: "BATCH_ADD_ENTRIES",1460 payload: {1461 entries: [1462 expect.objectContaining({1463 title: "Imported site",1464 username: "imported-user",1465 password: "imported-pass",1466 url: "https://imported.test",1467 }),1468 ],1469 },1470 });1471 expect(onImport).toHaveBeenCalled();1472 expect(container.textContent).toContain("1 entry appears to be duplicates");1473 });14741475 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1476 parseImportSourceMock.mockResolvedValue(sampleImportResult());1477 sendMessageMock.mockImplementation(async (msg: unknown) => {1478 const type = (msg as { type?: string }).type;1479 if (type === "GET_ENTRIES") {1480 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1481 }1482 if (type === "CLEAR_VAULT_ENTRIES") {1483 return { ok: true };1484 }1485 if (type === "BATCH_ADD_ENTRIES") {1486 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1487 }1488 return { ok: true };1489 });1490 const onImport = vi.fn(async () => undefined);1491 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1492 await flushView();1493 sendMessageMock.mockClear();14941495 await selectImportFile(container);1496 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1497 await flushView();14981499 findButton(container, "Replace vault with 1").click();1500 await flushView();15011502 expect(sendMessageMock).not.toHaveBeenCalledWith(1503 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1504 );1505 expect(container.textContent).toContain("Replace forever? This cannot be undone.");15061507 findButton(container, "Yes, replace forever").click();1508 await flushView();15091510 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1511 expect(sendMessageMock).toHaveBeenCalledWith({1512 type: "BATCH_ADD_ENTRIES",1513 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1514 });1515 expect(onImport).toHaveBeenCalled();1516 });15171518 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1519 parseImportSourceMock.mockResolvedValue(sampleImportResult());1520 sendMessageMock.mockImplementation(async (msg: unknown) => {1521 const type = (msg as { type?: string }).type;1522 if (type === "CREATE_VAULT") return { ok: true };1523 if (type === "BATCH_ADD_ENTRIES") {1524 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1525 }1526 return { ok: true };1527 });1528 const onImport = vi.fn(async () => undefined);1529 const container = mount(1530 <ImportView1531 mode="onboarding"1532 replacesExistingVault={true}1533 onBack={vi.fn()}1534 onImport={onImport}1535 />1536 );15371538 await selectImportFile(container);15391540 const createButton = findButton(container, "Create vault + import 1");1541 expect(createButton.disabled).toBe(true);1542 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1543 expect(container.textContent).toContain("Use at least 8 characters");15441545 const masterInput = container.querySelector(1546 'input[placeholder="new master password"]'1547 ) as HTMLInputElement;1548 const confirmInput = container.querySelector(1549 'input[placeholder="confirm master password"]'1550 ) as HTMLInputElement;1551 const revealButton = container.querySelector(1552 'button[aria-label="Show passwords"]'1553 ) as HTMLButtonElement;1554 expect(revealButton).toBeTruthy();1555 revealButton.click();1556 await flushView();1557 expect(masterInput.type).toBe("text");1558 expect(confirmInput.type).toBe("text");15591560 inputText(masterInput, "fresh-master-password");1561 inputText(confirmInput, "fresh-master-password");1562 await flushView();15631564 expect(createButton.disabled).toBe(false);1565 createButton.click();1566 await flushView();15671568 expect(sendMessageMock).toHaveBeenCalledWith({1569 type: "CREATE_VAULT",1570 payload: { password: "fresh-master-password" },1571 });1572 expect(sendMessageMock).toHaveBeenCalledWith({1573 type: "BATCH_ADD_ENTRIES",1574 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1575 });1576 expect(onImport).toHaveBeenCalled();1577 });15781579});