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("PB-26-709: pending device approval identifies the key, time, provenance, and vault scope", async () => {721 sendMessageMock.mockImplementation(async (msg: unknown) => {722 const m = msg as { type: string };723 if (m.type === "GET_PENDING_VOUCHED_DEVICES") {724 return {725 ok: true,726 data: [{727 deviceId: "phone-source-id",728 deviceName: "June's Phone",729 deviceType: "android",730 longTermPublicKeyHex: "a1b2c3d4e5f6" + "0".repeat(52),731 signingPublicKeyHex: "b".repeat(64),732 pairedAt: Date.UTC(2026, 8, 5),733 receivedAt: Date.UTC(2026, 8, 6, 20, 15),734 vouchedByDeviceId: "trusted-browser-id",735 }],736 };737 }738 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [] } };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((button) => /sync/i.test(button.textContent ?? ""))746 ?.click();747 for (let i = 0; i < 30; i++) await Promise.resolve();748749 expect(container.textContent).toContain("a1b2 c3d4 e5f6");750 expect(container.textContent).toContain("trusted-browser-id");751 expect(container.textContent).toContain("2026-09-06");752 expect(container.textContent).toContain("existing vault contents");753 });754755 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {756 const device = {757 deviceId: "phone-1",758 deviceName: "June's Phone",759 deviceType: "android" as const,760 longTermPublicKeyHex: "a".repeat(64),761 signingPublicKeyHex: "b".repeat(64),762 pairedAt: 1,763 trustSource: "direct" as const,764 };765 let paired: typeof device[] = [device];766 sendMessageMock.mockImplementation(async (msg: any) => {767 if (msg?.type === "LIST_PAIRED_DEVICES") {768 return { ok: true, data: { devices: [...paired] } };769 }770 if (msg?.type === "UNPAIR_DEVICE") {771 772 773 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);774 return { ok: true };775 }776 if (msg?.type === "RENAME_PAIRED_DEVICE") {777 paired = paired.map((d) =>778 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,779 );780 return { ok: true };781 }782 return { ok: true, data: null };783 });784785 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);786 for (let i = 0; i < 30; i++) await Promise.resolve();787 Array.from(container.querySelectorAll("button"))788 .find((b) => /sync/i.test(b.textContent ?? ""))789 ?.click();790 for (let i = 0; i < 30; i++) await Promise.resolve();791792 793 expect(container.textContent).toContain("PAIRED DEVICES");794 expect(container.textContent).toContain("June's Phone");795 expect(container.textContent).toContain("Android");796 Array.from(container.querySelectorAll("button"))797 .find((b) => (b.textContent ?? "").trim() === "rename")!798 .click();799 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;800 renameInput.value = "Travel phone";801 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));802 Array.from(container.querySelectorAll("button"))803 .find((b) => (b.textContent ?? "").trim() === "save")!804 .click();805 for (let i = 0; i < 30; i++) await Promise.resolve();806 expect(sendMessageMock).toHaveBeenCalledWith({807 type: "RENAME_PAIRED_DEVICE",808 payload: { deviceId: "phone-1", name: "Travel phone" },809 });810 expect(container.textContent).toContain("Travel phone");811 const syncNowButton = container.querySelector<HTMLButtonElement>(812 'button[aria-label="Sync this vault now"]',813 )!;814 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));815 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;816 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;817 expect(818 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,819 ).toBeTruthy();820 expect(821 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,822 ).toBeTruthy();823824 Array.from(container.querySelectorAll("button"))825 .find((b) => (b.textContent ?? "").trim() === "remove")!826 .click();827 for (let i = 0; i < 10; i++) await Promise.resolve();828 const confirm = Array.from(829 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),830 ).find((b) => (b.textContent ?? "").trim() === "remove");831 confirm!.click();832 for (let i = 0; i < 30; i++) await Promise.resolve();833834 expect(sendMessageMock).toHaveBeenCalledWith({835 type: "UNPAIR_DEVICE",836 payload: { deviceId: "phone-1" },837 });838 839 840 841 expect(container.textContent).not.toContain("June's Phone");842 expect(container.textContent).not.toContain("PAIRED DEVICES");843 });844 it("Settings keeps the confirmation and shows revoke failures", async () => {845 const device = {846 deviceId: "phone-1",847 deviceName: "June's Phone",848 deviceType: "android" as const,849 longTermPublicKeyHex: "a".repeat(64),850 signingPublicKeyHex: "b".repeat(64),851 pairedAt: 1,852 trustSource: "direct" as const,853 };854 sendMessageMock.mockImplementation(async (msg: unknown) => {855 const message = msg as { type?: string; payload?: { deviceId?: string } };856 if (message.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };857 if (message.type === "UNPAIR_DEVICE") return { ok: false, error: "trust_data_corrupted" };858 return { ok: true, data: null };859 });860861 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);862 for (let i = 0; i < 30; i++) await Promise.resolve();863 for (let i = 0; i < 30; i++) await Promise.resolve();864 Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();865 for (let i = 0; i < 30; i++) await Promise.resolve();866 Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();867 for (let i = 0; i < 10; i++) await Promise.resolve();868 Array.from(container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();869 for (let i = 0; i < 30; i++) await Promise.resolve();870871 expect(container.textContent).toContain("trust_data_corrupted");872 expect(container.textContent).toContain("June's Phone");873 });874875 it("Settings keeps connection and recovery details collapsed until requested", async () => {876 sendMessageMock.mockImplementation(async (msg: any) => {877 if (msg?.type === "LIST_PAIRED_DEVICES") {878 return { ok: true, data: { devices: [] } };879 }880 return { ok: true, data: null };881 });882883 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);884 for (let i = 0; i < 30; i++) await Promise.resolve();885 Array.from(container.querySelectorAll("button"))886 .find((button) => /sync/i.test(button.textContent ?? ""))887 ?.click();888 for (let i = 0; i < 20; i++) await Promise.resolve();889890 expect(container.textContent).toContain("SYNC STATUS");891 expect(container.textContent).toContain("ADD A DEVICE");892 expect(container.textContent).not.toContain("Public relay");893 expect(container.textContent).not.toContain("Rotate sync identity");894895 const connection = Array.from(container.querySelectorAll("button"))896 .find((button) => button.textContent?.includes("CONNECTION"));897 connection?.click();898 await Promise.resolve();899 expect(container.textContent).toContain("Public relay");900901 const recovery = Array.from(container.querySelectorAll("button"))902 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));903 recovery?.click();904 await Promise.resolve();905 expect(container.textContent).toContain("Rotate sync identity");906 });907908 it("Settings requires current, new, and confirm passwords before enabling save", async () => {909 sendMessageMock.mockResolvedValue({ ok: true, data: null });910 const container = mount(911 <SettingsView912 entriesCount={1}913 breachMonitoring={true}914 lockOnClose={true}915 autoLockMinutes={5}916 trashRetentionDays={30}917 theme="dark"918 accentColor="peach"919 textSize="medium"920 reduceMotion={false}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 expect(container.querySelectorAll(".stk-card").length).toBe(7);940 941 942 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");943944 const changeButton = Array.from(container.querySelectorAll("button")).find(945 (candidate) => candidate.textContent?.trim() === "change"946 ) as HTMLButtonElement;947 changeButton.click();948 await flushView();949950 const saveButton = Array.from(container.querySelectorAll("button")).find(951 (candidate) => candidate.textContent?.trim() === "save new password"952 ) as HTMLButtonElement;953 expect(saveButton.disabled).toBe(true);954955 const inputs = container.querySelectorAll('input[type="password"]');956 inputText(inputs[0] as HTMLInputElement, "current-pass");957 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");958 await flushView();959 expect(saveButton.disabled).toBe(true);960961 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");962 await flushView();963 expect(saveButton.disabled).toBe(false);964 });965966 it("Settings feedback highlights the selected category and hides the form after submit", async () => {967 sendMessageMock.mockResolvedValue({ ok: true, data: null });968 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({969 ok: true,970 json: async () => ({ success: true }),971 }));972 vi.stubGlobal("fetch", fetchMock);973974 const container = mount(975 <SettingsView976 entriesCount={1}977 breachMonitoring={true}978 lockOnClose={true}979 autoLockMinutes={5}980 trashRetentionDays={30}981 theme="dark"982 accentColor="peach"983 textSize="medium"984 reduceMotion={false}985 initialSection="feedback"986 onBreachMonitoringChange={vi.fn()}987 onLockOnCloseChange={vi.fn()}988 onAutoLockMinutesChange={vi.fn()}989 onTrashRetentionChange={vi.fn()}990 onThemeChange={vi.fn()}991 onAccentChange={vi.fn()}992 onTextSizeChange={vi.fn()}993 onReduceMotionChange={vi.fn()}994 shortcuts={DEFAULT_BINDINGS}995 onShortcutsChange={vi.fn()}996 onBack={vi.fn()}997 onImport={vi.fn()}998 onExport={vi.fn()}999 onBreachCheckDone={vi.fn()}1000 />1001 );1002 await flushView();10031004 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;1005 expect(feedbackCard.classList.contains("is-active")).toBe(true);10061007 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];1008 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;1009 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;1010 expect(bugButton.getAttribute("aria-pressed")).toBe("true");1011 expect(featureButton.getAttribute("aria-pressed")).toBe("false");10121013 featureButton.click();1014 await flushView();10151016 expect(bugButton.getAttribute("aria-pressed")).toBe("false");1017 expect(featureButton.getAttribute("aria-pressed")).toBe("true");1018 expect(featureButton.classList.contains("is-active")).toBe(true);10191020 inputText(1021 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1022 '<script>alert("xss")</script> Please make this easier to see & safer.'1023 );1024 const sendButton = Array.from(container.querySelectorAll("button")).find(1025 (candidate) => candidate.textContent?.trim() === "Send"1026 ) as HTMLButtonElement;1027 sendButton.click();10281029 await vi.waitFor(() => {1030 expect(container.textContent).toContain("Thank you");1031 });10321033 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));1034 expect(requestBody.subject).toContain("Feature request");1035 expect(requestBody.message).toBe(1036 "<script>alert("xss")</script> Please make this easier to see & safer."1037 );1038 expect(requestBody.message).not.toContain("<script>");1039 expect(container.textContent).toContain("Someone will process it soon");1040 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1041 expect(container.querySelector(".peach-feedback-submit")).toBeNull();1042 });10431044 it("Settings keeps license activation on the full-screen celebration path", async () => {1045 const onLicenseChange = vi.fn(async () => undefined);1046 sendMessageMock.mockImplementation(async (msg: unknown) => {1047 const type = (msg as { type?: string }).type;1048 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };1049 if (type === "ACTIVATE_LICENSE") {1050 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1051 }1052 if (type === "GET_ENTRIES") return { ok: true, data: [] };1053 return { ok: true, data: null };1054 });10551056 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);1057 await flushView();10581059 inputText(1060 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,1061 "PEACH-TEST-KEY",1062 );1063 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;1064 apply.click();10651066 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));1067 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);1068 expect(container.textContent).not.toContain("thank you ✦");1069 });10701071 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1072 sendMessageMock.mockImplementation(async (msg: unknown) => {1073 const type = (msg as { type?: string }).type;1074 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1075 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1076 if (type === "GET_ENTRIES") return { ok: true, data: [] };1077 return { ok: true, data: null };1078 });10791080 const onLicenseChangeFromStar = vi.fn();1081 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1082 await flushView();1083 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1084 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1085 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();10861087 const onLicenseChangeFromPeach = vi.fn();1088 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1089 await flushView();1090 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1091 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1092 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1093 });10941095 it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1096 sendMessageMock.mockResolvedValue({ ok: true, data: null });1097 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1098 ok: true,1099 json: async () => ({ success: true }),1100 }));1101 vi.stubGlobal("fetch", fetchMock);1102 1103 1104 1105 vi.stubGlobal("browser", {1106 permissions: {1107 getAll: vi.fn(async () => ({ data_collection: [] })),1108 request: vi.fn(async () => false),1109 },1110 });11111112 const container = mount(1113 <SettingsView1114 entriesCount={1}1115 breachMonitoring={true}1116 lockOnClose={true}1117 autoLockMinutes={5}1118 trashRetentionDays={30}1119 theme="dark"1120 accentColor="peach"1121 textSize="medium"1122 reduceMotion={false}1123 initialSection="feedback"1124 onBreachMonitoringChange={vi.fn()}1125 onLockOnCloseChange={vi.fn()}1126 onAutoLockMinutesChange={vi.fn()}1127 onTrashRetentionChange={vi.fn()}1128 onThemeChange={vi.fn()}1129 onAccentChange={vi.fn()}1130 onTextSizeChange={vi.fn()}1131 onReduceMotionChange={vi.fn()}1132 shortcuts={DEFAULT_BINDINGS}1133 onShortcutsChange={vi.fn()}1134 onBack={vi.fn()}1135 onImport={vi.fn()}1136 onExport={vi.fn()}1137 onBreachCheckDone={vi.fn()}1138 />1139 );1140 await flushView();11411142 inputText(1143 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1144 "Please improve the feedback flow."1145 );1146 const sendButton = Array.from(container.querySelectorAll("button")).find(1147 (candidate) => candidate.textContent?.trim() === "Send"1148 ) as HTMLButtonElement;1149 sendButton.click();11501151 await vi.waitFor(() => {1152 expect(fetchMock).toHaveBeenCalledTimes(1);1153 });1154 await vi.waitFor(() => {1155 expect(container.textContent).toContain("Thank you");1156 });1157 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1158 });11591160 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1161 sendMessageMock.mockImplementation(async (msg: unknown) => {1162 const m = msg as { type: string };1163 if (m.type === "GET_RECOVERY_WORDS") {1164 return {1165 ok: true,1166 data: [1167 "abandon", "ability", "able", "about", "above", "absent",1168 "absorb", "abstract", "absurd", "abuse", "access", "accident",1169 ],1170 };1171 }1172 return { ok: true, data: null };1173 });1174 const onAutoLockMinutesChange = vi.fn();1175 const onThemeChange = vi.fn();1176 const container = mount(1177 <SettingsView1178 entriesCount={1}1179 breachMonitoring={true}1180 lockOnClose={true}1181 autoLockMinutes={5}1182 trashRetentionDays={30}1183 theme="dark"1184 accentColor="peach"1185 textSize="medium"1186 reduceMotion={false}1187 onBreachMonitoringChange={vi.fn()}1188 onLockOnCloseChange={vi.fn()}1189 onAutoLockMinutesChange={onAutoLockMinutesChange}1190 onTrashRetentionChange={vi.fn()}1191 onThemeChange={onThemeChange}1192 onAccentChange={vi.fn()}1193 onTextSizeChange={vi.fn()}1194 onReduceMotionChange={vi.fn()}1195 shortcuts={DEFAULT_BINDINGS}1196 onShortcutsChange={vi.fn()}1197 onBack={vi.fn()}1198 onImport={vi.fn()}1199 onExport={vi.fn()}1200 onBreachCheckDone={vi.fn()}1201 />1202 );1203 await flushView();1204 sendMessageMock.mockClear();12051206 1207 const selects = container.querySelectorAll("select");1208 changeSelect(selects[0] as HTMLSelectElement, "15");1209 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);12101211 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1212 appearanceCard.click();1213 await flushView();1214 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1215 const themeButton = Array.from(container.querySelectorAll("button")).find(1216 (candidate) => candidate.textContent?.trim() === "Switch to light"1217 ) as HTMLButtonElement;1218 themeButton.click();1219 expect(onThemeChange).toHaveBeenCalledWith("light");12201221 1222 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1223 protectionCard.click();1224 await flushView();12251226 const changeButton = Array.from(container.querySelectorAll("button")).find(1227 (candidate) => candidate.textContent?.trim() === "change"1228 ) as HTMLButtonElement;1229 changeButton.click();1230 await flushView();12311232 const inputs = container.querySelectorAll('input[type="password"]');1233 inputText(inputs[0] as HTMLInputElement, "current-pass");1234 inputText(inputs[1] as HTMLInputElement, "new-password-123");1235 inputText(inputs[2] as HTMLInputElement, "new-password-123");1236 await flushView();12371238 const saveButton = Array.from(container.querySelectorAll("button")).find(1239 (candidate) => candidate.textContent?.trim() === "save new password"1240 ) as HTMLButtonElement;1241 saveButton.click();1242 await flushView();12431244 expect(sendMessageMock).toHaveBeenCalledWith({1245 type: "CHANGE_PASSWORD",1246 payload: {1247 currentPassword: "current-pass",1248 newPassword: "new-password-123",1249 },1250 });12511252 1253 expect(container.textContent).toContain("save your recovery words");1254 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1255 });12561257 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1258 sendMessageMock.mockResolvedValue({ ok: true });1259 const container = mount(1260 <EntryView1261 entry={sampleEntry()}1262 onDelete={vi.fn()}1263 onFieldSaved={vi.fn(async () => undefined)}1264 />1265 );12661267 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1268 passwordButton.click();1269 await flushView();12701271 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1272 type: "COPY",1273 payload: { text: "secret123", label: "password", alreadyWritten: false },1274 });1275 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1276 type: "FIRE_CANARY_PING",1277 payload: {1278 webhookUrl: "https://example.com/hook",1279 entryTitle: "GitHub",1280 trigger: "password_copied",1281 },1282 });12831284 sendMessageMock.mockClear();1285 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1286 usernameButton.click();1287 await flushView();12881289 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1290 type: "COPY",1291 payload: { text: "june", label: "username", alreadyWritten: false },1292 });1293 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1294 type: "FIRE_CANARY_PING",1295 payload: {1296 webhookUrl: "https://example.com/hook",1297 entryTitle: "GitHub",1298 trigger: "username_copied",1299 },1300 });1301 });13021303 it("Entry keyboard password copy reports its canary after COPY", async () => {1304 sendMessageMock.mockResolvedValue({ ok: true });1305 mount(1306 <EntryView1307 entry={sampleEntry()}1308 onDelete={vi.fn()}1309 onFieldSaved={vi.fn(async () => undefined)}1310 />1311 );13121313 window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", bubbles: true }));1314 await flushView();13151316 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1317 type: "COPY",1318 payload: { text: "secret123", label: "password", alreadyWritten: false },1319 });1320 await flushView();1321 expect(sendMessageMock.mock.calls).toContainEqual([{1322 type: "FIRE_CANARY_PING",1323 payload: {1324 webhookUrl: "https://example.com/hook",1325 entryTitle: "GitHub",1326 trigger: "password_copied",1327 },1328 }]);1329 });1330 it("Entry Cmd+C leaves the native clipboard shortcut untouched", async () => {1331 sendMessageMock.mockResolvedValue({ ok: true });1332 mount(1333 <EntryView1334 entry={sampleEntry()}1335 onDelete={vi.fn()}1336 onFieldSaved={vi.fn(async () => undefined)}1337 />1338 );13391340 window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", metaKey: true, bubbles: true }));1341 await flushView();13421343 expect(sendMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ type: "COPY" }));1344 });13451346 it("Vault password swipe copy reports its canary after COPY", async () => {1347 sendMessageMock.mockResolvedValue({ ok: true });1348 const container = mount(1349 <VaultView1350 entries={[sampleEntry()]}1351 sortOrder="url-first"1352 onSelectEntry={vi.fn()}1353 onSortOrderChange={vi.fn()}1354 onAdd={vi.fn()}1355 onClose={vi.fn()}1356 />1357 );1358 const row = container.querySelector(".peach-entry-row") as HTMLButtonElement;1359 row.dispatchEvent(new TouchEvent("touchstart", {1360 bubbles: true,1361 touches: [{ clientX: 0, clientY: 0 } as Touch],1362 }));1363 row.dispatchEvent(new TouchEvent("touchend", {1364 bubbles: true,1365 changedTouches: [{ clientX: 100, clientY: 0 } as Touch],1366 }));1367 await flushView();13681369 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1370 type: "COPY",1371 payload: { text: "secret123", label: "password", alreadyWritten: false },1372 });1373 await flushView();1374 expect(sendMessageMock.mock.calls).toContainEqual([{1375 type: "FIRE_CANARY_PING",1376 payload: {1377 webhookUrl: "https://example.com/hook",1378 entryTitle: "GitHub",1379 trigger: "password_copied",1380 },1381 }]);1382 });13831384 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1385 const container = mount(1386 <EntryView1387 entry={sampleEntry({1388 type: "passkey",1389 username: undefined,1390 password: undefined,1391 totp: undefined,1392 canary: undefined,1393 passkey: {1394 rpId: "vercel.com",1395 rpName: "Vercel",1396 credentialId: "cred-1",1397 publicKeyCose: "cose",1398 userHandle: "handle",1399 userName: "june",1400 userDisplayName: "June",1401 algorithm: -7,1402 signCount: 0,1403 aaguid: "",1404 backupEligible: false,1405 backupState: false,1406 createdAt: 1,1407 },1408 })}1409 onDelete={vi.fn()}1410 onFieldSaved={vi.fn(async () => undefined)}1411 />1412 );14131414 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1415 (button) => button.textContent?.includes("Advanced")1416 ) as HTMLButtonElement | undefined;1417 expect(advanced).toBeDefined();1418 expect(advanced?.textContent).toContain("+ Advanced");14191420 advanced!.click();1421 await flushView();14221423 expect(container.textContent).toContain("passkey · vercel.com");1424 });14251426 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1427 sendMessageMock.mockResolvedValue({ ok: true });1428 const onFieldSaved = vi.fn(async () => undefined);1429 const container = mount(1430 <EntryView1431 entry={sampleEntry()}1432 onDelete={vi.fn()}1433 onFieldSaved={onFieldSaved}1434 />1435 );14361437 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1438 passwordValue.click();1439 await flushView();14401441 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1442 inputText(passwordInput, "");1443 await flushView();14441445 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1446 saveButton.click();1447 await flushView();14481449 expect(sendMessageMock).not.toHaveBeenCalledWith(1450 expect.objectContaining({ type: "UPDATE_ENTRY" })1451 );1452 expect(onFieldSaved).not.toHaveBeenCalled();14531454 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1455 totpMain.click();14561457 await vi.waitFor(() => {1458 expect(sendMessageMock).toHaveBeenLastCalledWith({1459 type: "COPY",1460 payload: { text: "123456", label: "totp", alreadyWritten: false },1461 });1462 }, { timeout: 500 });1463 });14641465 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1466 sendMessageMock.mockResolvedValue({ ok: true });1467 const container = mount(1468 <EntryView1469 entry={sampleEntry()}1470 onDelete={vi.fn()}1471 onFieldSaved={vi.fn(async () => undefined)}1472 />1473 );14741475 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1476 usernameValue.click();1477 await flushView();14781479 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1480 usernameInput.blur();1481 await flushView();14821483 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1484 expect(sendMessageMock).not.toHaveBeenCalledWith(1485 expect.objectContaining({ type: "UPDATE_ENTRY" })1486 );1487 });14881489 it("Entry keeps the full used-email list available while editing an email", async () => {1490 const container = mount(1491 <EntryView1492 entry={sampleEntry({ email: "[email protected]" })}1493 entries={[1494 sampleEntry({ email: "[email protected]" }),1495 sampleEntry({ id: "entry-2", email: "[email protected]" }),1496 sampleEntry({ id: "entry-3", username: "duckuccino" }),1497 ]}1498 onDelete={vi.fn()}1499 onFieldSaved={vi.fn(async () => undefined)}1500 />1501 );15021503 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1504 (card) => card.querySelector(".peach-label")?.textContent === "email"1505 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1506 emailValue.click();1507 await flushView();15081509 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1510 .map((element) => element.textContent);1511 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1512 });15131514 it("Entry lets TOTP secrets be edited inline", async () => {1515 sendMessageMock.mockResolvedValue({ ok: true });1516 const onFieldSaved = vi.fn(async () => undefined);1517 const container = mount(1518 <EntryView1519 entry={sampleEntry()}1520 onDelete={vi.fn()}1521 onFieldSaved={onFieldSaved}1522 />1523 );15241525 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1526 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1527 await flushView();15281529 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1530 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1531 await flushView();15321533 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1534 saveButton.click();1535 await flushView();15361537 expect(sendMessageMock).toHaveBeenCalledWith({1538 type: "UPDATE_ENTRY",1539 payload: {1540 id: "entry-1",1541 patch: { totp: "NB2W45DFOIZA====" },1542 },1543 });1544 expect(onFieldSaved).toHaveBeenCalled();1545 });15461547 it("Vault seeds and focuses search when a search seed is provided", async () => {1548 const container = mount(1549 <VaultView1550 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1551 sortOrder="url-first"1552 searchSeed="g"1553 searchSeedNonce={1}1554 onSelectEntry={vi.fn()}1555 onSortOrderChange={vi.fn()}1556 onAdd={vi.fn()}1557 onClose={vi.fn()}1558 />1559 );15601561 await flushView();15621563 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1564 expect(search.value).toBe("g");1565 expect(document.activeElement).toBe(search);1566 });15671568 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1569 parseImportSourceMock.mockResolvedValue(1570 sampleImportResult({1571 entries: [1572 {1573 type: "login",1574 title: "Imported site",1575 username: "imported-user",1576 password: "imported-pass",1577 url: "https://imported.test",1578 },1579 ],1580 })1581 );1582 sendMessageMock.mockImplementation(async (msg: unknown) => {1583 const type = (msg as { type?: string }).type;1584 if (type === "GET_ENTRIES") {1585 return {1586 ok: true,1587 data: [1588 sampleEntry({1589 id: "existing-1",1590 title: "Imported site",1591 username: "imported-user",1592 url: "https://imported.test",1593 }),1594 ],1595 };1596 }1597 if (type === "BATCH_ADD_ENTRIES") {1598 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1599 }1600 return { ok: true };1601 });1602 const onImport = vi.fn(async () => undefined);1603 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1604 await flushView();1605 sendMessageMock.mockClear();16061607 await selectImportFile(container);1608 findButton(container, "Import 1").click();1609 await flushView();16101611 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1612 expect(sendMessageMock).toHaveBeenCalledWith({1613 type: "BATCH_ADD_ENTRIES",1614 payload: {1615 entries: [1616 expect.objectContaining({1617 title: "Imported site",1618 username: "imported-user",1619 password: "imported-pass",1620 url: "https://imported.test",1621 }),1622 ],1623 },1624 });1625 expect(onImport).toHaveBeenCalled();1626 expect(container.textContent).toContain("1 entry appears to be duplicates");1627 });16281629 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1630 parseImportSourceMock.mockResolvedValue(sampleImportResult());1631 sendMessageMock.mockImplementation(async (msg: unknown) => {1632 const type = (msg as { type?: string }).type;1633 if (type === "GET_ENTRIES") {1634 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1635 }1636 if (type === "CLEAR_VAULT_ENTRIES") {1637 return { ok: true };1638 }1639 if (type === "BATCH_ADD_ENTRIES") {1640 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1641 }1642 return { ok: true };1643 });1644 const onImport = vi.fn(async () => undefined);1645 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1646 await flushView();1647 sendMessageMock.mockClear();16481649 await selectImportFile(container);1650 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1651 await flushView();16521653 findButton(container, "Replace vault with 1").click();1654 await flushView();16551656 expect(sendMessageMock).not.toHaveBeenCalledWith(1657 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1658 );1659 expect(container.textContent).toContain("Replace forever? This cannot be undone.");16601661 findButton(container, "Yes, replace forever").click();1662 await flushView();16631664 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1665 expect(sendMessageMock).toHaveBeenCalledWith({1666 type: "BATCH_ADD_ENTRIES",1667 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1668 });1669 expect(onImport).toHaveBeenCalled();1670 });16711672 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1673 parseImportSourceMock.mockResolvedValue(sampleImportResult());1674 sendMessageMock.mockImplementation(async (msg: unknown) => {1675 const type = (msg as { type?: string }).type;1676 if (type === "CREATE_VAULT") return { ok: true };1677 if (type === "BATCH_ADD_ENTRIES") {1678 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1679 }1680 return { ok: true };1681 });1682 const onImport = vi.fn(async () => undefined);1683 const container = mount(1684 <ImportView1685 mode="onboarding"1686 replacesExistingVault={true}1687 onBack={vi.fn()}1688 onImport={onImport}1689 />1690 );16911692 await selectImportFile(container);16931694 const createButton = findButton(container, "Create vault + import 1");1695 expect(createButton.disabled).toBe(true);1696 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1697 expect(container.textContent).toContain("Use at least 8 characters");16981699 const masterInput = container.querySelector(1700 'input[placeholder="new master password"]'1701 ) as HTMLInputElement;1702 const confirmInput = container.querySelector(1703 'input[placeholder="confirm master password"]'1704 ) as HTMLInputElement;1705 const revealButton = container.querySelector(1706 'button[aria-label="Show passwords"]'1707 ) as HTMLButtonElement;1708 expect(revealButton).toBeTruthy();1709 revealButton.click();1710 await flushView();1711 expect(masterInput.type).toBe("text");1712 expect(confirmInput.type).toBe("text");17131714 inputText(masterInput, "fresh-master-password");1715 inputText(confirmInput, "fresh-master-password");1716 await flushView();17171718 expect(createButton.disabled).toBe(false);1719 createButton.click();1720 await flushView();17211722 expect(sendMessageMock).toHaveBeenCalledWith({1723 type: "CREATE_VAULT",1724 payload: { password: "fresh-master-password" },1725 });1726 expect(sendMessageMock).toHaveBeenCalledWith({1727 type: "BATCH_ADD_ENTRIES",1728 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1729 });1730 expect(onImport).toHaveBeenCalled();1731 });17321733});