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 { VaultView } from "./Vault";19import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2021const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();22const triggerRingMock = vi.fn();23const triggerPipPulseMock = vi.fn();24const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();25const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2627vi.mock("../App", () => ({28 sendMessage: (msg: unknown) => sendMessageMock(msg),29 triggerRing: () => triggerRingMock(),30 triggerPipPulse: () => triggerPipPulseMock(),31}));3233vi.mock("../../shared/import", () => ({34 parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),35}));3637vi.mock("../../shared/favicon", async (importOriginal) => {38 const actual = await importOriginal<typeof import("../../shared/favicon")>();39 return { ...actual, faviconUrl: () => null };40});4142vi.mock("../../crypto/totp", () => ({43 generateTOTP: () => ({ code: "123 456", remaining: 24 }),44 parseOtpauthURI: (value: string) =>45 value.startsWith("otpauth://")46 ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }47 : null,48}));4950vi.mock("../components/GeneratorDrawer", () => ({51 GeneratorDrawer: () => null,52}));5354vi.mock("../components/RuneBackground", () => ({55 RuneBackground: () => null,56}));5758vi.mock("@zxcvbn-ts/core", () => ({59 zxcvbn: (value: string) => zxcvbnMock(value),60 zxcvbnOptions: { setOptions: vi.fn() },61}));62vi.mock("@zxcvbn-ts/language-common", () => ({63 adjacencyGraphs: {},64 dictionary: {},65}));66vi.mock("@zxcvbn-ts/language-en", () => ({67 translations: {},68 dictionary: {},69}));7071describe("popup contract tests", () => {72 const disposers: Array<() => void> = [];7374 beforeEach(() => {75 document.body.innerHTML = "";76 sendMessageMock.mockReset();77 triggerRingMock.mockReset();78 triggerPipPulseMock.mockReset();79 parseImportSourceMock.mockReset();80 zxcvbnMock.mockReset();81 zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));82 parseImportSourceMock.mockResolvedValue({83 format: "unknown",84 label: "Unknown format",85 entries: [],86 totalItems: 0,87 skippedItems: 0,88 requiresPassword: false,89 });9091 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {92 return window.setTimeout(() => cb(0), 0) as unknown as number;93 });94 vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));95 vi.stubGlobal("URL", {96 createObjectURL: vi.fn(() => "blob:peach-test"),97 revokeObjectURL: vi.fn(),98 });99 vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});100101 (globalThis as { chrome?: typeof chrome }).chrome = {102 runtime: {103 getManifest: () => ({ version: "1.4.47" }),104 onMessage: { addListener: vi.fn() },105 },106 } as unknown as typeof chrome;107 });108109 afterEach(() => {110 while (disposers.length > 0) {111 disposers.pop()?.();112 }113 vi.restoreAllMocks();114 vi.unstubAllGlobals();115 });116117 function mount(view: JSX.Element | (() => JSX.Element)) {118 const mounted = renderView(view);119 disposers.push(mounted.dispose);120 return mounted.container;121 }122123 function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {124 return {125 id: "entry-1",126 type: "login",127 title: "GitHub",128 username: "june",129 password: "secret123",130 url: "https://github.com",131 totp: "JBSWY3DPEHPK3PXP",132 canary: {133 webhookUrl: "https://example.com/hook",134 },135 createdAt: 1,136 modifiedAt: 1,137 ...overrides,138 };139 }140141 function sampleImportResult(142 overrides: Partial<ImportParseResult> = {}143 ): ImportParseResult {144 return {145 format: "csv",146 label: "CSV import",147 entries: [148 {149 type: "login",150 title: "Imported site",151 username: "imported-user",152 password: "imported-pass",153 url: "https://imported.test",154 },155 ],156 totalItems: 1,157 skippedItems: 0,158 requiresPassword: false,159 ...overrides,160 };161 }162163 function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {164 return {165 entriesCount: 1,166 breachMonitoring: true,167 lockOnClose: true,168 autoLockMinutes: 5,169 trashRetentionDays: 30,170 theme: "dark" as const,171 accentColor: "peach" as const,172 textSize: "medium" as const,173 reduceMotion: false,174 onBreachMonitoringChange: vi.fn(),175 onLockOnCloseChange: vi.fn(),176 onAutoLockMinutesChange: vi.fn(),177 onTrashRetentionChange: vi.fn(),178 onThemeChange: vi.fn(),179 onAccentChange: vi.fn(),180 onTextSizeChange: vi.fn(),181 onReduceMotionChange: vi.fn(),182 shortcuts: DEFAULT_BINDINGS,183 onShortcutsChange: vi.fn(),184 onBack: vi.fn(),185 onImport: vi.fn(),186 onExport: vi.fn(),187 onBreachCheckDone: vi.fn(),188 ...overrides,189 };190 }191192 async function selectImportFile(193 container: HTMLElement,194 contents = '{"export":"test"}'195 ) {196 197 198 199 const fileCard = findButton(container, "Choose file →");200 if (fileCard) {201 fileCard.click();202 await flushView();203 }204 const input = container.querySelector('input[type="file"]') as HTMLInputElement;205 const file = new File([contents], "import.json", { type: "application/json" });206 Object.defineProperty(input, "files", {207 configurable: true,208 value: [file],209 });210 input.dispatchEvent(new Event("change", { bubbles: true }));211 await flushView();212 await flushView();213 }214215 function findButton(container: HTMLElement, text: string): HTMLButtonElement {216 return Array.from(container.querySelectorAll("button")).find(217 (candidate) => candidate.textContent?.trim() === text218 ) as HTMLButtonElement;219 }220221 it("Lock sends UNLOCK with the entered passphrase", async () => {222 sendMessageMock.mockResolvedValue({ ok: true });223 const onUnlock = vi.fn(async () => undefined);224 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);225226 const password = container.querySelector("#unlock-password") as HTMLInputElement;227 inputText(password, "correct horse battery staple");228 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();229 await flushView();230231 expect(sendMessageMock).toHaveBeenCalledWith({232 type: "UNLOCK",233 payload: { password: "correct horse battery staple" },234 });235 expect(onUnlock).toHaveBeenCalled();236 });237238 it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {239 sendMessageMock.mockImplementation(async (msg: unknown) => {240 return (msg as { type: string }).type === "GET_SETTINGS"241 ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }242 : { ok: true };243 });244 const onUnlock = vi.fn(async () => undefined);245 const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);246 await flushView();247248 inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");249 (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();250 await flushView();251252 expect(sendMessageMock).toHaveBeenCalledWith({253 type: "UNLOCK_WITH_PIN",254 payload: { pin: "1234" },255 });256 expect(onUnlock).toHaveBeenCalled();257 expect(container.textContent).toContain("use master password");258 });259260 it("places the PIN method switch below the unlock button", async () => {261 sendMessageMock.mockImplementation(async (msg: unknown) => {262 return (msg as { type: string }).type === "GET_SETTINGS"263 ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }264 : { ok: true };265 });266 const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);267 await flushView();268269 const buttons = Array.from(container.querySelectorAll("button"));270 const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");271 const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");272 expect(pinIndex).toBeGreaterThan(unlockIndex);273 });274275 it("keeps standalone note creation in Notes mode", async () => {276 const chromeApi = globalThis.chrome as unknown as {277 tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };278 };279 chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };280281 const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);282 await flushView();283284 expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))285 .toEqual(["login", "card"]);286 expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();287 });288289 it("gives Notes mode the full note-formatting toolbar", async () => {290 const container = mount(291 <NotesEditor292 note={null}293 onBack={vi.fn()}294 onSaved={vi.fn()}295 onDelete={vi.fn()}296 />297 );298 await flushView();299300 expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();301 expect(container.querySelector('[title="Divider"]')).toBeTruthy();302 expect(container.querySelector('[title="Quote"]')).toBeTruthy();303 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);304 expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);305 expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");306 });307308 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {309 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);310 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;311 const password = container.querySelector("#master-password") as HTMLInputElement;312 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;313314 inputText(password, "short");315 inputText(confirm, "short");316 await flushView();317 expect(createButton.disabled).toBe(true);318319 zxcvbnMock.mockImplementation(() => ({ score: 1 }));320 inputText(password, "weakweakweak");321 inputText(confirm, "weakweakweak");322 await flushView();323 expect(createButton.disabled).toBe(true);324 });325326 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {327 328 sendMessageMock.mockImplementation(329 async (msg: unknown) => {330 const m = msg as { type: string; payload?: { password?: string } };331 if (m.type === "CREATE_VAULT") return { ok: true };332 if (m.type === "GET_RECOVERY_WORDS") {333 334 return {335 ok: true,336 data: [337 "abandon", "ability", "able", "about", "above", "absent",338 "absorb", "abstract", "absurd", "abuse", "access", "accident",339 ],340 };341 }342 return { ok: true };343 }344 );345 zxcvbnMock.mockImplementation(() => ({ score: 3 }));346 const onUnlock = vi.fn(async () => undefined);347 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);348349 const password = container.querySelector("#master-password") as HTMLInputElement;350 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;351 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;352353 inputText(password, "correct horse battery staple");354 inputText(confirm, "correct horse battery staple");355 await flushView();356 expect(createButton.disabled).toBe(false);357358 pressEnter(confirm);359 await flushView();360361 expect(sendMessageMock).toHaveBeenCalledWith({362 type: "CREATE_VAULT",363 payload: { password: "correct horse battery staple" },364 });365 366 367 expect(onUnlock).not.toHaveBeenCalled();368 });369370 it("Recovery words flow submits the new master password and completes", async () => {371 sendMessageMock.mockImplementation(async (msg: unknown) => {372 const type = (msg as { type?: string }).type;373 if (type === "VALIDATE_RECOVERY_WORDS") {374 return { ok: true, data: { valid: true, invalid: [] } };375 }376 if (type === "RECOVER_WITH_WORDS") return { ok: true };377 return { ok: true };378 });379 const canvasContext = {380 clearRect: vi.fn(),381 fillText: vi.fn(),382 beginPath: vi.fn(),383 closePath: vi.fn(),384 arc: vi.fn(),385 fill: vi.fn(),386 stroke: vi.fn(),387 save: vi.fn(),388 restore: vi.fn(),389 translate: vi.fn(),390 rotate: vi.fn(),391 scale: vi.fn(),392 drawImage: vi.fn(),393 measureText: vi.fn(() => ({ width: 0 })),394 setTransform: vi.fn(),395 fillRect: vi.fn(),396 strokeRect: vi.fn(),397 } as unknown as CanvasRenderingContext2D;398 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);399400 const onRecoveryComplete = vi.fn(async () => undefined);401 const container = mount(402 <RecoveryWordEntryView403 onCancel={vi.fn()}404 onRecoveryComplete={onRecoveryComplete}405 />406 );407408 const recoveryWords = WORD_LIST.slice(0, 12);409 for (let i = 0; i < recoveryWords.length; i++) {410 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;411 inputText(input, recoveryWords[i]!);412 }413 await flushView();414415 findButton(container, "verify words").click();416 await flushView();417418 expect(container.textContent).toContain("new master password");419420 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;421 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;422 inputText(newPassword, "fresh-recovery-password");423 inputText(confirmPassword, "fresh-recovery-password");424 await flushView();425426 findButton(container, "recover vault").click();427 await flushView();428429 expect(sendMessageMock).toHaveBeenCalledWith({430 type: "RECOVER_WITH_WORDS",431 payload: {432 words: recoveryWords,433 newPassword: "fresh-recovery-password",434 },435 });436 expect(onRecoveryComplete).toHaveBeenCalled();437 });438439 it("Export menu surfaces both backup formats", async () => {440 const container = mount(<ExportView onBack={vi.fn()} />);441 await flushView();442443 expect(container.textContent).toContain("PeachScript");444 expect(container.textContent).toContain("CSV");445 });446447 it("Lock can open directly on backup restore from onboarding", async () => {448 const container = mount(() => (449 <LockView450 hasVault={false}451 ready={true}452 onUnlock={vi.fn()}453 initialRestore={true}454 />455 ));456 await flushView();457458 expect(container.textContent).toContain("PeachScript");459 expect(container.textContent).toContain("Codex");460 expect(container.textContent).toContain("CSV");461 expect(container.querySelector("#master-password")).toBeNull();462 });463464 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {465 const device = {466 deviceId: "phone-1",467 deviceName: "June's Phone",468 deviceType: "android" as const,469 longTermPublicKeyHex: "a".repeat(64),470 signingPublicKeyHex: "b".repeat(64),471 pairedAt: 1,472 trustSource: "direct" as const,473 };474 sendMessageMock.mockImplementation(async (msg: unknown) => {475 const m = msg as { type: string };476 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };477 return { ok: true, data: null };478 });479480 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);481 for (let i = 0; i < 30; i++) await Promise.resolve();482483 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>484 /sync/i.test(b.textContent ?? ""),485 );486 openSync?.click();487 for (let i = 0; i < 30; i++) await Promise.resolve();488489 const remove = Array.from(container.querySelectorAll("button")).find(490 (b) => (b.textContent ?? "").trim() === "remove",491 );492 expect(remove).toBeTruthy();493494 495 496 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();497498 remove!.click();499 for (let i = 0; i < 10; i++) await Promise.resolve();500501 502 503 const note = container.querySelector(".peach-unpair-confirm-note");504 expect(note).toBeTruthy();505 const text = note!.textContent ?? "";506 expect(text).toContain("master password");507 expect(text).toContain("recovery words");508 expect(text).toContain("future changes");509 });510511 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {512 const device = {513 deviceId: "phone-1",514 deviceName: "June's Phone",515 deviceType: "android" as const,516 longTermPublicKeyHex: "a".repeat(64),517 signingPublicKeyHex: "b".repeat(64),518 pairedAt: 1,519 trustSource: "direct" as const,520 };521 let paired: typeof device[] = [device];522 sendMessageMock.mockImplementation(async (msg: any) => {523 if (msg?.type === "LIST_PAIRED_DEVICES") {524 return { ok: true, data: { devices: [...paired] } };525 }526 if (msg?.type === "UNPAIR_DEVICE") {527 528 529 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);530 return { ok: true };531 }532 if (msg?.type === "RENAME_PAIRED_DEVICE") {533 paired = paired.map((d) =>534 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,535 );536 return { ok: true };537 }538 return { ok: true, data: null };539 });540541 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);542 for (let i = 0; i < 30; i++) await Promise.resolve();543 Array.from(container.querySelectorAll("button"))544 .find((b) => /sync/i.test(b.textContent ?? ""))545 ?.click();546 for (let i = 0; i < 30; i++) await Promise.resolve();547548 549 expect(container.textContent).toContain("PAIRED DEVICES");550 expect(container.textContent).toContain("June's Phone");551 expect(container.textContent).toContain("Android");552 Array.from(container.querySelectorAll("button"))553 .find((b) => (b.textContent ?? "").trim() === "rename")!554 .click();555 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;556 renameInput.value = "Travel phone";557 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));558 Array.from(container.querySelectorAll("button"))559 .find((b) => (b.textContent ?? "").trim() === "save")!560 .click();561 for (let i = 0; i < 30; i++) await Promise.resolve();562 expect(sendMessageMock).toHaveBeenCalledWith({563 type: "RENAME_PAIRED_DEVICE",564 payload: { deviceId: "phone-1", name: "Travel phone" },565 });566 expect(container.textContent).toContain("Travel phone");567 const syncNowButton = container.querySelector<HTMLButtonElement>(568 'button[aria-label="Sync this vault now"]',569 )!;570 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));571 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;572 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;573 expect(574 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,575 ).toBeTruthy();576 expect(577 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,578 ).toBeTruthy();579580 Array.from(container.querySelectorAll("button"))581 .find((b) => (b.textContent ?? "").trim() === "remove")!582 .click();583 for (let i = 0; i < 10; i++) await Promise.resolve();584 const confirm = Array.from(585 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),586 ).find((b) => (b.textContent ?? "").trim() === "remove");587 confirm!.click();588 for (let i = 0; i < 30; i++) await Promise.resolve();589590 expect(sendMessageMock).toHaveBeenCalledWith({591 type: "UNPAIR_DEVICE",592 payload: { deviceId: "phone-1" },593 });594 595 596 597 expect(container.textContent).not.toContain("June's Phone");598 expect(container.textContent).not.toContain("PAIRED DEVICES");599 });600601 it("Settings keeps connection and recovery details collapsed until requested", async () => {602 sendMessageMock.mockImplementation(async (msg: any) => {603 if (msg?.type === "LIST_PAIRED_DEVICES") {604 return { ok: true, data: { devices: [] } };605 }606 return { ok: true, data: null };607 });608609 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);610 for (let i = 0; i < 30; i++) await Promise.resolve();611 Array.from(container.querySelectorAll("button"))612 .find((button) => /sync/i.test(button.textContent ?? ""))613 ?.click();614 for (let i = 0; i < 20; i++) await Promise.resolve();615616 expect(container.textContent).toContain("SYNC STATUS");617 expect(container.textContent).toContain("ADD A DEVICE");618 expect(container.textContent).not.toContain("Public relay");619 expect(container.textContent).not.toContain("Rotate sync identity");620621 const connection = Array.from(container.querySelectorAll("button"))622 .find((button) => button.textContent?.includes("CONNECTION"));623 connection?.click();624 await Promise.resolve();625 expect(container.textContent).toContain("Public relay");626627 const recovery = Array.from(container.querySelectorAll("button"))628 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));629 recovery?.click();630 await Promise.resolve();631 expect(container.textContent).toContain("Rotate sync identity");632 });633634 it("Settings requires current, new, and confirm passwords before enabling save", async () => {635 sendMessageMock.mockResolvedValue({ ok: true, data: null });636 const container = mount(637 <SettingsView638 entriesCount={1}639 breachMonitoring={true}640 lockOnClose={true}641 autoLockMinutes={5}642 trashRetentionDays={30}643 theme="dark"644 accentColor="peach"645 textSize="medium"646 reduceMotion={false}647 onBreachMonitoringChange={vi.fn()}648 onLockOnCloseChange={vi.fn()}649 onAutoLockMinutesChange={vi.fn()}650 onTrashRetentionChange={vi.fn()}651 onThemeChange={vi.fn()}652 onAccentChange={vi.fn()}653 onTextSizeChange={vi.fn()}654 onReduceMotionChange={vi.fn()}655 shortcuts={DEFAULT_BINDINGS}656 onShortcutsChange={vi.fn()}657 onBack={vi.fn()}658 onImport={vi.fn()}659 onExport={vi.fn()}660 onBreachCheckDone={vi.fn()}661 />662 );663 await flushView();664665 expect(container.querySelectorAll(".stk-card").length).toBe(7);666 667 668 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");669670 const changeButton = Array.from(container.querySelectorAll("button")).find(671 (candidate) => candidate.textContent?.trim() === "change"672 ) as HTMLButtonElement;673 changeButton.click();674 await flushView();675676 const saveButton = Array.from(container.querySelectorAll("button")).find(677 (candidate) => candidate.textContent?.trim() === "save new password"678 ) as HTMLButtonElement;679 expect(saveButton.disabled).toBe(true);680681 const inputs = container.querySelectorAll('input[type="password"]');682 inputText(inputs[0] as HTMLInputElement, "current-pass");683 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");684 await flushView();685 expect(saveButton.disabled).toBe(true);686687 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");688 await flushView();689 expect(saveButton.disabled).toBe(false);690 });691692 it("Settings feedback highlights the selected category and hides the form after submit", async () => {693 sendMessageMock.mockResolvedValue({ ok: true, data: null });694 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({695 ok: true,696 json: async () => ({ success: true }),697 }));698 vi.stubGlobal("fetch", fetchMock);699700 const container = mount(701 <SettingsView702 entriesCount={1}703 breachMonitoring={true}704 lockOnClose={true}705 autoLockMinutes={5}706 trashRetentionDays={30}707 theme="dark"708 accentColor="peach"709 textSize="medium"710 reduceMotion={false}711 initialSection="feedback"712 onBreachMonitoringChange={vi.fn()}713 onLockOnCloseChange={vi.fn()}714 onAutoLockMinutesChange={vi.fn()}715 onTrashRetentionChange={vi.fn()}716 onThemeChange={vi.fn()}717 onAccentChange={vi.fn()}718 onTextSizeChange={vi.fn()}719 onReduceMotionChange={vi.fn()}720 shortcuts={DEFAULT_BINDINGS}721 onShortcutsChange={vi.fn()}722 onBack={vi.fn()}723 onImport={vi.fn()}724 onExport={vi.fn()}725 onBreachCheckDone={vi.fn()}726 />727 );728 await flushView();729730 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;731 expect(feedbackCard.classList.contains("is-active")).toBe(true);732733 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];734 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;735 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;736 expect(bugButton.getAttribute("aria-pressed")).toBe("true");737 expect(featureButton.getAttribute("aria-pressed")).toBe("false");738739 featureButton.click();740 await flushView();741742 expect(bugButton.getAttribute("aria-pressed")).toBe("false");743 expect(featureButton.getAttribute("aria-pressed")).toBe("true");744 expect(featureButton.classList.contains("is-active")).toBe(true);745746 inputText(747 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,748 '<script>alert("xss")</script> Please make this easier to see & safer.'749 );750 const sendButton = Array.from(container.querySelectorAll("button")).find(751 (candidate) => candidate.textContent?.trim() === "Send"752 ) as HTMLButtonElement;753 sendButton.click();754755 await vi.waitFor(() => {756 expect(container.textContent).toContain("Thank you");757 });758759 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));760 expect(requestBody.subject).toContain("Feature request");761 expect(requestBody.message).toBe(762 "<script>alert("xss")</script> Please make this easier to see & safer."763 );764 expect(requestBody.message).not.toContain("<script>");765 expect(container.textContent).toContain("Someone will process it soon");766 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();767 expect(container.querySelector(".peach-feedback-submit")).toBeNull();768 });769770 it("Settings keeps license activation on the full-screen celebration path", async () => {771 const onLicenseChange = vi.fn(async () => undefined);772 sendMessageMock.mockImplementation(async (msg: unknown) => {773 const type = (msg as { type?: string }).type;774 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };775 if (type === "ACTIVATE_LICENSE") {776 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };777 }778 if (type === "GET_ENTRIES") return { ok: true, data: [] };779 return { ok: true, data: null };780 });781782 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);783 await flushView();784785 inputText(786 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,787 "PEACH-TEST-KEY",788 );789 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;790 apply.click();791792 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));793 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);794 expect(container.textContent).not.toContain("thank you ✦");795 });796797 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {798 sendMessageMock.mockImplementation(async (msg: unknown) => {799 const type = (msg as { type?: string }).type;800 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };801 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };802 if (type === "GET_ENTRIES") return { ok: true, data: [] };803 return { ok: true, data: null };804 });805806 const onLicenseChangeFromStar = vi.fn();807 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);808 await flushView();809 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();810 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);811 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();812813 const onLicenseChangeFromPeach = vi.fn();814 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);815 await flushView();816 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();817 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);818 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();819 });820821 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {822 sendMessageMock.mockResolvedValue({ ok: true, data: null });823 const fetchMock = vi.fn();824 const requestMock = vi.fn(async () => false);825 vi.stubGlobal("fetch", fetchMock);826 vi.stubGlobal("browser", {827 permissions: {828 getAll: vi.fn(async () => ({ data_collection: [] })),829 request: requestMock,830 },831 });832833 const container = mount(834 <SettingsView835 entriesCount={1}836 breachMonitoring={true}837 lockOnClose={true}838 autoLockMinutes={5}839 trashRetentionDays={30}840 theme="dark"841 accentColor="peach"842 textSize="medium"843 reduceMotion={false}844 initialSection="feedback"845 onBreachMonitoringChange={vi.fn()}846 onLockOnCloseChange={vi.fn()}847 onAutoLockMinutesChange={vi.fn()}848 onTrashRetentionChange={vi.fn()}849 onThemeChange={vi.fn()}850 onAccentChange={vi.fn()}851 onTextSizeChange={vi.fn()}852 onReduceMotionChange={vi.fn()}853 shortcuts={DEFAULT_BINDINGS}854 onShortcutsChange={vi.fn()}855 onBack={vi.fn()}856 onImport={vi.fn()}857 onExport={vi.fn()}858 onBreachCheckDone={vi.fn()}859 />860 );861 await flushView();862863 inputText(864 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,865 "Please improve the feedback flow."866 );867 const sendButton = Array.from(container.querySelectorAll("button")).find(868 (candidate) => candidate.textContent?.trim() === "Send"869 ) as HTMLButtonElement;870 sendButton.click();871872 await vi.waitFor(() => {873 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });874 });875 expect(fetchMock).not.toHaveBeenCalled();876 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();877 });878879 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {880 sendMessageMock.mockImplementation(async (msg: unknown) => {881 const m = msg as { type: string };882 if (m.type === "GET_RECOVERY_WORDS") {883 return {884 ok: true,885 data: [886 "abandon", "ability", "able", "about", "above", "absent",887 "absorb", "abstract", "absurd", "abuse", "access", "accident",888 ],889 };890 }891 return { ok: true, data: null };892 });893 const onAutoLockMinutesChange = vi.fn();894 const onThemeChange = vi.fn();895 const container = mount(896 <SettingsView897 entriesCount={1}898 breachMonitoring={true}899 lockOnClose={true}900 autoLockMinutes={5}901 trashRetentionDays={30}902 theme="dark"903 accentColor="peach"904 textSize="medium"905 reduceMotion={false}906 onBreachMonitoringChange={vi.fn()}907 onLockOnCloseChange={vi.fn()}908 onAutoLockMinutesChange={onAutoLockMinutesChange}909 onTrashRetentionChange={vi.fn()}910 onThemeChange={onThemeChange}911 onAccentChange={vi.fn()}912 onTextSizeChange={vi.fn()}913 onReduceMotionChange={vi.fn()}914 shortcuts={DEFAULT_BINDINGS}915 onShortcutsChange={vi.fn()}916 onBack={vi.fn()}917 onImport={vi.fn()}918 onExport={vi.fn()}919 onBreachCheckDone={vi.fn()}920 />921 );922 await flushView();923 sendMessageMock.mockClear();924925 926 const selects = container.querySelectorAll("select");927 changeSelect(selects[0] as HTMLSelectElement, "15");928 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);929930 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;931 appearanceCard.click();932 await flushView();933 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");934 const themeButton = Array.from(container.querySelectorAll("button")).find(935 (candidate) => candidate.textContent?.trim() === "Switch to light"936 ) as HTMLButtonElement;937 themeButton.click();938 expect(onThemeChange).toHaveBeenCalledWith("light");939940 941 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;942 protectionCard.click();943 await flushView();944945 const changeButton = Array.from(container.querySelectorAll("button")).find(946 (candidate) => candidate.textContent?.trim() === "change"947 ) as HTMLButtonElement;948 changeButton.click();949 await flushView();950951 const inputs = container.querySelectorAll('input[type="password"]');952 inputText(inputs[0] as HTMLInputElement, "current-pass");953 inputText(inputs[1] as HTMLInputElement, "new-password-123");954 inputText(inputs[2] as HTMLInputElement, "new-password-123");955 await flushView();956957 const saveButton = Array.from(container.querySelectorAll("button")).find(958 (candidate) => candidate.textContent?.trim() === "save new password"959 ) as HTMLButtonElement;960 saveButton.click();961 await flushView();962963 expect(sendMessageMock).toHaveBeenCalledWith({964 type: "CHANGE_PASSWORD",965 payload: {966 currentPassword: "current-pass",967 newPassword: "new-password-123",968 },969 });970971 972 expect(container.textContent).toContain("save your recovery words");973 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });974 });975976 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {977 sendMessageMock.mockResolvedValue({ ok: true });978 const container = mount(979 <EntryView980 entry={sampleEntry()}981 onDelete={vi.fn()}982 onFieldSaved={vi.fn(async () => undefined)}983 />984 );985986 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;987 passwordButton.click();988 await flushView();989990 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {991 type: "COPY",992 payload: { text: "secret123", label: "password", alreadyWritten: false },993 });994 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {995 type: "FIRE_CANARY_PING",996 payload: {997 webhookUrl: "https://example.com/hook",998 entryTitle: "GitHub",999 trigger: "password_copied",1000 },1001 });10021003 sendMessageMock.mockClear();1004 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1005 usernameButton.click();1006 await flushView();10071008 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1009 type: "COPY",1010 payload: { text: "june", label: "username", alreadyWritten: false },1011 });1012 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1013 type: "FIRE_CANARY_PING",1014 payload: {1015 webhookUrl: "https://example.com/hook",1016 entryTitle: "GitHub",1017 trigger: "username_copied",1018 },1019 });1020 });10211022 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1023 const container = mount(1024 <EntryView1025 entry={sampleEntry({1026 type: "passkey",1027 username: undefined,1028 password: undefined,1029 totp: undefined,1030 canary: undefined,1031 passkey: {1032 rpId: "vercel.com",1033 rpName: "Vercel",1034 credentialId: "cred-1",1035 publicKeyCose: "cose",1036 userHandle: "handle",1037 userName: "june",1038 userDisplayName: "June",1039 algorithm: -7,1040 signCount: 0,1041 aaguid: "",1042 backupEligible: false,1043 backupState: false,1044 createdAt: 1,1045 },1046 })}1047 onDelete={vi.fn()}1048 onFieldSaved={vi.fn(async () => undefined)}1049 />1050 );10511052 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1053 (button) => button.textContent?.includes("Advanced")1054 ) as HTMLButtonElement | undefined;1055 expect(advanced).toBeDefined();1056 expect(advanced?.textContent).toContain("+ Advanced");10571058 advanced!.click();1059 await flushView();10601061 expect(container.textContent).toContain("passkey · vercel.com");1062 });10631064 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1065 sendMessageMock.mockResolvedValue({ ok: true });1066 const onFieldSaved = vi.fn(async () => undefined);1067 const container = mount(1068 <EntryView1069 entry={sampleEntry()}1070 onDelete={vi.fn()}1071 onFieldSaved={onFieldSaved}1072 />1073 );10741075 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1076 passwordValue.click();1077 await flushView();10781079 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1080 inputText(passwordInput, "");1081 await flushView();10821083 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1084 saveButton.click();1085 await flushView();10861087 expect(sendMessageMock).not.toHaveBeenCalledWith(1088 expect.objectContaining({ type: "UPDATE_ENTRY" })1089 );1090 expect(onFieldSaved).not.toHaveBeenCalled();10911092 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1093 totpMain.click();10941095 await vi.waitFor(() => {1096 expect(sendMessageMock).toHaveBeenLastCalledWith({1097 type: "COPY",1098 payload: { text: "123456", label: "totp", alreadyWritten: false },1099 });1100 }, { timeout: 500 });1101 });11021103 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1104 sendMessageMock.mockResolvedValue({ ok: true });1105 const container = mount(1106 <EntryView1107 entry={sampleEntry()}1108 onDelete={vi.fn()}1109 onFieldSaved={vi.fn(async () => undefined)}1110 />1111 );11121113 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1114 usernameValue.click();1115 await flushView();11161117 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1118 usernameInput.blur();1119 await flushView();11201121 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1122 expect(sendMessageMock).not.toHaveBeenCalledWith(1123 expect.objectContaining({ type: "UPDATE_ENTRY" })1124 );1125 });11261127 it("Entry keeps the full used-email list available while editing an email", async () => {1128 const container = mount(1129 <EntryView1130 entry={sampleEntry({ email: "[email protected]" })}1131 entries={[1132 sampleEntry({ email: "[email protected]" }),1133 sampleEntry({ id: "entry-2", email: "[email protected]" }),1134 sampleEntry({ id: "entry-3", username: "duckuccino" }),1135 ]}1136 onDelete={vi.fn()}1137 onFieldSaved={vi.fn(async () => undefined)}1138 />1139 );11401141 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1142 (card) => card.querySelector(".peach-label")?.textContent === "email"1143 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1144 emailValue.click();1145 await flushView();11461147 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1148 .map((element) => element.textContent);1149 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1150 });11511152 it("Entry lets TOTP secrets be edited inline", async () => {1153 sendMessageMock.mockResolvedValue({ ok: true });1154 const onFieldSaved = vi.fn(async () => undefined);1155 const container = mount(1156 <EntryView1157 entry={sampleEntry()}1158 onDelete={vi.fn()}1159 onFieldSaved={onFieldSaved}1160 />1161 );11621163 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1164 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1165 await flushView();11661167 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1168 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1169 await flushView();11701171 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1172 saveButton.click();1173 await flushView();11741175 expect(sendMessageMock).toHaveBeenCalledWith({1176 type: "UPDATE_ENTRY",1177 payload: {1178 id: "entry-1",1179 patch: { totp: "NB2W45DFOIZA====" },1180 },1181 });1182 expect(onFieldSaved).toHaveBeenCalled();1183 });11841185 it("Vault seeds and focuses search when a search seed is provided", async () => {1186 const container = mount(1187 <VaultView1188 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1189 sortOrder="url-first"1190 searchSeed="g"1191 searchSeedNonce={1}1192 onSelectEntry={vi.fn()}1193 onSortOrderChange={vi.fn()}1194 onAdd={vi.fn()}1195 onClose={vi.fn()}1196 />1197 );11981199 await flushView();12001201 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1202 expect(search.value).toBe("g");1203 expect(document.activeElement).toBe(search);1204 });12051206 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1207 parseImportSourceMock.mockResolvedValue(1208 sampleImportResult({1209 entries: [1210 {1211 type: "login",1212 title: "Imported site",1213 username: "imported-user",1214 password: "imported-pass",1215 url: "https://imported.test",1216 },1217 ],1218 })1219 );1220 sendMessageMock.mockImplementation(async (msg: unknown) => {1221 const type = (msg as { type?: string }).type;1222 if (type === "GET_ENTRIES") {1223 return {1224 ok: true,1225 data: [1226 sampleEntry({1227 id: "existing-1",1228 title: "Imported site",1229 username: "imported-user",1230 url: "https://imported.test",1231 }),1232 ],1233 };1234 }1235 if (type === "BATCH_ADD_ENTRIES") {1236 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1237 }1238 return { ok: true };1239 });1240 const onImport = vi.fn(async () => undefined);1241 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1242 await flushView();1243 sendMessageMock.mockClear();12441245 await selectImportFile(container);1246 findButton(container, "Import 1").click();1247 await flushView();12481249 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1250 expect(sendMessageMock).toHaveBeenCalledWith({1251 type: "BATCH_ADD_ENTRIES",1252 payload: {1253 entries: [1254 expect.objectContaining({1255 title: "Imported site",1256 username: "imported-user",1257 password: "imported-pass",1258 url: "https://imported.test",1259 }),1260 ],1261 },1262 });1263 expect(onImport).toHaveBeenCalled();1264 expect(container.textContent).toContain("1 entry appears to be duplicates");1265 });12661267 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1268 parseImportSourceMock.mockResolvedValue(sampleImportResult());1269 sendMessageMock.mockImplementation(async (msg: unknown) => {1270 const type = (msg as { type?: string }).type;1271 if (type === "GET_ENTRIES") {1272 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1273 }1274 if (type === "CLEAR_VAULT_ENTRIES") {1275 return { ok: true };1276 }1277 if (type === "BATCH_ADD_ENTRIES") {1278 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1279 }1280 return { ok: true };1281 });1282 const onImport = vi.fn(async () => undefined);1283 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1284 await flushView();1285 sendMessageMock.mockClear();12861287 await selectImportFile(container);1288 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1289 await flushView();12901291 findButton(container, "Replace vault with 1").click();1292 await flushView();12931294 expect(sendMessageMock).not.toHaveBeenCalledWith(1295 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1296 );1297 expect(container.textContent).toContain("Replace forever? This cannot be undone.");12981299 findButton(container, "Yes, replace forever").click();1300 await flushView();13011302 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1303 expect(sendMessageMock).toHaveBeenCalledWith({1304 type: "BATCH_ADD_ENTRIES",1305 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1306 });1307 expect(onImport).toHaveBeenCalled();1308 });13091310 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1311 parseImportSourceMock.mockResolvedValue(sampleImportResult());1312 sendMessageMock.mockImplementation(async (msg: unknown) => {1313 const type = (msg as { type?: string }).type;1314 if (type === "CREATE_VAULT") return { ok: true };1315 if (type === "BATCH_ADD_ENTRIES") {1316 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1317 }1318 return { ok: true };1319 });1320 const onImport = vi.fn(async () => undefined);1321 const container = mount(1322 <ImportView1323 mode="onboarding"1324 replacesExistingVault={true}1325 onBack={vi.fn()}1326 onImport={onImport}1327 />1328 );13291330 await selectImportFile(container);13311332 const createButton = findButton(container, "Create vault + import 1");1333 expect(createButton.disabled).toBe(true);1334 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1335 expect(container.textContent).toContain("Use at least 8 characters");13361337 const masterInput = container.querySelector(1338 'input[placeholder="new master password"]'1339 ) as HTMLInputElement;1340 const confirmInput = container.querySelector(1341 'input[placeholder="confirm master password"]'1342 ) as HTMLInputElement;1343 const revealButton = container.querySelector(1344 'button[aria-label="Show passwords"]'1345 ) as HTMLButtonElement;1346 expect(revealButton).toBeTruthy();1347 revealButton.click();1348 await flushView();1349 expect(masterInput.type).toBe("text");1350 expect(confirmInput.type).toBe("text");13511352 inputText(masterInput, "fresh-master-password");1353 inputText(confirmInput, "fresh-master-password");1354 await flushView();13551356 expect(createButton.disabled).toBe(false);1357 createButton.click();1358 await flushView();13591360 expect(sendMessageMock).toHaveBeenCalledWith({1361 type: "CREATE_VAULT",1362 payload: { password: "fresh-master-password" },1363 });1364 expect(sendMessageMock).toHaveBeenCalledWith({1365 type: "BATCH_ADD_ENTRIES",1366 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1367 });1368 expect(onImport).toHaveBeenCalled();1369 });13701371 it("Import encrypted backups reparse with the backup password before import", async () => {1372 parseImportSourceMock1373 .mockResolvedValueOnce(1374 sampleImportResult({1375 format: "peach-json",1376 label: "Peach encrypted backup",1377 entries: [],1378 totalItems: 0,1379 requiresPassword: true,1380 })1381 )1382 .mockResolvedValueOnce(1383 sampleImportResult({1384 format: "peach-json",1385 label: "Peach encrypted backup",1386 })1387 );1388 sendMessageMock.mockImplementation(async (msg: unknown) => {1389 const type = (msg as { type?: string }).type;1390 if (type === "GET_ENTRIES") return { ok: true, data: [] };1391 if (type === "BATCH_ADD_ENTRIES") {1392 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1393 }1394 return { ok: true };1395 });1396 const onImport = vi.fn(async () => undefined);1397 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1398 await flushView();1399 sendMessageMock.mockClear();14001401 await selectImportFile(container, '{"type":"peach-export"}');1402 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1403 inputText(password, "backup-passphrase");1404 pressEnter(password);1405 await flushView();1406 await flushView();14071408 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1409 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");14101411 findButton(container, "Import 1").click();1412 await flushView();14131414 expect(sendMessageMock).toHaveBeenCalledWith({1415 type: "BATCH_ADD_ENTRIES",1416 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1417 });1418 expect(onImport).toHaveBeenCalled();1419 });1420});