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 lockedSyncRequiresUnlock: false,173 trashRetentionDays: 30,174 theme: "dark" as const,175 accentColor: "peach" as const,176 textSize: "medium" as const,177 reduceMotion: false,178 onBreachMonitoringChange: vi.fn(),179 onLockOnCloseChange: vi.fn(),180 onAutoLockMinutesChange: vi.fn(),181 onTrashRetentionChange: vi.fn(),182 onLockedSyncRequiresUnlockChange: vi.fn(),183 onThemeChange: vi.fn(),184 onAccentChange: vi.fn(),185 onTextSizeChange: vi.fn(),186 onReduceMotionChange: vi.fn(),187 shortcuts: DEFAULT_BINDINGS,188 onShortcutsChange: vi.fn(),189 onBack: vi.fn(),190 onImport: vi.fn(),191 onExport: vi.fn(),192 onBreachCheckDone: vi.fn(),193 ...overrides,194 };195 }196197 async function selectImportFile(198 container: HTMLElement,199 contents = '{"export":"test"}'200 ) {201 202 203 204 const fileCard = findButton(container, "Choose file →");205 if (fileCard) {206 fileCard.click();207 await flushView();208 }209 const input = container.querySelector('input[type="file"]') as HTMLInputElement;210 const file = new File([contents], "import.json", { type: "application/json" });211 Object.defineProperty(input, "files", {212 configurable: true,213 value: [file],214 });215 input.dispatchEvent(new Event("change", { bubbles: true }));216 await flushView();217 await flushView();218 }219220 function findButton(container: HTMLElement, text: string): HTMLButtonElement {221 return Array.from(container.querySelectorAll("button")).find(222 (candidate) => candidate.textContent?.trim() === text223 ) as HTMLButtonElement;224 }225226 it("Lock sends UNLOCK with the entered passphrase", async () => {227 sendMessageMock.mockResolvedValue({ ok: true });228 const onUnlock = vi.fn(async () => undefined);229 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);230231 const password = container.querySelector("#unlock-password") as HTMLInputElement;232 inputText(password, "correct horse battery staple");233 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();234 await flushView();235236 expect(sendMessageMock).toHaveBeenCalledWith({237 type: "UNLOCK",238 payload: { password: "correct horse battery staple" },239 });240 expect(onUnlock).toHaveBeenCalled();241 });242243 it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {244 sendMessageMock.mockImplementation(async (msg: unknown) => {245 const type = (msg as { type: string }).type;246 if (type === "GET_PIN_STATUS") {247 return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };248 }249 return type === "GET_SETTINGS"250 ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }251 : { ok: true };252 });253 const onUnlock = vi.fn(async () => undefined);254 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);255 await flushView();256257 inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");258 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();259 await flushView();260261 expect(sendMessageMock).toHaveBeenCalledWith({262 type: "UNLOCK_WITH_PIN",263 payload: { pin: "1234" },264 });265 expect(onUnlock).toHaveBeenCalled();266 expect(container.textContent).toContain("use master password");267 });268269 it("places the PIN method switch below the unlock button", async () => {270 sendMessageMock.mockImplementation(async (msg: unknown) => {271 const type = (msg as { type: string }).type;272 if (type === "GET_PIN_STATUS") {273 return { ok: true, data: { configured: true, available: true, attemptsRemaining: 3 } };274 }275 return type === "GET_SETTINGS"276 ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }277 : { ok: true };278 });279 const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);280 await flushView();281282 const buttons = Array.from(container.querySelectorAll("button"));283 const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");284 const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");285 expect(pinIndex).toBeGreaterThan(unlockIndex);286 });287288 it("keeps standalone note creation in Notes mode", async () => {289 const chromeApi = globalThis.chrome as unknown as {290 tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };291 };292 chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };293294 const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);295 await flushView();296297 expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))298 .toEqual(["login", "card"]);299 expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();300 });301302 it("gives Notes mode the full note-formatting toolbar", async () => {303 const container = mount(304 <NotesEditor305 note={null}306 onBack={vi.fn()}307 onSaved={vi.fn()}308 onDelete={vi.fn()}309 />310 );311 await flushView();312313 expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();314 expect(container.querySelector('[title="Divider"]')).toBeTruthy();315 expect(container.querySelector('[title="Quote"]')).toBeTruthy();316 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);317 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);318 expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");319 });320321 it("saves formatting applied by the Notes toolbar before leaving", async () => {322 const savedNote = {323 id: "note-1",324 type: "note" as const,325 title: "Untitled",326 notes: "<b>formatted</b>",327 createdAt: 1,328 modifiedAt: 2,329 };330 sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });331 const originalExecCommand = document.execCommand;332 const execCommandMock = vi.fn(() => {333 const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");334 if (body) body.innerHTML = "<b>formatted</b>";335 return true;336 });337 Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });338339 try {340 const container = mount(341 <NotesEditor342 note={null}343 onBack={vi.fn()}344 onSaved={vi.fn()}345 onDelete={vi.fn()}346 />347 );348 await flushView();349 container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(350 new MouseEvent("mousedown", { bubbles: true, cancelable: true })351 );352 expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);353 container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();354 await flushView();355356 expect(sendMessageMock).toHaveBeenCalledWith({357 type: "SAVE_NOTE",358 payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },359 });360 } finally {361 Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });362 }363 });364365 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {366 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);367 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;368 const password = container.querySelector("#master-password") as HTMLInputElement;369 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;370371 inputText(password, "short");372 inputText(confirm, "short");373 await flushView();374 expect(createButton.disabled).toBe(true);375376 zxcvbnMock.mockImplementation(() => ({ score: 1 }));377 inputText(password, "weakweakweak");378 inputText(confirm, "weakweakweak");379 await flushView();380 expect(createButton.disabled).toBe(true);381 });382383 it("Lock create-vault header pins the wordmark left with back on the right", async () => {384 const container = mount(385 <LockView386 hasVault={false}387 ready={true}388 onUnlock={vi.fn()}389 onBackToOnboarding={() => {}}390 />391 );392 const header = container.querySelector(".peach-newvault-header") as HTMLElement;393 const wordmark = header.querySelector(".peach-wordmark-stack");394 const back = header.querySelector(".peach-newvault-back");395 expect(wordmark).toBeTruthy();396 expect(back).toBeTruthy();397 398 399 expect(header.firstElementChild).toBe(wordmark);400 expect(header.lastElementChild).toBe(back);401 await flushView();402 });403404 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {405 406 sendMessageMock.mockImplementation(407 async (msg: unknown) => {408 const m = msg as { type: string; payload?: { password?: string } };409 if (m.type === "CREATE_VAULT") return { ok: true };410 if (m.type === "GET_RECOVERY_WORDS") {411 412 return {413 ok: true,414 data: [415 "abandon", "ability", "able", "about", "above", "absent",416 "absorb", "abstract", "absurd", "abuse", "access", "accident",417 ],418 };419 }420 return { ok: true };421 }422 );423 zxcvbnMock.mockImplementation(() => ({ score: 3 }));424 const onUnlock = vi.fn(async () => undefined);425 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);426427 const password = container.querySelector("#master-password") as HTMLInputElement;428 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;429 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;430431 inputText(password, "correct horse battery staple");432 inputText(confirm, "correct horse battery staple");433 await flushView();434 expect(createButton.disabled).toBe(false);435436 pressEnter(confirm);437 await flushView();438439 expect(sendMessageMock).toHaveBeenCalledWith({440 type: "CREATE_VAULT",441 payload: { password: "correct horse battery staple" },442 });443 444 445 expect(onUnlock).not.toHaveBeenCalled();446 });447448 it("recovery display asks for three distinct randomized word positions and confirms", async () => {449 const words12 = [450 "abandon", "ability", "able", "about", "above", "absent",451 "absorb", "abstract", "absurd", "abuse", "access", "accident",452 ];453 sendMessageMock.mockImplementation(async (msg: unknown) => {454 const type = (msg as { type?: string }).type;455 if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };456 return { ok: true };457 });458459 vi.useFakeTimers();460 const onDone = vi.fn();461 try {462 const container = mount(<RecoveryWordDisplayView onDone={onDone} />);463 464 await vi.advanceTimersByTimeAsync(0);465466 const prompts = Array.from(467 container.querySelectorAll(".peach-recovery-confirm-num")468 ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));469 expect(prompts).toHaveLength(3);470 expect(new Set(prompts).size).toBe(3);471 for (const p of prompts) {472 expect(p).toBeGreaterThanOrEqual(1);473 expect(p).toBeLessThanOrEqual(12);474 }475476 477 const inputs = Array.from(478 container.querySelectorAll(".peach-recovery-confirm-input")479 ) as HTMLInputElement[];480 expect(inputs).toHaveLength(3);481 expect(inputs[0]!.disabled).toBe(true);482 await vi.advanceTimersByTimeAsync(5000);483 expect(inputs[0]!.disabled).toBe(false);484485 prompts.forEach((pos, i) => {486 inputText(inputs[i]!, words12[pos - 1]!);487 });488489 const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;490 expect(confirmBtn.disabled).toBe(false);491 confirmBtn.click();492 await vi.advanceTimersByTimeAsync(700); 493494 expect(onDone).toHaveBeenCalledTimes(1);495 } finally {496 vi.useRealTimers();497 }498 });499500 it("Recovery words flow submits the new master password and completes", async () => {501 sendMessageMock.mockImplementation(async (msg: unknown) => {502 const type = (msg as { type?: string }).type;503 if (type === "VALIDATE_RECOVERY_WORDS") {504 return { ok: true, data: { valid: true, invalid: [] } };505 }506 if (type === "RECOVER_WITH_WORDS") return { ok: true };507 return { ok: true };508 });509 const canvasContext = {510 clearRect: vi.fn(),511 fillText: vi.fn(),512 beginPath: vi.fn(),513 closePath: vi.fn(),514 arc: vi.fn(),515 fill: vi.fn(),516 stroke: vi.fn(),517 save: vi.fn(),518 restore: vi.fn(),519 translate: vi.fn(),520 rotate: vi.fn(),521 scale: vi.fn(),522 drawImage: vi.fn(),523 measureText: vi.fn(() => ({ width: 0 })),524 setTransform: vi.fn(),525 fillRect: vi.fn(),526 strokeRect: vi.fn(),527 } as unknown as CanvasRenderingContext2D;528 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);529530 const onRecoveryComplete = vi.fn(async () => undefined);531 const container = mount(532 <RecoveryWordEntryView533 onCancel={vi.fn()}534 onRecoveryComplete={onRecoveryComplete}535 />536 );537538 const recoveryWords = WORD_LIST.slice(0, 12);539 for (let i = 0; i < recoveryWords.length; i++) {540 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;541 inputText(input, recoveryWords[i]!);542 }543 await flushView();544545 findButton(container, "verify words").click();546 await flushView();547548 expect(container.textContent).toContain("new master password");549550 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;551 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;552 inputText(newPassword, "fresh-recovery-password");553 inputText(confirmPassword, "fresh-recovery-password");554 await flushView();555556 findButton(container, "recover vault").click();557 await flushView();558559 expect(sendMessageMock).toHaveBeenCalledWith({560 type: "RECOVER_WITH_WORDS",561 payload: {562 words: recoveryWords,563 newPassword: "fresh-recovery-password",564 },565 });566 expect(onRecoveryComplete).toHaveBeenCalled();567 });568569 it("Export menu surfaces both backup formats", async () => {570 const container = mount(<ExportView onBack={vi.fn()} />);571 await flushView();572573 expect(container.textContent).toContain("PeachScript");574 expect(container.textContent).toContain("CSV");575 });576577 it("Lock can open directly on backup restore from onboarding", async () => {578 const container = mount(() => (579 <LockView580 hasVault={false}581 ready={true}582 onUnlock={vi.fn()}583 initialRestore={true}584 />585 ));586 await flushView();587588 expect(container.textContent).toContain("PeachScript");589 expect(container.textContent).toContain("Codex");590 expect(container.textContent).toContain("CSV");591 expect(container.querySelector("#master-password")).toBeNull();592 });593594 it("Lock restore back returns to onboarding, not the create-vault screen", async () => {595 const onBackToOnboarding = vi.fn();596 const container = mount(() => (597 <LockView598 hasVault={false}599 ready={true}600 onUnlock={vi.fn()}601 initialRestore={true}602 onBackToOnboarding={onBackToOnboarding}603 />604 ));605 await flushView();606607 (container.querySelector(".peach-restore-back") as HTMLButtonElement).click();608 await flushView();609 expect(onBackToOnboarding).toHaveBeenCalledTimes(1);610 });611612 it("Lock restore resumes on the picked PeachScript source, not the picker", async () => {613 const container = mount(() => (614 <LockView615 hasVault={false}616 ready={true}617 onUnlock={vi.fn()}618 initialRestore={true}619 initialRestoreSource="symbols"620 />621 ));622 await flushView();623624 expect(container.querySelector(".peach-restore-script")).toBeTruthy();625 expect(container.querySelector("#restore-backup-pass")).toBeTruthy();626 expect(container.querySelector(".peach-restore-sources")).toBeNull();627 });628629 it("Lock restore resumes on the picked CSV source, not the picker", async () => {630 const container = mount(() => (631 <LockView632 hasVault={false}633 ready={true}634 onUnlock={vi.fn()}635 initialRestore={true}636 initialRestoreSource="csv"637 />638 ));639 await flushView();640641 expect(container.querySelector("#restore-csv-pass")).toBeTruthy();642 expect(container.querySelector("#restore-csv-confirm")).toBeTruthy();643 expect(container.querySelector(".peach-restore-sources")).toBeNull();644 });645646 it("Lock restore PeachScript form loads a dropped .peach file", async () => {647 const container = mount(() => (648 <LockView649 hasVault={false}650 ready={true}651 onUnlock={vi.fn()}652 initialRestore={true}653 initialRestoreSource="symbols"654 />655 ));656 await flushView();657658 const form = container.querySelector(".peach-restore-form") as HTMLElement;659 expect(form).toBeTruthy();660661 662 663 const file = new File(["dropped-peach-backup"], "vault.peach", { type: "text/plain" });664 const drop = new Event("drop", { bubbles: true, cancelable: true }) as Event & {665 dataTransfer?: { files: File[] };666 };667 drop.dataTransfer = { files: [file] };668 form.dispatchEvent(drop);669 await flushView();670671 const script = container.querySelector(".peach-restore-script") as HTMLTextAreaElement;672 expect(script.value).toBe("dropped-peach-backup");673 });674675 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {676 const device = {677 deviceId: "phone-1",678 deviceName: "June's Phone",679 deviceType: "android" as const,680 longTermPublicKeyHex: "a".repeat(64),681 signingPublicKeyHex: "b".repeat(64),682 pairedAt: 1,683 trustSource: "direct" as const,684 };685 sendMessageMock.mockImplementation(async (msg: unknown) => {686 const m = msg as { type: string };687 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };688 return { ok: true, data: null };689 });690691 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);692 for (let i = 0; i < 30; i++) await Promise.resolve();693694 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>695 /sync/i.test(b.textContent ?? ""),696 );697 openSync?.click();698 for (let i = 0; i < 30; i++) await Promise.resolve();699700 const remove = Array.from(container.querySelectorAll("button")).find(701 (b) => (b.textContent ?? "").trim() === "remove",702 );703 expect(remove).toBeTruthy();704705 706 707 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();708709 remove!.click();710 for (let i = 0; i < 10; i++) await Promise.resolve();711712 713 714 const note = container.querySelector(".peach-unpair-confirm-note");715 expect(note).toBeTruthy();716 const text = note!.textContent ?? "";717 expect(text).toContain("master password");718 expect(text).toContain("recovery words");719 expect(text).toContain("future changes");720 });721722 it("PB-26-709: pending device approval identifies the key, time, provenance, and vault scope", async () => {723 sendMessageMock.mockImplementation(async (msg: unknown) => {724 const m = msg as { type: string };725 if (m.type === "GET_PENDING_VOUCHED_DEVICES") {726 return {727 ok: true,728 data: [{729 deviceId: "phone-source-id",730 deviceName: "June's Phone",731 deviceType: "android",732 longTermPublicKeyHex: "a1b2c3d4e5f6" + "0".repeat(52),733 signingPublicKeyHex: "b".repeat(64),734 pairedAt: Date.UTC(2026, 8, 5),735 receivedAt: Date.UTC(2026, 8, 6, 20, 15),736 vouchedByDeviceId: "trusted-browser-id",737 }],738 };739 }740 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [] } };741 return { ok: true, data: null };742 });743744 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);745 for (let i = 0; i < 30; i++) await Promise.resolve();746 Array.from(container.querySelectorAll("button"))747 .find((button) => /sync/i.test(button.textContent ?? ""))748 ?.click();749 for (let i = 0; i < 30; i++) await Promise.resolve();750751 expect(container.textContent).toContain("a1b2 c3d4 e5f6");752 expect(container.textContent).toContain("trusted-browser-id");753 expect(container.textContent).toContain("2026-09-06");754 expect(container.textContent).toContain("existing vault contents");755 });756757 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {758 const device = {759 deviceId: "phone-1",760 deviceName: "June's Phone",761 deviceType: "android" as const,762 longTermPublicKeyHex: "a".repeat(64),763 signingPublicKeyHex: "b".repeat(64),764 pairedAt: 1,765 trustSource: "direct" as const,766 };767 let paired: typeof device[] = [device];768 sendMessageMock.mockImplementation(async (msg: any) => {769 if (msg?.type === "LIST_PAIRED_DEVICES") {770 return { ok: true, data: { devices: [...paired] } };771 }772 if (msg?.type === "UNPAIR_DEVICE") {773 774 775 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);776 return { ok: true };777 }778 if (msg?.type === "RENAME_PAIRED_DEVICE") {779 paired = paired.map((d) =>780 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,781 );782 return { ok: true };783 }784 return { ok: true, data: null };785 });786787 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);788 for (let i = 0; i < 30; i++) await Promise.resolve();789 Array.from(container.querySelectorAll("button"))790 .find((b) => /sync/i.test(b.textContent ?? ""))791 ?.click();792 for (let i = 0; i < 30; i++) await Promise.resolve();793794 795 expect(container.textContent).toContain("PAIRED DEVICES");796 expect(container.textContent).toContain("June's Phone");797 expect(container.textContent).toContain("Android");798 Array.from(container.querySelectorAll("button"))799 .find((b) => (b.textContent ?? "").trim() === "rename")!800 .click();801 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;802 renameInput.value = "Travel phone";803 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));804 Array.from(container.querySelectorAll("button"))805 .find((b) => (b.textContent ?? "").trim() === "save")!806 .click();807 for (let i = 0; i < 30; i++) await Promise.resolve();808 expect(sendMessageMock).toHaveBeenCalledWith({809 type: "RENAME_PAIRED_DEVICE",810 payload: { deviceId: "phone-1", name: "Travel phone" },811 });812 expect(container.textContent).toContain("Travel phone");813 const syncNowButton = container.querySelector<HTMLButtonElement>(814 'button[aria-label="Sync this vault now"]',815 )!;816 const syncStatusRow = syncNowButton.closest(".peach-settings-row--meta");817 const syncCard = syncNowButton.closest('[data-settings-card-id="sync"]');818 expect(syncStatusRow).not.toBeNull();819 expect(syncStatusRow?.contains(syncNowButton)).toBe(true);820 expect(syncCard?.querySelectorAll(".peach-settings-row--meta")).toHaveLength(1);821 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));822 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;823 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;824 expect(825 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,826 ).toBeTruthy();827 expect(828 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,829 ).toBeTruthy();830831 Array.from(container.querySelectorAll("button"))832 .find((b) => (b.textContent ?? "").trim() === "remove")!833 .click();834 for (let i = 0; i < 10; i++) await Promise.resolve();835 const confirm = Array.from(836 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),837 ).find((b) => (b.textContent ?? "").trim() === "remove");838 confirm!.click();839 for (let i = 0; i < 30; i++) await Promise.resolve();840841 expect(sendMessageMock).toHaveBeenCalledWith({842 type: "UNPAIR_DEVICE",843 payload: { deviceId: "phone-1" },844 });845 846 847 848 expect(container.textContent).not.toContain("June's Phone");849 expect(container.textContent).not.toContain("PAIRED DEVICES");850 });851 it("Settings keeps the confirmation and shows revoke failures", async () => {852 const device = {853 deviceId: "phone-1",854 deviceName: "June's Phone",855 deviceType: "android" as const,856 longTermPublicKeyHex: "a".repeat(64),857 signingPublicKeyHex: "b".repeat(64),858 pairedAt: 1,859 trustSource: "direct" as const,860 };861 sendMessageMock.mockImplementation(async (msg: unknown) => {862 const message = msg as { type?: string; payload?: { deviceId?: string } };863 if (message.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };864 if (message.type === "UNPAIR_DEVICE") return { ok: false, error: "trust_data_corrupted" };865 return { ok: true, data: null };866 });867868 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);869 for (let i = 0; i < 30; i++) await Promise.resolve();870 for (let i = 0; i < 30; i++) await Promise.resolve();871 Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();872 for (let i = 0; i < 30; i++) await Promise.resolve();873 Array.from(container.querySelectorAll("button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();874 for (let i = 0; i < 10; i++) await Promise.resolve();875 Array.from(container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button")).find((b) => (b.textContent ?? "").trim() === "remove")?.click();876 for (let i = 0; i < 30; i++) await Promise.resolve();877878 expect(container.textContent).toContain("trust_data_corrupted");879 expect(container.textContent).toContain("June's Phone");880 });881882 it("Settings drops the secure-reset panel once the device is on the settled generation", async () => {883 const openSyncCard = async (state: "V2_ACTIVE" | "V2_ANCHOR" | "LEGACY_ACTIVE") => {884 sendMessageMock.mockImplementation(async (msg: unknown) => {885 const message = msg as { type?: string };886 if (message.type === "GET_SYNC_GENERATION") {887 return { ok: true, data: { schemaVersion: 1, generation: 2, state, createdAt: 1 } };888 }889 return { ok: true, data: null };890 });891 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);892 for (let i = 0; i < 30; i++) await Promise.resolve();893 Array.from(container.querySelectorAll("button")).find((b) => /sync/i.test(b.textContent ?? ""))?.click();894 for (let i = 0; i < 30; i++) await Promise.resolve();895 return container;896 };897898 899 const settled = await openSyncCard("V2_ACTIVE");900 expect(settled.textContent).not.toContain("SECURE SYNC RESET");901 expect(settled.textContent).toContain("ADD A DEVICE");902903 904 const anchored = await openSyncCard("V2_ANCHOR");905 expect(anchored.textContent).toContain("SECURE SYNC RESET");906 expect(anchored.textContent).toContain("Not re-paired");907908 const legacy = await openSyncCard("LEGACY_ACTIVE");909 expect(legacy.textContent).toContain("SECURE SYNC RESET");910 expect(legacy.textContent).toContain("Legacy Sync generation");911 });912913 it("Settings keeps connection and recovery details collapsed until requested", async () => {914 sendMessageMock.mockImplementation(async (msg: any) => {915 if (msg?.type === "LIST_PAIRED_DEVICES") {916 return { ok: true, data: { devices: [] } };917 }918 return { ok: true, data: null };919 });920921 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);922 for (let i = 0; i < 30; i++) await Promise.resolve();923 Array.from(container.querySelectorAll("button"))924 .find((button) => /sync/i.test(button.textContent ?? ""))925 ?.click();926 for (let i = 0; i < 20; i++) await Promise.resolve();927928 expect(container.textContent).toContain("SYNC STATUS");929 expect(container.textContent).toContain("ADD A DEVICE");930 expect(container.textContent).not.toContain("Public relay");931 expect(container.textContent).not.toContain("Rotate sync identity");932933 const connection = Array.from(container.querySelectorAll("button"))934 .find((button) => button.textContent?.includes("CONNECTION"));935 connection?.click();936 await Promise.resolve();937 expect(container.textContent).toContain("Public relay");938939 const recovery = Array.from(container.querySelectorAll("button"))940 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));941 recovery?.click();942 await Promise.resolve();943 expect(container.textContent).toContain("Rotate sync identity");944 });945946 it("Settings requires current, new, and confirm passwords before enabling save", async () => {947 sendMessageMock.mockResolvedValue({ ok: true, data: null });948 const container = mount(949 <SettingsView950 entriesCount={1}951 breachMonitoring={true}952 lockOnClose={true}953 lockedSyncRequiresUnlock={false}954 autoLockMinutes={5}955 trashRetentionDays={30}956 theme="dark"957 accentColor="peach"958 textSize="medium"959 reduceMotion={false}960 onBreachMonitoringChange={vi.fn()}961 onLockedSyncRequiresUnlockChange={vi.fn()}962 onLockOnCloseChange={vi.fn()}963 onAutoLockMinutesChange={vi.fn()}964 onTrashRetentionChange={vi.fn()}965 onThemeChange={vi.fn()}966 onAccentChange={vi.fn()}967 onTextSizeChange={vi.fn()}968 onReduceMotionChange={vi.fn()}969 shortcuts={DEFAULT_BINDINGS}970 onShortcutsChange={vi.fn()}971 onBack={vi.fn()}972 onImport={vi.fn()}973 onExport={vi.fn()}974 onBreachCheckDone={vi.fn()}975 />976 );977 await flushView();978979 expect(container.querySelectorAll(".stk-card").length).toBe(7);980 981 982 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");983984 const changeButton = Array.from(container.querySelectorAll("button")).find(985 (candidate) => candidate.textContent?.trim() === "change"986 ) as HTMLButtonElement;987 changeButton.click();988 await flushView();989990 const saveButton = Array.from(container.querySelectorAll("button")).find(991 (candidate) => candidate.textContent?.trim() === "save new password"992 ) as HTMLButtonElement;993 expect(saveButton.disabled).toBe(true);994995 const inputs = container.querySelectorAll('input[type="password"]');996 inputText(inputs[0] as HTMLInputElement, "current-pass");997 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");998 await flushView();999 expect(saveButton.disabled).toBe(true);10001001 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");1002 await flushView();1003 expect(saveButton.disabled).toBe(false);1004 });10051006 it("Settings feedback highlights the selected category and hides the form after submit", async () => {1007 sendMessageMock.mockResolvedValue({ ok: true, data: null });1008 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1009 ok: true,1010 json: async () => ({ success: true }),1011 }));1012 vi.stubGlobal("fetch", fetchMock);10131014 const container = mount(1015 <SettingsView1016 entriesCount={1}1017 breachMonitoring={true}1018 lockOnClose={true}1019 lockedSyncRequiresUnlock={false}1020 autoLockMinutes={5}1021 trashRetentionDays={30}1022 theme="dark"1023 accentColor="peach"1024 textSize="medium"1025 reduceMotion={false}1026 initialSection="feedback"1027 onLockedSyncRequiresUnlockChange={vi.fn()}1028 onBreachMonitoringChange={vi.fn()}1029 onLockOnCloseChange={vi.fn()}1030 onAutoLockMinutesChange={vi.fn()}1031 onTrashRetentionChange={vi.fn()}1032 onThemeChange={vi.fn()}1033 onAccentChange={vi.fn()}1034 onTextSizeChange={vi.fn()}1035 onReduceMotionChange={vi.fn()}1036 shortcuts={DEFAULT_BINDINGS}1037 onShortcutsChange={vi.fn()}1038 onBack={vi.fn()}1039 onImport={vi.fn()}1040 onExport={vi.fn()}1041 onBreachCheckDone={vi.fn()}1042 />1043 );1044 await flushView();10451046 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;1047 expect(feedbackCard.classList.contains("is-active")).toBe(true);10481049 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];1050 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;1051 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;1052 expect(bugButton.getAttribute("aria-pressed")).toBe("true");1053 expect(featureButton.getAttribute("aria-pressed")).toBe("false");10541055 featureButton.click();1056 await flushView();10571058 expect(bugButton.getAttribute("aria-pressed")).toBe("false");1059 expect(featureButton.getAttribute("aria-pressed")).toBe("true");1060 expect(featureButton.classList.contains("is-active")).toBe(true);10611062 inputText(1063 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1064 '<script>alert("xss")</script> Please make this easier to see & safer.'1065 );1066 const sendButton = Array.from(container.querySelectorAll("button")).find(1067 (candidate) => candidate.textContent?.trim() === "Send"1068 ) as HTMLButtonElement;1069 sendButton.click();10701071 await vi.waitFor(() => {1072 expect(container.textContent).toContain("Thank you");1073 });10741075 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));1076 expect(requestBody.subject).toContain("Feature request");1077 expect(requestBody.message).toBe(1078 "<script>alert("xss")</script> Please make this easier to see & safer."1079 );1080 expect(requestBody.message).not.toContain("<script>");1081 expect(container.textContent).toContain("Someone will process it soon");1082 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1083 expect(container.querySelector(".peach-feedback-submit")).toBeNull();1084 });10851086 it("Settings keeps license activation on the full-screen celebration path", async () => {1087 const onLicenseChange = vi.fn(async () => undefined);1088 sendMessageMock.mockImplementation(async (msg: unknown) => {1089 const type = (msg as { type?: string }).type;1090 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };1091 if (type === "ACTIVATE_LICENSE") {1092 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1093 }1094 if (type === "GET_ENTRIES") return { ok: true, data: [] };1095 return { ok: true, data: null };1096 });10971098 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);1099 await flushView();11001101 inputText(1102 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,1103 "PEACH-TEST-KEY",1104 );1105 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;1106 apply.click();11071108 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));1109 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);1110 expect(container.textContent).not.toContain("thank you ✦");1111 });11121113 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {1114 sendMessageMock.mockImplementation(async (msg: unknown) => {1115 const type = (msg as { type?: string }).type;1116 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };1117 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };1118 if (type === "GET_ENTRIES") return { ok: true, data: [] };1119 return { ok: true, data: null };1120 });11211122 const onLicenseChangeFromStar = vi.fn();1123 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);1124 await flushView();1125 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();1126 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1127 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();11281129 const onLicenseChangeFromPeach = vi.fn();1130 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);1131 await flushView();1132 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();1133 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);1134 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();1135 });11361137 it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {1138 sendMessageMock.mockResolvedValue({ ok: true, data: null });1139 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({1140 ok: true,1141 json: async () => ({ success: true }),1142 }));1143 vi.stubGlobal("fetch", fetchMock);1144 1145 1146 1147 vi.stubGlobal("browser", {1148 permissions: {1149 getAll: vi.fn(async () => ({ data_collection: [] })),1150 request: vi.fn(async () => false),1151 },1152 });11531154 const container = mount(1155 <SettingsView1156 entriesCount={1}1157 breachMonitoring={true}1158 lockOnClose={true}1159 lockedSyncRequiresUnlock={false}1160 autoLockMinutes={5}1161 trashRetentionDays={30}1162 theme="dark"1163 accentColor="peach"1164 textSize="medium"1165 reduceMotion={false}1166 initialSection="feedback"1167 onLockedSyncRequiresUnlockChange={vi.fn()}1168 onBreachMonitoringChange={vi.fn()}1169 onLockOnCloseChange={vi.fn()}1170 onAutoLockMinutesChange={vi.fn()}1171 onTrashRetentionChange={vi.fn()}1172 onThemeChange={vi.fn()}1173 onAccentChange={vi.fn()}1174 onTextSizeChange={vi.fn()}1175 onReduceMotionChange={vi.fn()}1176 shortcuts={DEFAULT_BINDINGS}1177 onShortcutsChange={vi.fn()}1178 onBack={vi.fn()}1179 onImport={vi.fn()}1180 onExport={vi.fn()}1181 onBreachCheckDone={vi.fn()}1182 />1183 );1184 await flushView();11851186 inputText(1187 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,1188 "Please improve the feedback flow."1189 );1190 const sendButton = Array.from(container.querySelectorAll("button")).find(1191 (candidate) => candidate.textContent?.trim() === "Send"1192 ) as HTMLButtonElement;1193 sendButton.click();11941195 await vi.waitFor(() => {1196 expect(fetchMock).toHaveBeenCalledTimes(1);1197 });1198 await vi.waitFor(() => {1199 expect(container.textContent).toContain("Thank you");1200 });1201 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1202 });12031204 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1205 sendMessageMock.mockImplementation(async (msg: unknown) => {1206 const m = msg as { type: string };1207 if (m.type === "GET_RECOVERY_WORDS") {1208 return {1209 ok: true,1210 data: [1211 "abandon", "ability", "able", "about", "above", "absent",1212 "absorb", "abstract", "absurd", "abuse", "access", "accident",1213 ],1214 };1215 }1216 return { ok: true, data: null };1217 });1218 const onAutoLockMinutesChange = vi.fn();1219 const onLockedSyncRequiresUnlockChange = vi.fn();1220 const onThemeChange = vi.fn();1221 const container = mount(1222 <SettingsView1223 entriesCount={1}1224 breachMonitoring={true}1225 lockOnClose={true}1226 lockedSyncRequiresUnlock={false}1227 autoLockMinutes={5}1228 trashRetentionDays={30}1229 theme="dark"1230 accentColor="peach"1231 textSize="medium"1232 reduceMotion={false}1233 onBreachMonitoringChange={vi.fn()}1234 onLockedSyncRequiresUnlockChange={onLockedSyncRequiresUnlockChange}1235 onLockOnCloseChange={vi.fn()}1236 onAutoLockMinutesChange={onAutoLockMinutesChange}1237 onTrashRetentionChange={vi.fn()}1238 onThemeChange={onThemeChange}1239 onAccentChange={vi.fn()}1240 onTextSizeChange={vi.fn()}1241 onReduceMotionChange={vi.fn()}1242 shortcuts={DEFAULT_BINDINGS}1243 onShortcutsChange={vi.fn()}1244 onBack={vi.fn()}1245 onImport={vi.fn()}1246 onExport={vi.fn()}1247 onBreachCheckDone={vi.fn()}1248 />1249 );1250 await flushView();1251 sendMessageMock.mockClear();12521253 1254 const selects = container.querySelectorAll("select");1255 changeSelect(selects[0] as HTMLSelectElement, "15");1256 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);12571258 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1259 appearanceCard.click();1260 await flushView();1261 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1262 const themeButton = Array.from(container.querySelectorAll("button")).find(1263 (candidate) => candidate.textContent?.trim() === "Switch to light"1264 ) as HTMLButtonElement;1265 themeButton.click();1266 expect(onThemeChange).toHaveBeenCalledWith("light");12671268 1269 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1270 protectionCard.click();1271 await flushView();1272 const custodyToggle = container.querySelector<HTMLButtonElement>(1273 'button[aria-label="Toggle unlock requirement for locked Sync after restart"]',1274 );1275 expect(custodyToggle).not.toBeNull();1276 custodyToggle!.click();1277 expect(onLockedSyncRequiresUnlockChange).toHaveBeenCalledWith(true);12781279 const changeButton = Array.from(container.querySelectorAll("button")).find(1280 (candidate) => candidate.textContent?.trim() === "change"1281 ) as HTMLButtonElement;1282 changeButton.click();1283 await flushView();12841285 const inputs = container.querySelectorAll('input[type="password"]');1286 inputText(inputs[0] as HTMLInputElement, "current-pass");1287 inputText(inputs[1] as HTMLInputElement, "new-password-123");1288 inputText(inputs[2] as HTMLInputElement, "new-password-123");1289 await flushView();12901291 const saveButton = Array.from(container.querySelectorAll("button")).find(1292 (candidate) => candidate.textContent?.trim() === "save new password"1293 ) as HTMLButtonElement;1294 saveButton.click();1295 await flushView();12961297 expect(sendMessageMock).toHaveBeenCalledWith({1298 type: "CHANGE_PASSWORD",1299 payload: {1300 currentPassword: "current-pass",1301 newPassword: "new-password-123",1302 },1303 });13041305 1306 expect(container.textContent).toContain("save your recovery words");1307 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1308 });13091310 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1311 sendMessageMock.mockResolvedValue({ ok: true });1312 const container = mount(1313 <EntryView1314 entry={sampleEntry()}1315 onDelete={vi.fn()}1316 onFieldSaved={vi.fn(async () => undefined)}1317 />1318 );13191320 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1321 passwordButton.click();1322 await flushView();13231324 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1325 type: "COPY",1326 payload: { text: "secret123", label: "password", alreadyWritten: false },1327 });1328 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1329 type: "FIRE_CANARY_PING",1330 payload: {1331 webhookUrl: "https://example.com/hook",1332 entryTitle: "GitHub",1333 trigger: "password_copied",1334 },1335 });13361337 sendMessageMock.mockClear();1338 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1339 usernameButton.click();1340 await flushView();13411342 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1343 type: "COPY",1344 payload: { text: "june", label: "username", alreadyWritten: false },1345 });1346 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1347 type: "FIRE_CANARY_PING",1348 payload: {1349 webhookUrl: "https://example.com/hook",1350 entryTitle: "GitHub",1351 trigger: "username_copied",1352 },1353 });1354 });13551356 it("Entry keyboard password copy reports its canary after COPY", async () => {1357 sendMessageMock.mockResolvedValue({ ok: true });1358 mount(1359 <EntryView1360 entry={sampleEntry()}1361 onDelete={vi.fn()}1362 onFieldSaved={vi.fn(async () => undefined)}1363 />1364 );13651366 window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", bubbles: true }));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 });1383 it("Entry Cmd+C leaves the native clipboard shortcut untouched", async () => {1384 sendMessageMock.mockResolvedValue({ ok: true });1385 mount(1386 <EntryView1387 entry={sampleEntry()}1388 onDelete={vi.fn()}1389 onFieldSaved={vi.fn(async () => undefined)}1390 />1391 );13921393 window.dispatchEvent(new KeyboardEvent("keydown", { key: "c", metaKey: true, bubbles: true }));1394 await flushView();13951396 expect(sendMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ type: "COPY" }));1397 });13981399 it("Vault password swipe copy reports its canary after COPY", async () => {1400 sendMessageMock.mockResolvedValue({ ok: true });1401 const container = mount(1402 <VaultView1403 entries={[sampleEntry()]}1404 sortOrder="url-first"1405 onSelectEntry={vi.fn()}1406 onSortOrderChange={vi.fn()}1407 onAdd={vi.fn()}1408 onClose={vi.fn()}1409 />1410 );1411 const row = container.querySelector(".peach-entry-row") as HTMLButtonElement;1412 row.dispatchEvent(new TouchEvent("touchstart", {1413 bubbles: true,1414 touches: [{ clientX: 0, clientY: 0 } as Touch],1415 }));1416 row.dispatchEvent(new TouchEvent("touchend", {1417 bubbles: true,1418 changedTouches: [{ clientX: 100, clientY: 0 } as Touch],1419 }));1420 await flushView();14211422 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1423 type: "COPY",1424 payload: { text: "secret123", label: "password", alreadyWritten: false },1425 });1426 await flushView();1427 expect(sendMessageMock.mock.calls).toContainEqual([{1428 type: "FIRE_CANARY_PING",1429 payload: {1430 webhookUrl: "https://example.com/hook",1431 entryTitle: "GitHub",1432 trigger: "password_copied",1433 },1434 }]);1435 });14361437 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1438 const container = mount(1439 <EntryView1440 entry={sampleEntry({1441 type: "passkey",1442 username: undefined,1443 password: undefined,1444 totp: undefined,1445 canary: undefined,1446 passkey: {1447 rpId: "vercel.com",1448 rpName: "Vercel",1449 credentialId: "cred-1",1450 publicKeyCose: "cose",1451 userHandle: "handle",1452 userName: "june",1453 userDisplayName: "June",1454 algorithm: -7,1455 signCount: 0,1456 aaguid: "",1457 backupEligible: false,1458 backupState: false,1459 createdAt: 1,1460 },1461 })}1462 onDelete={vi.fn()}1463 onFieldSaved={vi.fn(async () => undefined)}1464 />1465 );14661467 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1468 (button) => button.textContent?.includes("Advanced")1469 ) as HTMLButtonElement | undefined;1470 expect(advanced).toBeDefined();1471 expect(advanced?.textContent).toContain("+ Advanced");14721473 advanced!.click();1474 await flushView();14751476 expect(container.textContent).toContain("passkey · vercel.com");1477 });14781479 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1480 sendMessageMock.mockResolvedValue({ ok: true });1481 const onFieldSaved = vi.fn(async () => undefined);1482 const container = mount(1483 <EntryView1484 entry={sampleEntry()}1485 onDelete={vi.fn()}1486 onFieldSaved={onFieldSaved}1487 />1488 );14891490 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1491 passwordValue.click();1492 await flushView();14931494 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1495 inputText(passwordInput, "");1496 await flushView();14971498 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1499 saveButton.click();1500 await flushView();15011502 expect(sendMessageMock).not.toHaveBeenCalledWith(1503 expect.objectContaining({ type: "UPDATE_ENTRY" })1504 );1505 expect(onFieldSaved).not.toHaveBeenCalled();15061507 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1508 totpMain.click();15091510 await vi.waitFor(() => {1511 expect(sendMessageMock).toHaveBeenLastCalledWith({1512 type: "COPY",1513 payload: { text: "123456", label: "totp", alreadyWritten: false },1514 });1515 }, { timeout: 500 });1516 });15171518 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1519 sendMessageMock.mockResolvedValue({ ok: true });1520 const container = mount(1521 <EntryView1522 entry={sampleEntry()}1523 onDelete={vi.fn()}1524 onFieldSaved={vi.fn(async () => undefined)}1525 />1526 );15271528 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1529 usernameValue.click();1530 await flushView();15311532 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1533 usernameInput.blur();1534 await flushView();15351536 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1537 expect(sendMessageMock).not.toHaveBeenCalledWith(1538 expect.objectContaining({ type: "UPDATE_ENTRY" })1539 );1540 });15411542 it("Entry keeps the full used-email list available while editing an email", async () => {1543 const container = mount(1544 <EntryView1545 entry={sampleEntry({ email: "[email protected]" })}1546 entries={[1547 sampleEntry({ email: "[email protected]" }),1548 sampleEntry({ id: "entry-2", email: "[email protected]" }),1549 sampleEntry({ id: "entry-3", username: "duckuccino" }),1550 ]}1551 onDelete={vi.fn()}1552 onFieldSaved={vi.fn(async () => undefined)}1553 />1554 );15551556 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1557 (card) => card.querySelector(".peach-label")?.textContent === "email"1558 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1559 emailValue.click();1560 await flushView();15611562 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1563 .map((element) => element.textContent);1564 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1565 });15661567 it("Entry lets TOTP secrets be edited inline", async () => {1568 sendMessageMock.mockResolvedValue({ ok: true });1569 const onFieldSaved = vi.fn(async () => undefined);1570 const container = mount(1571 <EntryView1572 entry={sampleEntry()}1573 onDelete={vi.fn()}1574 onFieldSaved={onFieldSaved}1575 />1576 );15771578 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1579 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1580 await flushView();15811582 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1583 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1584 await flushView();15851586 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1587 saveButton.click();1588 await flushView();15891590 expect(sendMessageMock).toHaveBeenCalledWith({1591 type: "UPDATE_ENTRY",1592 payload: {1593 id: "entry-1",1594 patch: { totp: "NB2W45DFOIZA====" },1595 },1596 });1597 expect(onFieldSaved).toHaveBeenCalled();1598 });15991600 it("Vault seeds and focuses search when a search seed is provided", async () => {1601 const container = mount(1602 <VaultView1603 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1604 sortOrder="url-first"1605 searchSeed="g"1606 searchSeedNonce={1}1607 onSelectEntry={vi.fn()}1608 onSortOrderChange={vi.fn()}1609 onAdd={vi.fn()}1610 onClose={vi.fn()}1611 />1612 );16131614 await flushView();16151616 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1617 expect(search.value).toBe("g");1618 expect(document.activeElement).toBe(search);1619 });16201621 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1622 parseImportSourceMock.mockResolvedValue(1623 sampleImportResult({1624 entries: [1625 {1626 type: "login",1627 title: "Imported site",1628 username: "imported-user",1629 password: "imported-pass",1630 url: "https://imported.test",1631 },1632 ],1633 })1634 );1635 sendMessageMock.mockImplementation(async (msg: unknown) => {1636 const type = (msg as { type?: string }).type;1637 if (type === "GET_ENTRIES") {1638 return {1639 ok: true,1640 data: [1641 sampleEntry({1642 id: "existing-1",1643 title: "Imported site",1644 username: "imported-user",1645 url: "https://imported.test",1646 }),1647 ],1648 };1649 }1650 if (type === "BATCH_ADD_ENTRIES") {1651 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1652 }1653 return { ok: true };1654 });1655 const onImport = vi.fn(async () => undefined);1656 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1657 await flushView();1658 sendMessageMock.mockClear();16591660 await selectImportFile(container);1661 findButton(container, "Import 1").click();1662 await flushView();16631664 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}');1665 expect(sendMessageMock).toHaveBeenCalledWith({1666 type: "BATCH_ADD_ENTRIES",1667 payload: {1668 entries: [1669 expect.objectContaining({1670 title: "Imported site",1671 username: "imported-user",1672 password: "imported-pass",1673 url: "https://imported.test",1674 }),1675 ],1676 },1677 });1678 expect(onImport).toHaveBeenCalled();1679 expect(container.textContent).toContain("1 entry appears to be duplicates");1680 });16811682 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1683 parseImportSourceMock.mockResolvedValue(sampleImportResult());1684 sendMessageMock.mockImplementation(async (msg: unknown) => {1685 const type = (msg as { type?: string }).type;1686 if (type === "GET_ENTRIES") {1687 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1688 }1689 if (type === "CLEAR_VAULT_ENTRIES") {1690 return { ok: true };1691 }1692 if (type === "BATCH_ADD_ENTRIES") {1693 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1694 }1695 return { ok: true };1696 });1697 const onImport = vi.fn(async () => undefined);1698 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1699 await flushView();1700 sendMessageMock.mockClear();17011702 await selectImportFile(container);1703 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1704 await flushView();17051706 findButton(container, "Replace vault with 1").click();1707 await flushView();17081709 expect(sendMessageMock).not.toHaveBeenCalledWith(1710 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1711 );1712 expect(container.textContent).toContain("Replace forever? This cannot be undone.");17131714 findButton(container, "Yes, replace forever").click();1715 await flushView();17161717 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1718 expect(sendMessageMock).toHaveBeenCalledWith({1719 type: "BATCH_ADD_ENTRIES",1720 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1721 });1722 expect(onImport).toHaveBeenCalled();1723 });17241725 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1726 parseImportSourceMock.mockResolvedValue(sampleImportResult());1727 sendMessageMock.mockImplementation(async (msg: unknown) => {1728 const type = (msg as { type?: string }).type;1729 if (type === "CREATE_VAULT") return { ok: true };1730 if (type === "BATCH_ADD_ENTRIES") {1731 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1732 }1733 return { ok: true };1734 });1735 const onImport = vi.fn(async () => undefined);1736 const container = mount(1737 <ImportView1738 mode="onboarding"1739 replacesExistingVault={true}1740 onBack={vi.fn()}1741 onImport={onImport}1742 />1743 );17441745 await selectImportFile(container);17461747 const createButton = findButton(container, "Create vault + import 1");1748 expect(createButton.disabled).toBe(true);1749 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1750 expect(container.textContent).toContain("Use at least 8 characters");17511752 const masterInput = container.querySelector(1753 'input[placeholder="new master password"]'1754 ) as HTMLInputElement;1755 const confirmInput = container.querySelector(1756 'input[placeholder="confirm master password"]'1757 ) as HTMLInputElement;1758 const revealButton = container.querySelector(1759 'button[aria-label="Show passwords"]'1760 ) as HTMLButtonElement;1761 expect(revealButton).toBeTruthy();1762 revealButton.click();1763 await flushView();1764 expect(masterInput.type).toBe("text");1765 expect(confirmInput.type).toBe("text");17661767 inputText(masterInput, "fresh-master-password");1768 inputText(confirmInput, "fresh-master-password");1769 await flushView();17701771 expect(createButton.disabled).toBe(false);1772 createButton.click();1773 await flushView();17741775 expect(sendMessageMock).toHaveBeenCalledWith({1776 type: "CREATE_VAULT",1777 payload: { password: "fresh-master-password" },1778 });1779 expect(sendMessageMock).toHaveBeenCalledWith({1780 type: "BATCH_ADD_ENTRIES",1781 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1782 });1783 expect(onImport).toHaveBeenCalled();1784 });17851786});