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 const type = (msg as { type: string }).type;244 if (type === "GET_PIN_STATUS") {245 return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };246 }247 return type === "GET_SETTINGS"248 ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }249 : { ok: true };250 });251 const onUnlock = vi.fn(async () => undefined);252 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);253 await flushView();254255 inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");256 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();257 await flushView();258259 expect(sendMessageMock).toHaveBeenCalledWith({260 type: "UNLOCK_WITH_PIN",261 payload: { pin: "1234" },262 });263 expect(onUnlock).toHaveBeenCalled();264 expect(container.textContent).toContain("use master password");265 });266267 it("places the PIN method switch below the unlock button", async () => {268 sendMessageMock.mockImplementation(async (msg: unknown) => {269 const type = (msg as { type: string }).type;270 if (type === "GET_PIN_STATUS") {271 return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };272 }273 return type === "GET_SETTINGS"274 ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }275 : { ok: true };276 });277 const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);278 await flushView();279280 const buttons = Array.from(container.querySelectorAll("button"));281 const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");282 const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");283 expect(pinIndex).toBeGreaterThan(unlockIndex);284 });285286 it("keeps standalone note creation in Notes mode", async () => {287 const chromeApi = globalThis.chrome as unknown as {288 tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };289 };290 chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };291292 const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);293 await flushView();294295 expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))296 .toEqual(["login", "card"]);297 expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();298 });299300 it("gives Notes mode the full note-formatting toolbar", async () => {301 const container = mount(302 <NotesEditor303 note={null}304 onBack={vi.fn()}305 onSaved={vi.fn()}306 onDelete={vi.fn()}307 />308 );309 await flushView();310311 expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();312 expect(container.querySelector('[title="Divider"]')).toBeTruthy();313 expect(container.querySelector('[title="Quote"]')).toBeTruthy();314 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);315 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);316 expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");317 });318319 it("saves formatting applied by the Notes toolbar before leaving", async () => {320 const savedNote = {321 id: "note-1",322 type: "note" as const,323 title: "Untitled",324 notes: "<b>formatted</b>",325 createdAt: 1,326 modifiedAt: 2,327 };328 sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });329 const originalExecCommand = document.execCommand;330 const execCommandMock = vi.fn(() => {331 const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");332 if (body) body.innerHTML = "<b>formatted</b>";333 return true;334 });335 Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });336337 try {338 const container = mount(339 <NotesEditor340 note={null}341 onBack={vi.fn()}342 onSaved={vi.fn()}343 onDelete={vi.fn()}344 />345 );346 await flushView();347 container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(348 new MouseEvent("mousedown", { bubbles: true, cancelable: true })349 );350 expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);351 container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();352 await flushView();353354 expect(sendMessageMock).toHaveBeenCalledWith({355 type: "SAVE_NOTE",356 payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },357 });358 } finally {359 Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });360 }361 });362363 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {364 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);365 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;366 const password = container.querySelector("#master-password") as HTMLInputElement;367 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;368369 inputText(password, "short");370 inputText(confirm, "short");371 await flushView();372 expect(createButton.disabled).toBe(true);373374 zxcvbnMock.mockImplementation(() => ({ score: 1 }));375 inputText(password, "weakweakweak");376 inputText(confirm, "weakweakweak");377 await flushView();378 expect(createButton.disabled).toBe(true);379 });380381 it("Lock create-vault header pins the wordmark left with back on the right", async () => {382 const container = mount(383 <LockView384 hasVault={false}385 ready={true}386 onUnlock={vi.fn()}387 onBackToOnboarding={() => {}}388 />389 );390 const header = container.querySelector(".peach-newvault-header") as HTMLElement;391 const wordmark = header.querySelector(".peach-wordmark-stack");392 const back = header.querySelector(".peach-newvault-back");393 expect(wordmark).toBeTruthy();394 expect(back).toBeTruthy();395 396 397 expect(header.firstElementChild).toBe(wordmark);398 expect(header.lastElementChild).toBe(back);399 await flushView();400 });401402 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {403 404 sendMessageMock.mockImplementation(405 async (msg: unknown) => {406 const m = msg as { type: string; payload?: { password?: string } };407 if (m.type === "CREATE_VAULT") return { ok: true };408 if (m.type === "GET_RECOVERY_WORDS") {409 410 return {411 ok: true,412 data: [413 "abandon", "ability", "able", "about", "above", "absent",414 "absorb", "abstract", "absurd", "abuse", "access", "accident",415 ],416 };417 }418 return { ok: true };419 }420 );421 zxcvbnMock.mockImplementation(() => ({ score: 3 }));422 const onUnlock = vi.fn(async () => undefined);423 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);424425 const password = container.querySelector("#master-password") as HTMLInputElement;426 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;427 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;428429 inputText(password, "correct horse battery staple");430 inputText(confirm, "correct horse battery staple");431 await flushView();432 expect(createButton.disabled).toBe(false);433434 pressEnter(confirm);435 await flushView();436437 expect(sendMessageMock).toHaveBeenCalledWith({438 type: "CREATE_VAULT",439 payload: { password: "correct horse battery staple" },440 });441 442 443 expect(onUnlock).not.toHaveBeenCalled();444 });445446 it("recovery display asks for three distinct randomized word positions and confirms", async () => {447 const words12 = [448 "abandon", "ability", "able", "about", "above", "absent",449 "absorb", "abstract", "absurd", "abuse", "access", "accident",450 ];451 sendMessageMock.mockImplementation(async (msg: unknown) => {452 const type = (msg as { type?: string }).type;453 if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };454 return { ok: true };455 });456457 vi.useFakeTimers();458 const onDone = vi.fn();459 try {460 const container = mount(<RecoveryWordDisplayView onDone={onDone} />);461 462 await vi.advanceTimersByTimeAsync(0);463464 const prompts = Array.from(465 container.querySelectorAll(".peach-recovery-confirm-num")466 ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));467 expect(prompts).toHaveLength(3);468 expect(new Set(prompts).size).toBe(3);469 for (const p of prompts) {470 expect(p).toBeGreaterThanOrEqual(1);471 expect(p).toBeLessThanOrEqual(12);472 }473474 475 const inputs = Array.from(476 container.querySelectorAll(".peach-recovery-confirm-input")477 ) as HTMLInputElement[];478 expect(inputs).toHaveLength(3);479 expect(inputs[0]!.disabled).toBe(true);480 await vi.advanceTimersByTimeAsync(5000);481 expect(inputs[0]!.disabled).toBe(false);482483 prompts.forEach((pos, i) => {484 inputText(inputs[i]!, words12[pos - 1]!);485 });486487 const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;488 expect(confirmBtn.disabled).toBe(false);489 confirmBtn.click();490 await vi.advanceTimersByTimeAsync(700); 491492 expect(onDone).toHaveBeenCalledTimes(1);493 } finally {494 vi.useRealTimers();495 }496 });497498 it("Recovery words flow submits the new master password and completes", async () => {499 sendMessageMock.mockImplementation(async (msg: unknown) => {500 const type = (msg as { type?: string }).type;501 if (type === "VALIDATE_RECOVERY_WORDS") {502 return { ok: true, data: { valid: true, invalid: [] } };503 }504 if (type === "RECOVER_WITH_WORDS") return { ok: true };505 return { ok: true };506 });507 const canvasContext = {508 clearRect: vi.fn(),509 fillText: vi.fn(),510 beginPath: vi.fn(),511 closePath: vi.fn(),512 arc: vi.fn(),513 fill: vi.fn(),514 stroke: vi.fn(),515 save: vi.fn(),516 restore: vi.fn(),517 translate: vi.fn(),518 rotate: vi.fn(),519 scale: vi.fn(),520 drawImage: vi.fn(),521 measureText: vi.fn(() => ({ width: 0 })),522 setTransform: vi.fn(),523 fillRect: vi.fn(),524 strokeRect: vi.fn(),525 } as unknown as CanvasRenderingContext2D;526 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);527528 const onRecoveryComplete = vi.fn(async () => undefined);529 const container = mount(530 <RecoveryWordEntryView531 onCancel={vi.fn()}532 onRecoveryComplete={onRecoveryComplete}533 />534 );535536 const recoveryWords = WORD_LIST.slice(0, 12);537 for (let i = 0; i < recoveryWords.length; i++) {538 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;539 inputText(input, recoveryWords[i]!);540 }541 await flushView();542543 findButton(container, "verify words").click();544 await flushView();545546 expect(container.textContent).toContain("new master password");547548 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;549 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;550 inputText(newPassword, "fresh-recovery-password");551 inputText(confirmPassword, "fresh-recovery-password");552 await flushView();553554 findButton(container, "recover vault").click();555 await flushView();556557 expect(sendMessageMock).toHaveBeenCalledWith({558 type: "RECOVER_WITH_WORDS",559 payload: {560 words: recoveryWords,561 newPassword: "fresh-recovery-password",562 },563 });564 expect(onRecoveryComplete).toHaveBeenCalled();565 });566567 it("Export menu surfaces both backup formats", async () => {568 const container = mount(<ExportView onBack={vi.fn()} />);569 await flushView();570571 expect(container.textContent).toContain("PeachScript");572 expect(container.textContent).toContain("CSV");573 });574575 it("Lock can open directly on backup restore from onboarding", async () => {576 const container = mount(() => (577 <LockView578 hasVault={false}579 ready={true}580 onUnlock={vi.fn()}581 initialRestore={true}582 />583 ));584 await flushView();585586 expect(container.textContent).toContain("PeachScript");587 expect(container.textContent).toContain("Codex");588 expect(container.textContent).toContain("CSV");589 expect(container.querySelector("#master-password")).toBeNull();590 });591592 it("Lock restore back returns to onboarding, not the create-vault screen", async () => {593 const onBackToOnboarding = vi.fn();594 const container = mount(() => (595 <LockView596 hasVault={false}597 ready={true}598 onUnlock={vi.fn()}599 initialRestore={true}600 onBackToOnboarding={onBackToOnboarding}601 />602 ));603 await flushView();604605 (container.querySelector(".peach-restore-back") as HTMLButtonElement).click();606 await flushView();607 expect(onBackToOnboarding).toHaveBeenCalledTimes(1);608 });609610 it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {611 const container = mount(() => (612 <LockView613 hasVault={false}614 ready={true}615 onUnlock={vi.fn()}616 initialRestore={true}617 initialRestoreSource="symbols"618 />619 ));620 await flushView();621622 expect(container.querySelector(".peach-restore-script")).toBeTruthy();623 expect(container.querySelector("#restore-backup-pass")).toBeTruthy();624 expect(container.querySelector(".peach-restore-sources")).toBeNull();625 });626627 it("Lock restore resumes on the picked CSV source, not the picker", async () => {628 const container = mount(() => (629 <LockView630 hasVault={false}631 ready={true}632 onUnlock={vi.fn()}633 initialRestore={true}634 initialRestoreSource="csv"635 />636 ));637 await flushView();638639 expect(container.querySelector("#restore-csv-pass")).toBeTruthy();640 expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();641 expect(container.querySelector(".peach-restore-sources")).toBeNull();642 });643644 it("Lock restore PeachScript form loads a dropped .peach file", async () => {645 const container = mount(() => (646 <LockView647 hasVault={false}648 ready={true}649 onUnlock={vi.fn()}650 initialRestore={true}651 initialRestoreSource="symbols"652 />653 ));654 await flushView();655656 const form = container.querySelector(".peach-restore-form") as HTMLElement;657 expect(form).toBeTruthy();658659 660 661 const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });662 const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {663 dataTransfer?: { files: File[] };664 };665 drop.dataTransfer = { files: [file] };666 form.dispatchEvent(drop);667 await flushView();668669 const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;670 expect(script.value).toBe("dropped-peach-backup");671 });672673 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {674 const device = {675 deviceId: "phone-1",676 deviceName: "June's Phone",677 deviceType: "android" as const,678 longTermPublicKeyHex: "a".repeat(64),679 signingPublicKeyHex: "b".repeat(64),680 pairedAt: 1,681 trustSource: "direct" as const,682 };683 sendMessageMock.mockImplementation(async (msg: unknown) => {684 const m = msg as { type: string };685 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };686 return { ok: true, data: null };687 });688689 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);690 for (let i = 0; i < 30; i++) await Promise.resolve();691692 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>693 /sync/i.test(b.textContent ?? ""),694 );695 openSync?.click();696 for (let i = 0; i < 30; i++) await Promise.resolve();697698 const remove = Array.from(container.querySelectorAll("button")).find(699 (b) => (b.textContent ?? "").trim() === "remove",700 );701 expect(remove).toBeTruthy();702703 704 705 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();706707 remove!.click();708 for (let i = 0; i < 10; i++) await Promise.resolve();709710 711 712 const note = container.querySelector(".peach-unpair-confirm-note");713 expect(note).toBeTruthy();714 const text = note!.textContent ?? "";715 expect(text).toContain("master password");716 expect(text).toContain("recovery words");717 expect(text).toContain("future changes");718 });719720 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {721 const device = {722 deviceId: "phone-1",723 deviceName: "June's Phone",724 deviceType: "android" as const,725 longTermPublicKeyHex: "a".repeat(64),726 signingPublicKeyHex: "b".repeat(64),727 pairedAt: 1,728 trustSource: "direct" as const,729 };730 let paired: typeof device[] = [device];731 sendMessageMock.mockImplementation(async (msg: any) => {732 if (msg?.type === "LIST_PAIRED_DEVICES") {733 return { ok: true, data: { devices: [...paired] } };734 }735 if (msg?.type === "UNPAIR_DEVICE") {736 737 738 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);739 return { ok: true };740 }741 if (msg?.type === "RENAME_PAIRED_DEVICE") {742 paired = paired.map((d) =>743 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,744 );745 return { ok: true };746 }747 return { ok: true, data: null };748 });749750 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);751 for (let i = 0; i < 30; i++) await Promise.resolve();752 Array.from(container.querySelectorAll("button"))753 .find((b) => /sync/i.test(b.textContent ?? ""))754 ?.click();755 for (let i = 0; i < 30; i++) await Promise.resolve();756757 758 expect(container.textContent).toContain("PAIRED DEVICES");759 expect(container.textContent).toContain("June's Phone");760 expect(container.textContent).toContain("Android");761 Array.from(container.querySelectorAll("button"))762 .find((b) => (b.textContent ?? "").trim() === "rename")!763 .click();764 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;765 renameInput.value = "Travel phone";766 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));767 Array.from(container.querySelectorAll("button"))768 .find((b) => (b.textContent ?? "").trim() === "save")!769 .click();770 for (let i = 0; i < 30; i++) await Promise.resolve();771 expect(sendMessageMock).toHaveBeenCalledWith({772 type: "RENAME_PAIRED_DEVICE",773 payload: { deviceId: "phone-1", name: "Travel phone" },774 });775 expect(container.textContent).toContain("Travel phone");776 const syncNowButton = container.querySelector<HTMLButtonElement>(777 'button[aria-label="Sync this vault now"]',778 )!;779 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));780 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;781 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;782 expect(783 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,784 ).toBeTruthy();785 expect(786 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,787 ).toBeTruthy();788789 Array.from(container.querySelectorAll("button"))790 .find((b) => (b.textContent ?? "").trim() === "remove")!791 .click();792 for (let i = 0; i < 10; i++) await Promise.resolve();793 const confirm = Array.from(794 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),795 ).find((b) => (b.textContent ?? "").trim() === "remove");796 confirm!.click();797 for (let i = 0; i < 30; i++) await Promise.resolve();798799 expect(sendMessageMock).toHaveBeenCalledWith({800 type: "UNPAIR_DEVICE",801 payload: { deviceId: "phone-1" },802 });803 804 805 806 expect(container.textContent).not.toContain("June's Phone");807 expect(container.textContent).not.toContain("PAIRED DEVICES");808 });809810 it("Settings keeps connection and recovery details collapsed until requested", async () => {811 sendMessageMock.mockImplementation(async (msg: any) => {812 if (msg?.type === "LIST_PAIRED_DEVICES") {813 return { ok: true, data: { devices: [] } };814 }815 return { ok: true, data: null };816 });817818 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);819 for (let i = 0; i < 30; i++) await Promise.resolve();820 Array.from(container.querySelectorAll("button"))821 .find((button) => /sync/i.test(button.textContent ?? ""))822 ?.click();823 for (let i = 0; i < 20; i++) await Promise.resolve();824825 expect(container.textContent).toContain("SYNC STATUS");826 expect(container.textContent).toContain("ADD A DEVICE");827 expect(container.textContent).not.toContain("Public relay");828 expect(container.textContent).not.toContain("Rotate sync identity");829830 const connection = Array.from(container.querySelectorAll("button"))831 .find((button) => button.textContent?.includes("CONNECTION"));832 connection?.click();833 await Promise.resolve();834 expect(container.textContent).toContain("Public relay");835836 const recovery = Array.from(container.querySelectorAll("button"))837 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));838 recovery?.click();839 await Promise.resolve();840 expect(container.textContent).toContain("Rotate sync identity");841 });842843 it("Settings requires current, new, and confirm passwords before enabling save", async () => {844 sendMessageMock.mockResolvedValue({ ok: true, data: null });845 const container = mount(846 <SettingsView847 entriesCount={1}848 breachMonitoring={true}849 lockOnClose={true}850 autoLockMinutes={5}851 trashRetentionDays={30}852 theme="dark"853 accentColor="peach"854 textSize="medium"855 reduceMotion={false}856 onBreachMonitoringChange={vi.fn()}857 onLockOnCloseChange={vi.fn()}858 onAutoLockMinutesChange={vi.fn()}859 onTrashRetentionChange={vi.fn()}860 onThemeChange={vi.fn()}861 onAccentChange={vi.fn()}862 onTextSizeChange={vi.fn()}863 onReduceMotionChange={vi.fn()}864 shortcuts={DEFAULT_BINDINGS}865 onShortcutsChange={vi.fn()}866 onBack={vi.fn()}867 onImport={vi.fn()}868 onExport={vi.fn()}869 onBreachCheckDone={vi.fn()}870 />871 );872 await flushView();873874 expect(container.querySelectorAll(".stk-card").length).toBe(7);875 876 877 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");878879 const changeButton = Array.from(container.querySelectorAll("button")).find(880 (candidate) => candidate.textContent?.trim() === "change"881 ) as HTMLButtonElement;882 changeButton.click();883 await flushView();884885 const saveButton = Array.from(container.querySelectorAll("button")).find(886 (candidate) => candidate.textContent?.trim() === "save new password"887 ) as HTMLButtonElement;888 expect(saveButton.disabled).toBe(true);889890 const inputs = container.querySelectorAll('input[type="password"]');891 inputText(inputs[0] as HTMLInputElement, "current-pass");892 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");893 await flushView();894 expect(saveButton.disabled).toBe(true);895896 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");897 await flushView();898 expect(saveButton.disabled).toBe(false);899 });900901 it("Settings feedback highlights the selected category and hides the form after submit", async () => {902 sendMessageMock.mockResolvedValue({ ok: true, data: null });903 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({904 ok: true,905 json: async () => ({ success: true }),906 }));907 vi.stubGlobal("fetch", fetchMock);908909 const container = mount(910 <SettingsView911 entriesCount={1}912 breachMonitoring={true}913 lockOnClose={true}914 autoLockMinutes={5}915 trashRetentionDays={30}916 theme="dark"917 accentColor="peach"918 textSize="medium"919 reduceMotion={false}920 initialSection="feedback"921 onBreachMonitoringChange={vi.fn()}922 onLockOnCloseChange={vi.fn()}923 onAutoLockMinutesChange={vi.fn()}924 onTrashRetentionChange={vi.fn()}925 onThemeChange={vi.fn()}926 onAccentChange={vi.fn()}927 onTextSizeChange={vi.fn()}928 onReduceMotionChange={vi.fn()}929 shortcuts={DEFAULT_BINDINGS}930 onShortcutsChange={vi.fn()}931 onBack={vi.fn()}932 onImport={vi.fn()}933 onExport={vi.fn()}934 onBreachCheckDone={vi.fn()}935 />936 );937 await flushView();938939 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;940 expect(feedbackCard.classList.contains("is-active")).toBe(true);941942 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];943 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;944 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;945 expect(bugButton.getAttribute("aria-pressed")).toBe("true");946 expect(featureButton.getAttribute("aria-pressed")).toBe("false");947948 featureButton.click();949 await flushView();950951 expect(bugButton.getAttribute("aria-pressed")).toBe("false");952 expect(featureButton.getAttribute("aria-pressed")).toBe("true");953 expect(featureButton.classList.contains("is-active")).toBe(true);954955 inputText(956 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,957 '<script>alert("xss")</script> Please make this easier to see & safer.'958 );959 const sendButton = Array.from(container.querySelectorAll("button")).find(960 (candidate) => candidate.textContent?.trim() === "Send"961 ) as HTMLButtonElement;962 sendButton.click();963964 await vi.waitFor(() => {965 expect(container.textContent).toContain("Thank you");966 });967968 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));969 expect(requestBody.subject).toContain("Feature request");970 expect(requestBody.message).toBe(971 "<script>alert("xss")</script> Please make this easier to see & safer."972 );973 expect(requestBody.message).not.toContain("<script>");974 expect(container.textContent).toContain("Someone will process it soon");975 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();976 expect(container.querySelector(".peach-feedback-submit")).toBeNull();977 });978979 it("Settings keeps license activation on the full-screen celebration path", async () => {980 const onLicenseChange = vi.fn(async () => undefined);981 sendMessageMock.mockImplementation(async (msg: unknown) => {982 const type = (msg as { type?: string }).type;983 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };984 if (type === "ACTIVATE_LICENSE") {985 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };986 }987 if (type === "GET_ENTRIES") return { ok: true, data: [] };988 return { ok: true, data: null };989 });990991 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);992 await flushView();993994 inputText(995 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,996 "PEACH-TEST-KEY",997 );998 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;999 apply.click();10001001 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));1002 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);1003 expect(container.textContent).not.toContain("thank you ✦");1004 });10051006 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1007 sendMessageMock.mockImplementation(async (msg: unknown) => {1008 const type = (msg as { type?: string }).type;1009 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1010 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1011 if (type === "GET_ENTRIES") return { ok: true, data: [] };1012 return { ok: true, data: null };1013 });10141015 const onLicenseChangeFromStar = vi.fn();1016 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1017 await flushView();1018 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1019 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1020 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();10211022 const onLicenseChangeFromPeach = vi.fn();1023 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1024 await flushView();1025 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1026 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1027 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1028 });10291030 it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1031 sendMessageMock.mockResolvedValue({ ok: true, data: null });1032 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1033 ok: true,1034 json: async () => ({ success: true }),1035 }));1036 vi.stubGlobal("fetch", fetchMock);1037 1038 1039 1040 vi.stubGlobal("browser", {1041 permissions: {1042 getAll: vi.fn(async () => ({ data_collection: [] })),1043 request: vi.fn(async () => false),1044 },1045 });10461047 const container = mount(1048 <SettingsView1049 entriesCount={1}1050 breachMonitoring={true}1051 lockOnClose={true}1052 autoLockMinutes={5}1053 trashRetentionDays={30}1054 theme="dark"1055 accentColor="peach"1056 textSize="medium"1057 reduceMotion={false}1058 initialSection="feedback"1059 onBreachMonitoringChange={vi.fn()}1060 onLockOnCloseChange={vi.fn()}1061 onAutoLockMinutesChange={vi.fn()}1062 onTrashRetentionChange={vi.fn()}1063 onThemeChange={vi.fn()}1064 onAccentChange={vi.fn()}1065 onTextSizeChange={vi.fn()}1066 onReduceMotionChange={vi.fn()}1067 shortcuts={DEFAULT_BINDINGS}1068 onShortcutsChange={vi.fn()}1069 onBack={vi.fn()}1070 onImport={vi.fn()}1071 onExport={vi.fn()}1072 onBreachCheckDone={vi.fn()}1073 />1074 );1075 await flushView();10761077 inputText(1078 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1079 "Please improve the feedback flow."1080 );1081 const sendButton = Array.from(container.querySelectorAll("button")).find(1082 (candidate) => candidate.textContent?.trim() === "Send"1083 ) as HTMLButtonElement;1084 sendButton.click();10851086 await vi.waitFor(() => {1087 expect(fetchMock).toHaveBeenCalledTimes(1);1088 });1089 await vi.waitFor(() => {1090 expect(container.textContent).toContain("Thank you");1091 });1092 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1093 });10941095 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1096 sendMessageMock.mockImplementation(async (msg: unknown) => {1097 const m = msg as { type: string };1098 if (m.type === "GET_RECOVERY_WORDS") {1099 return {1100 ok: true,1101 data: [1102 "abandon", "ability", "able", "about", "above", "absent",1103 "absorb", "abstract", "absurd", "abuse", "access", "accident",1104 ],1105 };1106 }1107 return { ok: true, data: null };1108 });1109 const onAutoLockMinutesChange = vi.fn();1110 const onThemeChange = vi.fn();1111 const container = mount(1112 <SettingsView1113 entriesCount={1}1114 breachMonitoring={true}1115 lockOnClose={true}1116 autoLockMinutes={5}1117 trashRetentionDays={30}1118 theme="dark"1119 accentColor="peach"1120 textSize="medium"1121 reduceMotion={false}1122 onBreachMonitoringChange={vi.fn()}1123 onLockOnCloseChange={vi.fn()}1124 onAutoLockMinutesChange={onAutoLockMinutesChange}1125 onTrashRetentionChange={vi.fn()}1126 onThemeChange={onThemeChange}1127 onAccentChange={vi.fn()}1128 onTextSizeChange={vi.fn()}1129 onReduceMotionChange={vi.fn()}1130 shortcuts={DEFAULT_BINDINGS}1131 onShortcutsChange={vi.fn()}1132 onBack={vi.fn()}1133 onImport={vi.fn()}1134 onExport={vi.fn()}1135 onBreachCheckDone={vi.fn()}1136 />1137 );1138 await flushView();1139 sendMessageMock.mockClear();11401141 1142 const selects = container.querySelectorAll("select");1143 changeSelect(selects[0] as HTMLSelectElement, "15");1144 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);11451146 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1147 appearanceCard.click();1148 await flushView();1149 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1150 const themeButton = Array.from(container.querySelectorAll("button")).find(1151 (candidate) => candidate.textContent?.trim() === "Switch to light"1152 ) as HTMLButtonElement;1153 themeButton.click();1154 expect(onThemeChange).toHaveBeenCalledWith("light");11551156 1157 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1158 protectionCard.click();1159 await flushView();11601161 const changeButton = Array.from(container.querySelectorAll("button")).find(1162 (candidate) => candidate.textContent?.trim() === "change"1163 ) as HTMLButtonElement;1164 changeButton.click();1165 await flushView();11661167 const inputs = container.querySelectorAll('input[type="password"]');1168 inputText(inputs[0] as HTMLInputElement, "current-pass");1169 inputText(inputs[1] as HTMLInputElement, "new-password-123");1170 inputText(inputs[2] as HTMLInputElement, "new-password-123");1171 await flushView();11721173 const saveButton = Array.from(container.querySelectorAll("button")).find(1174 (candidate) => candidate.textContent?.trim() === "save new password"1175 ) as HTMLButtonElement;1176 saveButton.click();1177 await flushView();11781179 expect(sendMessageMock).toHaveBeenCalledWith({1180 type: "CHANGE_PASSWORD",1181 payload: {1182 currentPassword: "current-pass",1183 newPassword: "new-password-123",1184 },1185 });11861187 1188 expect(container.textContent).toContain("save your recovery words");1189 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1190 });11911192 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1193 sendMessageMock.mockResolvedValue({ ok: true });1194 const container = mount(1195 <EntryView1196 entry={sampleEntry()}1197 onDelete={vi.fn()}1198 onFieldSaved={vi.fn(async () => undefined)}1199 />1200 );12011202 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1203 passwordButton.click();1204 await flushView();12051206 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1207 type: "COPY",1208 payload: { text: "secret123", label: "password", alreadyWritten: false },1209 });1210 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1211 type: "FIRE_CANARY_PING",1212 payload: {1213 webhookUrl: "https://example.com/hook",1214 entryTitle: "GitHub",1215 trigger: "password_copied",1216 },1217 });12181219 sendMessageMock.mockClear();1220 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1221 usernameButton.click();1222 await flushView();12231224 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1225 type: "COPY",1226 payload: { text: "june", label: "username", alreadyWritten: false },1227 });1228 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1229 type: "FIRE_CANARY_PING",1230 payload: {1231 webhookUrl: "https://example.com/hook",1232 entryTitle: "GitHub",1233 trigger: "username_copied",1234 },1235 });1236 });12371238 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1239 const container = mount(1240 <EntryView1241 entry={sampleEntry({1242 type: "passkey",1243 username: undefined,1244 password: undefined,1245 totp: undefined,1246 canary: undefined,1247 passkey: {1248 rpId: "vercel.com",1249 rpName: "Vercel",1250 credentialId: "cred-1",1251 publicKeyCose: "cose",1252 userHandle: "handle",1253 userName: "june",1254 userDisplayName: "June",1255 algorithm: -7,1256 signCount: 0,1257 aaguid: "",1258 backupEligible: false,1259 backupState: false,1260 createdAt: 1,1261 },1262 })}1263 onDelete={vi.fn()}1264 onFieldSaved={vi.fn(async () => undefined)}1265 />1266 );12671268 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1269 (button) => button.textContent?.includes("Advanced")1270 ) as HTMLButtonElement | undefined;1271 expect(advanced).toBeDefined();1272 expect(advanced?.textContent).toContain("+ Advanced");12731274 advanced!.click();1275 await flushView();12761277 expect(container.textContent).toContain("passkey · vercel.com");1278 });12791280 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1281 sendMessageMock.mockResolvedValue({ ok: true });1282 const onFieldSaved = vi.fn(async () => undefined);1283 const container = mount(1284 <EntryView1285 entry={sampleEntry()}1286 onDelete={vi.fn()}1287 onFieldSaved={onFieldSaved}1288 />1289 );12901291 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1292 passwordValue.click();1293 await flushView();12941295 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1296 inputText(passwordInput, "");1297 await flushView();12981299 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1300 saveButton.click();1301 await flushView();13021303 expect(sendMessageMock).not.toHaveBeenCalledWith(1304 expect.objectContaining({ type: "UPDATE_ENTRY" })1305 );1306 expect(onFieldSaved).not.toHaveBeenCalled();13071308 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1309 totpMain.click();13101311 await vi.waitFor(() => {1312 expect(sendMessageMock).toHaveBeenLastCalledWith({1313 type: "COPY",1314 payload: { text: "123456", label: "totp", alreadyWritten: false },1315 });1316 }, { timeout: 500 });1317 });13181319 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1320 sendMessageMock.mockResolvedValue({ ok: true });1321 const container = mount(1322 <EntryView1323 entry={sampleEntry()}1324 onDelete={vi.fn()}1325 onFieldSaved={vi.fn(async () => undefined)}1326 />1327 );13281329 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1330 usernameValue.click();1331 await flushView();13321333 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1334 usernameInput.blur();1335 await flushView();13361337 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1338 expect(sendMessageMock).not.toHaveBeenCalledWith(1339 expect.objectContaining({ type: "UPDATE_ENTRY" })1340 );1341 });13421343 it("Entry keeps the full used-email list available while editing an email", async () => {1344 const container = mount(1345 <EntryView1346 entry={sampleEntry({ email: "[email protected]" })}1347 entries={[1348 sampleEntry({ email: "[email protected]" }),1349 sampleEntry({ id: "entry-2", email: "[email protected]" }),1350 sampleEntry({ id: "entry-3", username: "duckuccino" }),1351 ]}1352 onDelete={vi.fn()}1353 onFieldSaved={vi.fn(async () => undefined)}1354 />1355 );13561357 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1358 (card) => card.querySelector(".peach-label")?.textContent === "email"1359 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1360 emailValue.click();1361 await flushView();13621363 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1364 .map((element) => element.textContent);1365 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1366 });13671368 it("Entry lets TOTP secrets be edited inline", async () => {1369 sendMessageMock.mockResolvedValue({ ok: true });1370 const onFieldSaved = vi.fn(async () => undefined);1371 const container = mount(1372 <EntryView1373 entry={sampleEntry()}1374 onDelete={vi.fn()}1375 onFieldSaved={onFieldSaved}1376 />1377 );13781379 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1380 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1381 await flushView();13821383 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1384 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1385 await flushView();13861387 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1388 saveButton.click();1389 await flushView();13901391 expect(sendMessageMock).toHaveBeenCalledWith({1392 type: "UPDATE_ENTRY",1393 payload: {1394 id: "entry-1",1395 patch: { totp: "NB2W45DFOIZA====" },1396 },1397 });1398 expect(onFieldSaved).toHaveBeenCalled();1399 });14001401 it("Vault seeds and focuses search when a search seed is provided", async () => {1402 const container = mount(1403 <VaultView1404 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1405 sortOrder="url-first"1406 searchSeed="g"1407 searchSeedNonce={1}1408 onSelectEntry={vi.fn()}1409 onSortOrderChange={vi.fn()}1410 onAdd={vi.fn()}1411 onClose={vi.fn()}1412 />1413 );14141415 await flushView();14161417 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1418 expect(search.value).toBe("g");1419 expect(document.activeElement).toBe(search);1420 });14211422 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1423 parseImportSourceMock.mockResolvedValue(1424 sampleImportResult({1425 entries: [1426 {1427 type: "login",1428 title: "Imported site",1429 username: "imported-user",1430 password: "imported-pass",1431 url: "https://imported.test",1432 },1433 ],1434 })1435 );1436 sendMessageMock.mockImplementation(async (msg: unknown) => {1437 const type = (msg as { type?: string }).type;1438 if (type === "GET_ENTRIES") {1439 return {1440 ok: true,1441 data: [1442 sampleEntry({1443 id: "existing-1",1444 title: "Imported site",1445 username: "imported-user",1446 url: "https://imported.test",1447 }),1448 ],1449 };1450 }1451 if (type === "BATCH_ADD_ENTRIES") {1452 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1453 }1454 return { ok: true };1455 });1456 const onImport = vi.fn(async () => undefined);1457 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1458 await flushView();1459 sendMessageMock.mockClear();14601461 await selectImportFile(container);1462 findButton(container, "Import 1").click();1463 await flushView();14641465 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1466 expect(sendMessageMock).toHaveBeenCalledWith({1467 type: "BATCH_ADD_ENTRIES",1468 payload: {1469 entries: [1470 expect.objectContaining({1471 title: "Imported site",1472 username: "imported-user",1473 password: "imported-pass",1474 url: "https://imported.test",1475 }),1476 ],1477 },1478 });1479 expect(onImport).toHaveBeenCalled();1480 expect(container.textContent).toContain("1 entry appears to be duplicates");1481 });14821483 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1484 parseImportSourceMock.mockResolvedValue(sampleImportResult());1485 sendMessageMock.mockImplementation(async (msg: unknown) => {1486 const type = (msg as { type?: string }).type;1487 if (type === "GET_ENTRIES") {1488 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1489 }1490 if (type === "CLEAR_VAULT_ENTRIES") {1491 return { ok: true };1492 }1493 if (type === "BATCH_ADD_ENTRIES") {1494 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1495 }1496 return { ok: true };1497 });1498 const onImport = vi.fn(async () => undefined);1499 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1500 await flushView();1501 sendMessageMock.mockClear();15021503 await selectImportFile(container);1504 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1505 await flushView();15061507 findButton(container, "Replace vault with 1").click();1508 await flushView();15091510 expect(sendMessageMock).not.toHaveBeenCalledWith(1511 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1512 );1513 expect(container.textContent).toContain("Replace forever? This cannot be undone.");15141515 findButton(container, "Yes, replace forever").click();1516 await flushView();15171518 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1519 expect(sendMessageMock).toHaveBeenCalledWith({1520 type: "BATCH_ADD_ENTRIES",1521 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1522 });1523 expect(onImport).toHaveBeenCalled();1524 });15251526 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1527 parseImportSourceMock.mockResolvedValue(sampleImportResult());1528 sendMessageMock.mockImplementation(async (msg: unknown) => {1529 const type = (msg as { type?: string }).type;1530 if (type === "CREATE_VAULT") return { ok: true };1531 if (type === "BATCH_ADD_ENTRIES") {1532 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1533 }1534 return { ok: true };1535 });1536 const onImport = vi.fn(async () => undefined);1537 const container = mount(1538 <ImportView1539 mode="onboarding"1540 replacesExistingVault={true}1541 onBack={vi.fn()}1542 onImport={onImport}1543 />1544 );15451546 await selectImportFile(container);15471548 const createButton = findButton(container, "Create vault + import 1");1549 expect(createButton.disabled).toBe(true);1550 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1551 expect(container.textContent).toContain("Use at least 8 characters");15521553 const masterInput = container.querySelector(1554 'input[placeholder="new master password"]'1555 ) as HTMLInputElement;1556 const confirmInput = container.querySelector(1557 'input[placeholder="confirm master password"]'1558 ) as HTMLInputElement;1559 const revealButton = container.querySelector(1560 'button[aria-label="Show passwords"]'1561 ) as HTMLButtonElement;1562 expect(revealButton).toBeTruthy();1563 revealButton.click();1564 await flushView();1565 expect(masterInput.type).toBe("text");1566 expect(confirmInput.type).toBe("text");15671568 inputText(masterInput, "fresh-master-password");1569 inputText(confirmInput, "fresh-master-password");1570 await flushView();15711572 expect(createButton.disabled).toBe(false);1573 createButton.click();1574 await flushView();15751576 expect(sendMessageMock).toHaveBeenCalledWith({1577 type: "CREATE_VAULT",1578 payload: { password: "fresh-master-password" },1579 });1580 expect(sendMessageMock).toHaveBeenCalledWith({1581 type: "BATCH_ADD_ENTRIES",1582 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1583 });1584 expect(onImport).toHaveBeenCalled();1585 });15861587});