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 { RecoveryWordEntryView } from "./RecoveryWordEntry";16import { VaultView } from "./Vault";17import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1819const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();20const triggerRingMock = vi.fn();21const triggerPipPulseMock = vi.fn();22const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();23const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2425vi.mock("../App", () => ({26 sendMessage: (msg: unknown) => sendMessageMock(msg),27 triggerRing: () => triggerRingMock(),28 triggerPipPulse: () => triggerPipPulseMock(),29}));3031vi.mock("../../shared/import", () => ({32 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),33}));3435vi.mock("../../shared/favicon", async (importOriginal) => {36 const actual = await importOriginal<typeof import("../../shared/favicon")>();37 return { ...actual, faviconUrl: () => null };38});3940vi.mock("../../crypto/totp", () => ({41 generateTOTP: () => ({ code: "123 456", remaining: 24 }),42 parseOtpauthURI: (value: string) =>43 value.startsWith("otpauth://")44 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }45 : null,46}));4748vi.mock("../components/GeneratorDrawer", () => ({49 GeneratorDrawer: () => null,50}));5152vi.mock("@zxcvbn-ts/core", () => ({53 zxcvbn: (value: string) => zxcvbnMock(value),54 zxcvbnOptions: { setOptions: vi.fn() },55}));56vi.mock("@zxcvbn-ts/language-common", () => ({57 adjacencyGraphs: {},58 dictionary: {},59}));60vi.mock("@zxcvbn-ts/language-en", () => ({61 translations: {},62 dictionary: {},63}));6465describe("popup contract tests", () => {66 const disposers: Array<() => void> = [];6768 beforeEach(() => {69 document.body.innerHTML = "";70 sendMessageMock.mockReset();71 triggerRingMock.mockReset();72 triggerPipPulseMock.mockReset();73 parseImportSourceMock.mockReset();74 zxcvbnMock.mockReset();75 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));76 parseImportSourceMock.mockResolvedValue({77 format: "unknown",78 label: "Unknown format",79 entries: [],80 totalItems: 0,81 skippedItems: 0,82 requiresPassword: false,83 });8485 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {86 return window.setTimeout(() => cb(0), 0) as unknown as number;87 });88 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));89 vi.stubGlobal("URL", {90 createObjectURL: vi.fn(() => "blob:peach-test"),91 revokeObjectURL: vi.fn(),92 });93 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9495 (globalThis as { chrome?: typeof chrome }).chrome = {96 runtime: {97 getManifest: () => ({ version: "1.4.47" }),98 onMessage: { addListener: vi.fn() },99 },100 } as unknown as typeof chrome;101 });102103 afterEach(() => {104 while (disposers.length > 0) {105 disposers.pop()?.();106 }107 vi.restoreAllMocks();108 vi.unstubAllGlobals();109 });110111 function mount(view: JSX.Element | (() => JSX.Element)) {112 const mounted = renderView(view);113 disposers.push(mounted.dispose);114 return mounted.container;115 }116117 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {118 return {119 id: "entry-1",120 type: "login",121 title: "GitHub",122 username: "june",123 password: "secret123",124 url: "https://github.com",125 totp: "JBSWY3DPEHPK3PXP",126 canary: {127 webhookUrl: "https://example.com/hook",128 },129 createdAt: 1,130 modifiedAt: 1,131 ...overrides,132 };133 }134135 function sampleImportResult(136 overrides: Partial<ImportParseResult> = {}137 ): ImportParseResult {138 return {139 format: "csv",140 label: "CSV import",141 entries: [142 {143 type: "login",144 title: "Imported site",145 username: "imported-user",146 password: "imported-pass",147 url: "https://imported.test",148 },149 ],150 totalItems: 1,151 skippedItems: 0,152 requiresPassword: false,153 ...overrides,154 };155 }156157 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {158 return {159 entriesCount: 1,160 breachMonitoring: true,161 lockOnClose: true,162 autoLockMinutes: 5,163 trashRetentionDays: 30,164 theme: "dark" as const,165 accentColor: "peach" as const,166 textSize: "medium" as const,167 reduceMotion: false,168 onBreachMonitoringChange: vi.fn(),169 onLockOnCloseChange: vi.fn(),170 onAutoLockMinutesChange: vi.fn(),171 onTrashRetentionChange: vi.fn(),172 onThemeChange: vi.fn(),173 onAccentChange: vi.fn(),174 onTextSizeChange: vi.fn(),175 onReduceMotionChange: vi.fn(),176 shortcuts: DEFAULT_BINDINGS,177 onShortcutsChange: vi.fn(),178 onBack: vi.fn(),179 onImport: vi.fn(),180 onExport: vi.fn(),181 onBreachCheckDone: vi.fn(),182 ...overrides,183 };184 }185186 async function selectImportFile(187 container: HTMLElement,188 contents = '{"export":"test"}'189 ) {190 191 192 193 const fileCard = findButton(container, "Choose file →");194 if (fileCard) {195 fileCard.click();196 await flushView();197 }198 const input = container.querySelector('input[type="file"]') as HTMLInputElement;199 const file = new File([contents], "import.json", { type: "application/json" });200 Object.defineProperty(input, "files", {201 configurable: true,202 value: [file],203 });204 input.dispatchEvent(new Event("change", { bubbles: true }));205 await flushView();206 await flushView();207 }208209 function findButton(container: HTMLElement, text: string): HTMLButtonElement {210 return Array.from(container.querySelectorAll("button")).find(211 (candidate) => candidate.textContent?.trim() === text212 ) as HTMLButtonElement;213 }214215 it("Lock sends UNLOCK with the entered passphrase", async () => {216 sendMessageMock.mockResolvedValue({ ok: true });217 const onUnlock = vi.fn(async () => undefined);218 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);219220 const password = container.querySelector("#unlock-password") as HTMLInputElement;221 inputText(password, "correct horse battery staple");222 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();223 await flushView();224225 expect(sendMessageMock).toHaveBeenCalledWith({226 type: "UNLOCK",227 payload: { password: "correct horse battery staple" },228 });229 expect(onUnlock).toHaveBeenCalled();230 });231232 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {233 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);234 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;235 const password = container.querySelector("#master-password") as HTMLInputElement;236 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;237238 inputText(password, "short");239 inputText(confirm, "short");240 await flushView();241 expect(createButton.disabled).toBe(true);242243 zxcvbnMock.mockImplementation(() => ({ score: 1 }));244 inputText(password, "weakweakweak");245 inputText(confirm, "weakweakweak");246 await flushView();247 expect(createButton.disabled).toBe(true);248 });249250 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {251 252 sendMessageMock.mockImplementation(253 async (msg: unknown) => {254 const m = msg as { type: string; payload?: { password?: string } };255 if (m.type === "CREATE_VAULT") return { ok: true };256 if (m.type === "GET_RECOVERY_WORDS") {257 258 return {259 ok: true,260 data: [261 "abandon", "ability", "able", "about", "above", "absent",262 "absorb", "abstract", "absurd", "abuse", "access", "accident",263 ],264 };265 }266 return { ok: true };267 }268 );269 zxcvbnMock.mockImplementation(() => ({ score: 3 }));270 const onUnlock = vi.fn(async () => undefined);271 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);272273 const password = container.querySelector("#master-password") as HTMLInputElement;274 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;275 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;276277 inputText(password, "correct horse battery staple");278 inputText(confirm, "correct horse battery staple");279 await flushView();280 expect(createButton.disabled).toBe(false);281282 pressEnter(confirm);283 await flushView();284285 expect(sendMessageMock).toHaveBeenCalledWith({286 type: "CREATE_VAULT",287 payload: { password: "correct horse battery staple" },288 });289 290 291 expect(onUnlock).not.toHaveBeenCalled();292 });293294 it("Recovery words flow submits the new master password and completes", async () => {295 sendMessageMock.mockImplementation(async (msg: unknown) => {296 const type = (msg as { type?: string }).type;297 if (type === "VALIDATE_RECOVERY_WORDS") {298 return { ok: true, data: { valid: true, invalid: [] } };299 }300 if (type === "RECOVER_WITH_WORDS") return { ok: true };301 return { ok: true };302 });303 const canvasContext = {304 clearRect: vi.fn(),305 fillText: vi.fn(),306 beginPath: vi.fn(),307 closePath: vi.fn(),308 arc: vi.fn(),309 fill: vi.fn(),310 stroke: vi.fn(),311 save: vi.fn(),312 restore: vi.fn(),313 translate: vi.fn(),314 rotate: vi.fn(),315 scale: vi.fn(),316 drawImage: vi.fn(),317 measureText: vi.fn(() => ({ width: 0 })),318 setTransform: vi.fn(),319 fillRect: vi.fn(),320 strokeRect: vi.fn(),321 } as unknown as CanvasRenderingContext2D;322 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);323324 const onRecoveryComplete = vi.fn(async () => undefined);325 const container = mount(326 <RecoveryWordEntryView327 onCancel={vi.fn()}328 onRecoveryComplete={onRecoveryComplete}329 />330 );331332 const recoveryWords = WORD_LIST.slice(0, 12);333 for (let i = 0; i < recoveryWords.length; i++) {334 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;335 inputText(input, recoveryWords[i]!);336 }337 await flushView();338339 findButton(container, "verify words").click();340 await flushView();341342 expect(container.textContent).toContain("new master password");343344 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;345 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;346 inputText(newPassword, "fresh-recovery-password");347 inputText(confirmPassword, "fresh-recovery-password");348 await flushView();349350 findButton(container, "recover vault").click();351 await flushView();352353 expect(sendMessageMock).toHaveBeenCalledWith({354 type: "RECOVER_WITH_WORDS",355 payload: {356 words: recoveryWords,357 newPassword: "fresh-recovery-password",358 },359 });360 expect(onRecoveryComplete).toHaveBeenCalled();361 });362363 it("Export menu surfaces both backup formats", async () => {364 const container = mount(<ExportView onBack={vi.fn()} />);365 await flushView();366367 expect(container.textContent).toContain("PeachScript");368 expect(container.textContent).toContain("CSV");369 });370371 it("Lock can open directly on backup restore from onboarding", async () => {372 const container = mount(() => (373 <LockView374 hasVault={false}375 ready={true}376 onUnlock={vi.fn()}377 initialRestore={true}378 />379 ));380 await flushView();381382 expect(container.textContent).toContain("PeachScript");383 expect(container.textContent).toContain("Codex");384 expect(container.textContent).toContain("CSV");385 expect(container.querySelector("#master-password")).toBeNull();386 });387388 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {389 const device = {390 deviceId: "phone-1",391 deviceName: "June's Phone",392 deviceType: "android" as const,393 longTermPublicKeyHex: "a".repeat(64),394 signingPublicKeyHex: "b".repeat(64),395 pairedAt: 1,396 trustSource: "direct" as const,397 };398 sendMessageMock.mockImplementation(async (msg: unknown) => {399 const m = msg as { type: string };400 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };401 return { ok: true, data: null };402 });403404 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);405 for (let i = 0; i < 30; i++) await Promise.resolve();406407 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>408 /sync/i.test(b.textContent ?? ""),409 );410 openSync?.click();411 for (let i = 0; i < 30; i++) await Promise.resolve();412413 const remove = Array.from(container.querySelectorAll("button")).find(414 (b) => (b.textContent ?? "").trim() === "remove",415 );416 expect(remove).toBeTruthy();417418 419 420 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();421422 remove!.click();423 for (let i = 0; i < 10; i++) await Promise.resolve();424425 426 427 const note = container.querySelector(".peach-unpair-confirm-note");428 expect(note).toBeTruthy();429 const text = note!.textContent ?? "";430 expect(text).toContain("master password");431 expect(text).toContain("recovery words");432 expect(text).toContain("future changes");433 });434435 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {436 const device = {437 deviceId: "phone-1",438 deviceName: "June's Phone",439 deviceType: "android" as const,440 longTermPublicKeyHex: "a".repeat(64),441 signingPublicKeyHex: "b".repeat(64),442 pairedAt: 1,443 trustSource: "direct" as const,444 };445 let paired: typeof device[] = [device];446 sendMessageMock.mockImplementation(async (msg: any) => {447 if (msg?.type === "LIST_PAIRED_DEVICES") {448 return { ok: true, data: { devices: [...paired] } };449 }450 if (msg?.type === "UNPAIR_DEVICE") {451 452 453 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);454 return { ok: true };455 }456 return { ok: true, data: null };457 });458459 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);460 for (let i = 0; i < 30; i++) await Promise.resolve();461 Array.from(container.querySelectorAll("button"))462 .find((b) => /sync/i.test(b.textContent ?? ""))463 ?.click();464 for (let i = 0; i < 30; i++) await Promise.resolve();465466 467 expect(container.textContent).toContain("PAIRED DEVICES");468 expect(container.textContent).toContain("June's Phone");469 expect(container.textContent).toContain("Android");470 const syncNowButton = container.querySelector<HTMLButtonElement>(471 'button[aria-label="Sync this vault now"]',472 )!;473 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));474 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;475 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;476 expect(477 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,478 ).toBeTruthy();479 expect(480 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,481 ).toBeTruthy();482483 Array.from(container.querySelectorAll("button"))484 .find((b) => (b.textContent ?? "").trim() === "remove")!485 .click();486 for (let i = 0; i < 10; i++) await Promise.resolve();487 const confirm = Array.from(488 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),489 ).find((b) => (b.textContent ?? "").trim() === "remove");490 confirm!.click();491 for (let i = 0; i < 30; i++) await Promise.resolve();492493 expect(sendMessageMock).toHaveBeenCalledWith({494 type: "UNPAIR_DEVICE",495 payload: { deviceId: "phone-1" },496 });497 498 499 500 expect(container.textContent).not.toContain("June's Phone");501 expect(container.textContent).not.toContain("PAIRED DEVICES");502 });503504 it("Settings keeps connection and recovery details collapsed until requested", async () => {505 sendMessageMock.mockImplementation(async (msg: any) => {506 if (msg?.type === "LIST_PAIRED_DEVICES") {507 return { ok: true, data: { devices: [] } };508 }509 return { ok: true, data: null };510 });511512 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);513 for (let i = 0; i < 30; i++) await Promise.resolve();514 Array.from(container.querySelectorAll("button"))515 .find((button) => /sync/i.test(button.textContent ?? ""))516 ?.click();517 for (let i = 0; i < 20; i++) await Promise.resolve();518519 expect(container.textContent).toContain("SYNC STATUS");520 expect(container.textContent).toContain("ADD A DEVICE");521 expect(container.textContent).not.toContain("Public relay");522 expect(container.textContent).not.toContain("Rotate sync identity");523524 const connection = Array.from(container.querySelectorAll("button"))525 .find((button) => button.textContent?.includes("CONNECTION"));526 connection?.click();527 await Promise.resolve();528 expect(container.textContent).toContain("Public relay");529530 const recovery = Array.from(container.querySelectorAll("button"))531 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));532 recovery?.click();533 await Promise.resolve();534 expect(container.textContent).toContain("Rotate sync identity");535 });536537 it("Settings requires current, new, and confirm passwords before enabling save", async () => {538 sendMessageMock.mockResolvedValue({ ok: true, data: null });539 const container = mount(540 <SettingsView541 entriesCount={1}542 breachMonitoring={true}543 lockOnClose={true}544 autoLockMinutes={5}545 trashRetentionDays={30}546 theme="dark"547 accentColor="peach"548 textSize="medium"549 reduceMotion={false}550 onBreachMonitoringChange={vi.fn()}551 onLockOnCloseChange={vi.fn()}552 onAutoLockMinutesChange={vi.fn()}553 onTrashRetentionChange={vi.fn()}554 onThemeChange={vi.fn()}555 onAccentChange={vi.fn()}556 onTextSizeChange={vi.fn()}557 onReduceMotionChange={vi.fn()}558 shortcuts={DEFAULT_BINDINGS}559 onShortcutsChange={vi.fn()}560 onBack={vi.fn()}561 onImport={vi.fn()}562 onExport={vi.fn()}563 onBreachCheckDone={vi.fn()}564 />565 );566 await flushView();567568 expect(container.querySelectorAll(".stk-card").length).toBe(7);569 570 571 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");572573 const changeButton = Array.from(container.querySelectorAll("button")).find(574 (candidate) => candidate.textContent?.trim() === "change"575 ) as HTMLButtonElement;576 changeButton.click();577 await flushView();578579 const saveButton = Array.from(container.querySelectorAll("button")).find(580 (candidate) => candidate.textContent?.trim() === "save new password"581 ) as HTMLButtonElement;582 expect(saveButton.disabled).toBe(true);583584 const inputs = container.querySelectorAll('input[type="password"]');585 inputText(inputs[0] as HTMLInputElement, "current-pass");586 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");587 await flushView();588 expect(saveButton.disabled).toBe(true);589590 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");591 await flushView();592 expect(saveButton.disabled).toBe(false);593 });594595 it("Settings feedback highlights the selected category and hides the form after submit", async () => {596 sendMessageMock.mockResolvedValue({ ok: true, data: null });597 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({598 ok: true,599 json: async () => ({ success: true }),600 }));601 vi.stubGlobal("fetch", fetchMock);602603 const container = mount(604 <SettingsView605 entriesCount={1}606 breachMonitoring={true}607 lockOnClose={true}608 autoLockMinutes={5}609 trashRetentionDays={30}610 theme="dark"611 accentColor="peach"612 textSize="medium"613 reduceMotion={false}614 initialSection="feedback"615 onBreachMonitoringChange={vi.fn()}616 onLockOnCloseChange={vi.fn()}617 onAutoLockMinutesChange={vi.fn()}618 onTrashRetentionChange={vi.fn()}619 onThemeChange={vi.fn()}620 onAccentChange={vi.fn()}621 onTextSizeChange={vi.fn()}622 onReduceMotionChange={vi.fn()}623 shortcuts={DEFAULT_BINDINGS}624 onShortcutsChange={vi.fn()}625 onBack={vi.fn()}626 onImport={vi.fn()}627 onExport={vi.fn()}628 onBreachCheckDone={vi.fn()}629 />630 );631 await flushView();632633 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;634 expect(feedbackCard.classList.contains("is-active")).toBe(true);635636 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];637 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;638 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;639 expect(bugButton.getAttribute("aria-pressed")).toBe("true");640 expect(featureButton.getAttribute("aria-pressed")).toBe("false");641642 featureButton.click();643 await flushView();644645 expect(bugButton.getAttribute("aria-pressed")).toBe("false");646 expect(featureButton.getAttribute("aria-pressed")).toBe("true");647 expect(featureButton.classList.contains("is-active")).toBe(true);648649 inputText(650 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,651 '<script>alert("xss")</script> Please make this easier to see & safer.'652 );653 const sendButton = Array.from(container.querySelectorAll("button")).find(654 (candidate) => candidate.textContent?.trim() === "Send"655 ) as HTMLButtonElement;656 sendButton.click();657658 await vi.waitFor(() => {659 expect(container.textContent).toContain("Thank you");660 });661662 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));663 expect(requestBody.subject).toContain("Feature request");664 expect(requestBody.message).toBe(665 "<script>alert("xss")</script> Please make this easier to see & safer."666 );667 expect(requestBody.message).not.toContain("<script>");668 expect(container.textContent).toContain("Someone will process it soon");669 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();670 expect(container.querySelector(".peach-feedback-submit")).toBeNull();671 });672673 it("Settings keeps license activation on the full-screen celebration path", async () => {674 const onLicenseChange = vi.fn(async () => undefined);675 sendMessageMock.mockImplementation(async (msg: unknown) => {676 const type = (msg as { type?: string }).type;677 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };678 if (type === "ACTIVATE_LICENSE") {679 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };680 }681 if (type === "GET_ENTRIES") return { ok: true, data: [] };682 return { ok: true, data: null };683 });684685 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);686 await flushView();687688 inputText(689 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,690 "PEACH-TEST-KEY",691 );692 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;693 apply.click();694695 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));696 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);697 expect(container.textContent).not.toContain("thank you ✦");698 });699700 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {701 sendMessageMock.mockImplementation(async (msg: unknown) => {702 const type = (msg as { type?: string }).type;703 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };704 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };705 if (type === "GET_ENTRIES") return { ok: true, data: [] };706 return { ok: true, data: null };707 });708709 const onLicenseChangeFromStar = vi.fn();710 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);711 await flushView();712 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();713 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);714 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();715716 const onLicenseChangeFromPeach = vi.fn();717 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);718 await flushView();719 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();720 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);721 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();722 });723724 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {725 sendMessageMock.mockResolvedValue({ ok: true, data: null });726 const fetchMock = vi.fn();727 const requestMock = vi.fn(async () => false);728 vi.stubGlobal("fetch", fetchMock);729 vi.stubGlobal("browser", {730 permissions: {731 getAll: vi.fn(async () => ({ data_collection: [] })),732 request: requestMock,733 },734 });735736 const container = mount(737 <SettingsView738 entriesCount={1}739 breachMonitoring={true}740 lockOnClose={true}741 autoLockMinutes={5}742 trashRetentionDays={30}743 theme="dark"744 accentColor="peach"745 textSize="medium"746 reduceMotion={false}747 initialSection="feedback"748 onBreachMonitoringChange={vi.fn()}749 onLockOnCloseChange={vi.fn()}750 onAutoLockMinutesChange={vi.fn()}751 onTrashRetentionChange={vi.fn()}752 onThemeChange={vi.fn()}753 onAccentChange={vi.fn()}754 onTextSizeChange={vi.fn()}755 onReduceMotionChange={vi.fn()}756 shortcuts={DEFAULT_BINDINGS}757 onShortcutsChange={vi.fn()}758 onBack={vi.fn()}759 onImport={vi.fn()}760 onExport={vi.fn()}761 onBreachCheckDone={vi.fn()}762 />763 );764 await flushView();765766 inputText(767 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,768 "Please improve the feedback flow."769 );770 const sendButton = Array.from(container.querySelectorAll("button")).find(771 (candidate) => candidate.textContent?.trim() === "Send"772 ) as HTMLButtonElement;773 sendButton.click();774775 await vi.waitFor(() => {776 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });777 });778 expect(fetchMock).not.toHaveBeenCalled();779 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();780 });781782 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {783 sendMessageMock.mockImplementation(async (msg: unknown) => {784 const m = msg as { type: string };785 if (m.type === "GET_RECOVERY_WORDS") {786 return {787 ok: true,788 data: [789 "abandon", "ability", "able", "about", "above", "absent",790 "absorb", "abstract", "absurd", "abuse", "access", "accident",791 ],792 };793 }794 return { ok: true, data: null };795 });796 const onAutoLockMinutesChange = vi.fn();797 const onThemeChange = vi.fn();798 const container = mount(799 <SettingsView800 entriesCount={1}801 breachMonitoring={true}802 lockOnClose={true}803 autoLockMinutes={5}804 trashRetentionDays={30}805 theme="dark"806 accentColor="peach"807 textSize="medium"808 reduceMotion={false}809 onBreachMonitoringChange={vi.fn()}810 onLockOnCloseChange={vi.fn()}811 onAutoLockMinutesChange={onAutoLockMinutesChange}812 onTrashRetentionChange={vi.fn()}813 onThemeChange={onThemeChange}814 onAccentChange={vi.fn()}815 onTextSizeChange={vi.fn()}816 onReduceMotionChange={vi.fn()}817 shortcuts={DEFAULT_BINDINGS}818 onShortcutsChange={vi.fn()}819 onBack={vi.fn()}820 onImport={vi.fn()}821 onExport={vi.fn()}822 onBreachCheckDone={vi.fn()}823 />824 );825 await flushView();826 sendMessageMock.mockClear();827828 829 const selects = container.querySelectorAll("select");830 changeSelect(selects[0] as HTMLSelectElement, "15");831 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);832833 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;834 appearanceCard.click();835 await flushView();836 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");837 const themeButton = Array.from(container.querySelectorAll("button")).find(838 (candidate) => candidate.textContent?.trim() === "Switch to light"839 ) as HTMLButtonElement;840 themeButton.click();841 expect(onThemeChange).toHaveBeenCalledWith("light");842843 844 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;845 protectionCard.click();846 await flushView();847848 const changeButton = Array.from(container.querySelectorAll("button")).find(849 (candidate) => candidate.textContent?.trim() === "change"850 ) as HTMLButtonElement;851 changeButton.click();852 await flushView();853854 const inputs = container.querySelectorAll('input[type="password"]');855 inputText(inputs[0] as HTMLInputElement, "current-pass");856 inputText(inputs[1] as HTMLInputElement, "new-password-123");857 inputText(inputs[2] as HTMLInputElement, "new-password-123");858 await flushView();859860 const saveButton = Array.from(container.querySelectorAll("button")).find(861 (candidate) => candidate.textContent?.trim() === "save new password"862 ) as HTMLButtonElement;863 saveButton.click();864 await flushView();865866 expect(sendMessageMock).toHaveBeenCalledWith({867 type: "CHANGE_PASSWORD",868 payload: {869 currentPassword: "current-pass",870 newPassword: "new-password-123",871 },872 });873874 875 expect(container.textContent).toContain("save your recovery words");876 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });877 });878879 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {880 sendMessageMock.mockResolvedValue({ ok: true });881 const container = mount(882 <EntryView883 entry={sampleEntry()}884 onDelete={vi.fn()}885 onFieldSaved={vi.fn(async () => undefined)}886 />887 );888889 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;890 passwordButton.click();891 await flushView();892893 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {894 type: "COPY",895 payload: { text: "secret123", label: "password", alreadyWritten: false },896 });897 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {898 type: "FIRE_CANARY_PING",899 payload: {900 webhookUrl: "https://example.com/hook",901 entryTitle: "GitHub",902 trigger: "password_copied",903 },904 });905906 sendMessageMock.mockClear();907 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;908 usernameButton.click();909 await flushView();910911 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {912 type: "COPY",913 payload: { text: "june", label: "username", alreadyWritten: false },914 });915 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {916 type: "FIRE_CANARY_PING",917 payload: {918 webhookUrl: "https://example.com/hook",919 entryTitle: "GitHub",920 trigger: "username_copied",921 },922 });923 });924925 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {926 sendMessageMock.mockResolvedValue({ ok: true });927 const onFieldSaved = vi.fn(async () => undefined);928 const container = mount(929 <EntryView930 entry={sampleEntry()}931 onDelete={vi.fn()}932 onFieldSaved={onFieldSaved}933 />934 );935936 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;937 passwordValue.click();938 await flushView();939940 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;941 inputText(passwordInput, "");942 await flushView();943944 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;945 saveButton.click();946 await flushView();947948 expect(sendMessageMock).not.toHaveBeenCalledWith(949 expect.objectContaining({ type: "UPDATE_ENTRY" })950 );951 expect(onFieldSaved).not.toHaveBeenCalled();952953 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;954 totpMain.click();955956 await vi.waitFor(() => {957 expect(sendMessageMock).toHaveBeenLastCalledWith({958 type: "COPY",959 payload: { text: "123456", label: "totp", alreadyWritten: false },960 });961 }, { timeout: 500 });962 });963964 it("Entry closes an unchanged inline editor when focus leaves it", async () => {965 sendMessageMock.mockResolvedValue({ ok: true });966 const container = mount(967 <EntryView968 entry={sampleEntry()}969 onDelete={vi.fn()}970 onFieldSaved={vi.fn(async () => undefined)}971 />972 );973974 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;975 usernameValue.click();976 await flushView();977978 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;979 usernameInput.blur();980 await flushView();981982 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();983 expect(sendMessageMock).not.toHaveBeenCalledWith(984 expect.objectContaining({ type: "UPDATE_ENTRY" })985 );986 });987988 it("Entry lets TOTP secrets be edited inline", async () => {989 sendMessageMock.mockResolvedValue({ ok: true });990 const onFieldSaved = vi.fn(async () => undefined);991 const container = mount(992 <EntryView993 entry={sampleEntry()}994 onDelete={vi.fn()}995 onFieldSaved={onFieldSaved}996 />997 );998999 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1000 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1001 await flushView();10021003 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1004 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1005 await flushView();10061007 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1008 saveButton.click();1009 await flushView();10101011 expect(sendMessageMock).toHaveBeenCalledWith({1012 type: "UPDATE_ENTRY",1013 payload: {1014 id: "entry-1",1015 patch: { totp: "NB2W45DFOIZA====" },1016 },1017 });1018 expect(onFieldSaved).toHaveBeenCalled();1019 });10201021 it("Vault seeds and focuses search when a search seed is provided", async () => {1022 const container = mount(1023 <VaultView1024 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1025 sortOrder="url-first"1026 searchSeed="g"1027 searchSeedNonce={1}1028 onSelectEntry={vi.fn()}1029 onSortOrderChange={vi.fn()}1030 onAdd={vi.fn()}1031 onClose={vi.fn()}1032 />1033 );10341035 await flushView();10361037 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1038 expect(search.value).toBe("g");1039 expect(document.activeElement).toBe(search);1040 });10411042 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1043 parseImportSourceMock.mockResolvedValue(1044 sampleImportResult({1045 entries: [1046 {1047 type: "login",1048 title: "Imported site",1049 username: "imported-user",1050 password: "imported-pass",1051 url: "https://imported.test",1052 },1053 ],1054 })1055 );1056 sendMessageMock.mockImplementation(async (msg: unknown) => {1057 const type = (msg as { type?: string }).type;1058 if (type === "GET_ENTRIES") {1059 return {1060 ok: true,1061 data: [1062 sampleEntry({1063 id: "existing-1",1064 title: "Imported site",1065 username: "imported-user",1066 url: "https://imported.test",1067 }),1068 ],1069 };1070 }1071 if (type === "BATCH_ADD_ENTRIES") {1072 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1073 }1074 return { ok: true };1075 });1076 const onImport = vi.fn(async () => undefined);1077 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1078 await flushView();1079 sendMessageMock.mockClear();10801081 await selectImportFile(container);1082 findButton(container, "Import 1").click();1083 await flushView();10841085 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1086 expect(sendMessageMock).toHaveBeenCalledWith({1087 type: "BATCH_ADD_ENTRIES",1088 payload: {1089 entries: [1090 expect.objectContaining({1091 title: "Imported site",1092 username: "imported-user",1093 password: "imported-pass",1094 url: "https://imported.test",1095 }),1096 ],1097 },1098 });1099 expect(onImport).toHaveBeenCalled();1100 expect(container.textContent).toContain("1 entry appears to be duplicates");1101 });11021103 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1104 parseImportSourceMock.mockResolvedValue(sampleImportResult());1105 sendMessageMock.mockImplementation(async (msg: unknown) => {1106 const type = (msg as { type?: string }).type;1107 if (type === "GET_ENTRIES") {1108 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1109 }1110 if (type === "CLEAR_VAULT_ENTRIES") {1111 return { ok: true };1112 }1113 if (type === "BATCH_ADD_ENTRIES") {1114 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1115 }1116 return { ok: true };1117 });1118 const onImport = vi.fn(async () => undefined);1119 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1120 await flushView();1121 sendMessageMock.mockClear();11221123 await selectImportFile(container);1124 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1125 await flushView();11261127 findButton(container, "Replace vault with 1").click();1128 await flushView();11291130 expect(sendMessageMock).not.toHaveBeenCalledWith(1131 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1132 );1133 expect(container.textContent).toContain("Replace forever? This cannot be undone.");11341135 findButton(container, "Yes, replace forever").click();1136 await flushView();11371138 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1139 expect(sendMessageMock).toHaveBeenCalledWith({1140 type: "BATCH_ADD_ENTRIES",1141 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1142 });1143 expect(onImport).toHaveBeenCalled();1144 });11451146 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1147 parseImportSourceMock.mockResolvedValue(sampleImportResult());1148 sendMessageMock.mockImplementation(async (msg: unknown) => {1149 const type = (msg as { type?: string }).type;1150 if (type === "CREATE_VAULT") return { ok: true };1151 if (type === "BATCH_ADD_ENTRIES") {1152 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1153 }1154 return { ok: true };1155 });1156 const onImport = vi.fn(async () => undefined);1157 const container = mount(1158 <ImportView1159 mode="onboarding"1160 replacesExistingVault={true}1161 onBack={vi.fn()}1162 onImport={onImport}1163 />1164 );11651166 await selectImportFile(container);11671168 const createButton = findButton(container, "Create vault + import 1");1169 expect(createButton.disabled).toBe(true);1170 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1171 expect(container.textContent).toContain("Use at least 8 characters");11721173 const masterInput = container.querySelector(1174 'input[placeholder="new master password"]'1175 ) as HTMLInputElement;1176 const confirmInput = container.querySelector(1177 'input[placeholder="confirm master password"]'1178 ) as HTMLInputElement;1179 const revealButton = container.querySelector(1180 'button[aria-label="Show passwords"]'1181 ) as HTMLButtonElement;1182 expect(revealButton).toBeTruthy();1183 revealButton.click();1184 await flushView();1185 expect(masterInput.type).toBe("text");1186 expect(confirmInput.type).toBe("text");11871188 inputText(masterInput, "fresh-master-password");1189 inputText(confirmInput, "fresh-master-password");1190 await flushView();11911192 expect(createButton.disabled).toBe(false);1193 createButton.click();1194 await flushView();11951196 expect(sendMessageMock).toHaveBeenCalledWith({1197 type: "CREATE_VAULT",1198 payload: { password: "fresh-master-password" },1199 });1200 expect(sendMessageMock).toHaveBeenCalledWith({1201 type: "BATCH_ADD_ENTRIES",1202 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1203 });1204 expect(onImport).toHaveBeenCalled();1205 });12061207 it("Import encrypted backups reparse with the backup password before import", async () => {1208 parseImportSourceMock1209 .mockResolvedValueOnce(1210 sampleImportResult({1211 format: "peach-json",1212 label: "Peach encrypted backup",1213 entries: [],1214 totalItems: 0,1215 requiresPassword: true,1216 })1217 )1218 .mockResolvedValueOnce(1219 sampleImportResult({1220 format: "peach-json",1221 label: "Peach encrypted backup",1222 })1223 );1224 sendMessageMock.mockImplementation(async (msg: unknown) => {1225 const type = (msg as { type?: string }).type;1226 if (type === "GET_ENTRIES") return { ok: true, data: [] };1227 if (type === "BATCH_ADD_ENTRIES") {1228 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1229 }1230 return { ok: true };1231 });1232 const onImport = vi.fn(async () => undefined);1233 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1234 await flushView();1235 sendMessageMock.mockClear();12361237 await selectImportFile(container, '{"type":"peach-export"}');1238 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1239 inputText(password, "backup-passphrase");1240 pressEnter(password);1241 await flushView();1242 await flushView();12431244 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1245 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");12461247 findButton(container, "Import 1").click();1248 await flushView();12491250 expect(sendMessageMock).toHaveBeenCalledWith({1251 type: "BATCH_ADD_ENTRIES",1252 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1253 });1254 expect(onImport).toHaveBeenCalled();1255 });1256});