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 });304305 it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {306 const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);307 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;308 const password = container.querySelector("#master-password") as HTMLInputElement;309 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;310311 inputText(password, "short");312 inputText(confirm, "short");313 await flushView();314 expect(createButton.disabled).toBe(true);315316 zxcvbnMock.mockImplementation(() => ({ score: 1 }));317 inputText(password, "weakweakweak");318 inputText(confirm, "weakweakweak");319 await flushView();320 expect(createButton.disabled).toBe(true);321 });322323 it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {324 325 sendMessageMock.mockImplementation(326 async (msg: unknown) => {327 const m = msg as { type: string; payload?: { password?: string } };328 if (m.type === "CREATE_VAULT") return { ok: true };329 if (m.type === "GET_RECOVERY_WORDS") {330 331 return {332 ok: true,333 data: [334 "abandon", "ability", "able", "about", "above", "absent",335 "absorb", "abstract", "absurd", "abuse", "access", "accident",336 ],337 };338 }339 return { ok: true };340 }341 );342 zxcvbnMock.mockImplementation(() => ({ score: 3 }));343 const onUnlock = vi.fn(async () => undefined);344 const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);345346 const password = container.querySelector("#master-password") as HTMLInputElement;347 const confirm = container.querySelector("#confirm-password") as HTMLInputElement;348 const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;349350 inputText(password, "correct horse battery staple");351 inputText(confirm, "correct horse battery staple");352 await flushView();353 expect(createButton.disabled).toBe(false);354355 pressEnter(confirm);356 await flushView();357358 expect(sendMessageMock).toHaveBeenCalledWith({359 type: "CREATE_VAULT",360 payload: { password: "correct horse battery staple" },361 });362 363 364 expect(onUnlock).not.toHaveBeenCalled();365 });366367 it("Recovery words flow submits the new master password and completes", async () => {368 sendMessageMock.mockImplementation(async (msg: unknown) => {369 const type = (msg as { type?: string }).type;370 if (type === "VALIDATE_RECOVERY_WORDS") {371 return { ok: true, data: { valid: true, invalid: [] } };372 }373 if (type === "RECOVER_WITH_WORDS") return { ok: true };374 return { ok: true };375 });376 const canvasContext = {377 clearRect: vi.fn(),378 fillText: vi.fn(),379 beginPath: vi.fn(),380 closePath: vi.fn(),381 arc: vi.fn(),382 fill: vi.fn(),383 stroke: vi.fn(),384 save: vi.fn(),385 restore: vi.fn(),386 translate: vi.fn(),387 rotate: vi.fn(),388 scale: vi.fn(),389 drawImage: vi.fn(),390 measureText: vi.fn(() => ({ width: 0 })),391 setTransform: vi.fn(),392 fillRect: vi.fn(),393 strokeRect: vi.fn(),394 } as unknown as CanvasRenderingContext2D;395 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);396397 const onRecoveryComplete = vi.fn(async () => undefined);398 const container = mount(399 <RecoveryWordEntryView400 onCancel={vi.fn()}401 onRecoveryComplete={onRecoveryComplete}402 />403 );404405 const recoveryWords = WORD_LIST.slice(0, 12);406 for (let i = 0; i < recoveryWords.length; i++) {407 const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;408 inputText(input, recoveryWords[i]!);409 }410 await flushView();411412 findButton(container, "verify words").click();413 await flushView();414415 expect(container.textContent).toContain("new master password");416417 const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;418 const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;419 inputText(newPassword, "fresh-recovery-password");420 inputText(confirmPassword, "fresh-recovery-password");421 await flushView();422423 findButton(container, "recover vault").click();424 await flushView();425426 expect(sendMessageMock).toHaveBeenCalledWith({427 type: "RECOVER_WITH_WORDS",428 payload: {429 words: recoveryWords,430 newPassword: "fresh-recovery-password",431 },432 });433 expect(onRecoveryComplete).toHaveBeenCalled();434 });435436 it("Export menu surfaces both backup formats", async () => {437 const container = mount(<ExportView onBack={vi.fn()} />);438 await flushView();439440 expect(container.textContent).toContain("PeachScript");441 expect(container.textContent).toContain("CSV");442 });443444 it("Lock can open directly on backup restore from onboarding", async () => {445 const container = mount(() => (446 <LockView447 hasVault={false}448 ready={true}449 onUnlock={vi.fn()}450 initialRestore={true}451 />452 ));453 await flushView();454455 expect(container.textContent).toContain("PeachScript");456 expect(container.textContent).toContain("Codex");457 expect(container.textContent).toContain("CSV");458 expect(container.querySelector("#master-password")).toBeNull();459 });460461 it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {462 const device = {463 deviceId: "phone-1",464 deviceName: "June's Phone",465 deviceType: "android" as const,466 longTermPublicKeyHex: "a".repeat(64),467 signingPublicKeyHex: "b".repeat(64),468 pairedAt: 1,469 trustSource: "direct" as const,470 };471 sendMessageMock.mockImplementation(async (msg: unknown) => {472 const m = msg as { type: string };473 if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };474 return { ok: true, data: null };475 });476477 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);478 for (let i = 0; i < 30; i++) await Promise.resolve();479480 const openSync = Array.from(container.querySelectorAll("button")).find((b) =>481 /sync/i.test(b.textContent ?? ""),482 );483 openSync?.click();484 for (let i = 0; i < 30; i++) await Promise.resolve();485486 const remove = Array.from(container.querySelectorAll("button")).find(487 (b) => (b.textContent ?? "").trim() === "remove",488 );489 expect(remove).toBeTruthy();490491 492 493 expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();494495 remove!.click();496 for (let i = 0; i < 10; i++) await Promise.resolve();497498 499 500 const note = container.querySelector(".peach-unpair-confirm-note");501 expect(note).toBeTruthy();502 const text = note!.textContent ?? "";503 expect(text).toContain("master password");504 expect(text).toContain("recovery words");505 expect(text).toContain("future changes");506 });507508 it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {509 const device = {510 deviceId: "phone-1",511 deviceName: "June's Phone",512 deviceType: "android" as const,513 longTermPublicKeyHex: "a".repeat(64),514 signingPublicKeyHex: "b".repeat(64),515 pairedAt: 1,516 trustSource: "direct" as const,517 };518 let paired: typeof device[] = [device];519 sendMessageMock.mockImplementation(async (msg: any) => {520 if (msg?.type === "LIST_PAIRED_DEVICES") {521 return { ok: true, data: { devices: [...paired] } };522 }523 if (msg?.type === "UNPAIR_DEVICE") {524 525 526 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);527 return { ok: true };528 }529 if (msg?.type === "RENAME_PAIRED_DEVICE") {530 paired = paired.map((d) =>531 d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,532 );533 return { ok: true };534 }535 return { ok: true, data: null };536 });537538 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);539 for (let i = 0; i < 30; i++) await Promise.resolve();540 Array.from(container.querySelectorAll("button"))541 .find((b) => /sync/i.test(b.textContent ?? ""))542 ?.click();543 for (let i = 0; i < 30; i++) await Promise.resolve();544545 546 expect(container.textContent).toContain("PAIRED DEVICES");547 expect(container.textContent).toContain("June's Phone");548 expect(container.textContent).toContain("Android");549 Array.from(container.querySelectorAll("button"))550 .find((b) => (b.textContent ?? "").trim() === "rename")!551 .click();552 const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;553 renameInput.value = "Travel phone";554 renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));555 Array.from(container.querySelectorAll("button"))556 .find((b) => (b.textContent ?? "").trim() === "save")!557 .click();558 for (let i = 0; i < 30; i++) await Promise.resolve();559 expect(sendMessageMock).toHaveBeenCalledWith({560 type: "RENAME_PAIRED_DEVICE",561 payload: { deviceId: "phone-1", name: "Travel phone" },562 });563 expect(container.textContent).toContain("Travel phone");564 const syncNowButton = container.querySelector<HTMLButtonElement>(565 'button[aria-label="Sync this vault now"]',566 )!;567 const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));568 const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;569 const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;570 expect(571 syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,572 ).toBeTruthy();573 expect(574 syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,575 ).toBeTruthy();576577 Array.from(container.querySelectorAll("button"))578 .find((b) => (b.textContent ?? "").trim() === "remove")!579 .click();580 for (let i = 0; i < 10; i++) await Promise.resolve();581 const confirm = Array.from(582 container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),583 ).find((b) => (b.textContent ?? "").trim() === "remove");584 confirm!.click();585 for (let i = 0; i < 30; i++) await Promise.resolve();586587 expect(sendMessageMock).toHaveBeenCalledWith({588 type: "UNPAIR_DEVICE",589 payload: { deviceId: "phone-1" },590 });591 592 593 594 expect(container.textContent).not.toContain("June's Phone");595 expect(container.textContent).not.toContain("PAIRED DEVICES");596 });597598 it("Settings keeps connection and recovery details collapsed until requested", async () => {599 sendMessageMock.mockImplementation(async (msg: any) => {600 if (msg?.type === "LIST_PAIRED_DEVICES") {601 return { ok: true, data: { devices: [] } };602 }603 return { ok: true, data: null };604 });605606 const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);607 for (let i = 0; i < 30; i++) await Promise.resolve();608 Array.from(container.querySelectorAll("button"))609 .find((button) => /sync/i.test(button.textContent ?? ""))610 ?.click();611 for (let i = 0; i < 20; i++) await Promise.resolve();612613 expect(container.textContent).toContain("SYNC STATUS");614 expect(container.textContent).toContain("ADD A DEVICE");615 expect(container.textContent).not.toContain("Public relay");616 expect(container.textContent).not.toContain("Rotate sync identity");617618 const connection = Array.from(container.querySelectorAll("button"))619 .find((button) => button.textContent?.includes("CONNECTION"));620 connection?.click();621 await Promise.resolve();622 expect(container.textContent).toContain("Public relay");623624 const recovery = Array.from(container.querySelectorAll("button"))625 .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));626 recovery?.click();627 await Promise.resolve();628 expect(container.textContent).toContain("Rotate sync identity");629 });630631 it("Settings requires current, new, and confirm passwords before enabling save", async () => {632 sendMessageMock.mockResolvedValue({ ok: true, data: null });633 const container = mount(634 <SettingsView635 entriesCount={1}636 breachMonitoring={true}637 lockOnClose={true}638 autoLockMinutes={5}639 trashRetentionDays={30}640 theme="dark"641 accentColor="peach"642 textSize="medium"643 reduceMotion={false}644 onBreachMonitoringChange={vi.fn()}645 onLockOnCloseChange={vi.fn()}646 onAutoLockMinutesChange={vi.fn()}647 onTrashRetentionChange={vi.fn()}648 onThemeChange={vi.fn()}649 onAccentChange={vi.fn()}650 onTextSizeChange={vi.fn()}651 onReduceMotionChange={vi.fn()}652 shortcuts={DEFAULT_BINDINGS}653 onShortcutsChange={vi.fn()}654 onBack={vi.fn()}655 onImport={vi.fn()}656 onExport={vi.fn()}657 onBreachCheckDone={vi.fn()}658 />659 );660 await flushView();661662 expect(container.querySelectorAll(".stk-card").length).toBe(7);663 664 665 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");666667 const changeButton = Array.from(container.querySelectorAll("button")).find(668 (candidate) => candidate.textContent?.trim() === "change"669 ) as HTMLButtonElement;670 changeButton.click();671 await flushView();672673 const saveButton = Array.from(container.querySelectorAll("button")).find(674 (candidate) => candidate.textContent?.trim() === "save new password"675 ) as HTMLButtonElement;676 expect(saveButton.disabled).toBe(true);677678 const inputs = container.querySelectorAll('input[type="password"]');679 inputText(inputs[0] as HTMLInputElement, "current-pass");680 inputText(inputs[1] as HTMLInputElement, "new-pass-12345");681 await flushView();682 expect(saveButton.disabled).toBe(true);683684 inputText(inputs[2] as HTMLInputElement, "new-pass-12345");685 await flushView();686 expect(saveButton.disabled).toBe(false);687 });688689 it("Settings feedback highlights the selected category and hides the form after submit", async () => {690 sendMessageMock.mockResolvedValue({ ok: true, data: null });691 const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({692 ok: true,693 json: async () => ({ success: true }),694 }));695 vi.stubGlobal("fetch", fetchMock);696697 const container = mount(698 <SettingsView699 entriesCount={1}700 breachMonitoring={true}701 lockOnClose={true}702 autoLockMinutes={5}703 trashRetentionDays={30}704 theme="dark"705 accentColor="peach"706 textSize="medium"707 reduceMotion={false}708 initialSection="feedback"709 onBreachMonitoringChange={vi.fn()}710 onLockOnCloseChange={vi.fn()}711 onAutoLockMinutesChange={vi.fn()}712 onTrashRetentionChange={vi.fn()}713 onThemeChange={vi.fn()}714 onAccentChange={vi.fn()}715 onTextSizeChange={vi.fn()}716 onReduceMotionChange={vi.fn()}717 shortcuts={DEFAULT_BINDINGS}718 onShortcutsChange={vi.fn()}719 onBack={vi.fn()}720 onImport={vi.fn()}721 onExport={vi.fn()}722 onBreachCheckDone={vi.fn()}723 />724 );725 await flushView();726727 const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;728 expect(feedbackCard.classList.contains("is-active")).toBe(true);729730 const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];731 const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;732 const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;733 expect(bugButton.getAttribute("aria-pressed")).toBe("true");734 expect(featureButton.getAttribute("aria-pressed")).toBe("false");735736 featureButton.click();737 await flushView();738739 expect(bugButton.getAttribute("aria-pressed")).toBe("false");740 expect(featureButton.getAttribute("aria-pressed")).toBe("true");741 expect(featureButton.classList.contains("is-active")).toBe(true);742743 inputText(744 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,745 '<script>alert("xss")</script> Please make this easier to see & safer.'746 );747 const sendButton = Array.from(container.querySelectorAll("button")).find(748 (candidate) => candidate.textContent?.trim() === "Send"749 ) as HTMLButtonElement;750 sendButton.click();751752 await vi.waitFor(() => {753 expect(container.textContent).toContain("Thank you");754 });755756 const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));757 expect(requestBody.subject).toContain("Feature request");758 expect(requestBody.message).toBe(759 "<script>alert("xss")</script> Please make this easier to see & safer."760 );761 expect(requestBody.message).not.toContain("<script>");762 expect(container.textContent).toContain("Someone will process it soon");763 expect(container.querySelector(".peach-feedback-textarea")).toBeNull();764 expect(container.querySelector(".peach-feedback-submit")).toBeNull();765 });766767 it("Settings keeps license activation on the full-screen celebration path", async () => {768 const onLicenseChange = vi.fn(async () => undefined);769 sendMessageMock.mockImplementation(async (msg: unknown) => {770 const type = (msg as { type?: string }).type;771 if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };772 if (type === "ACTIVATE_LICENSE") {773 return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };774 }775 if (type === "GET_ENTRIES") return { ok: true, data: [] };776 return { ok: true, data: null };777 });778779 const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);780 await flushView();781782 inputText(783 container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,784 "PEACH-TEST-KEY",785 );786 const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;787 apply.click();788789 await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));790 expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);791 expect(container.textContent).not.toContain("thank you ✦");792 });793794 it("Settings Pro star and peach clicks use the same in-card replay path", async () => {795 sendMessageMock.mockImplementation(async (msg: unknown) => {796 const type = (msg as { type?: string }).type;797 if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };798 if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };799 if (type === "GET_ENTRIES") return { ok: true, data: [] };800 return { ok: true, data: null };801 });802803 const onLicenseChangeFromStar = vi.fn();804 const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);805 await flushView();806 (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();807 expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);808 expect(onLicenseChangeFromStar).not.toHaveBeenCalled();809810 const onLicenseChangeFromPeach = vi.fn();811 const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);812 await flushView();813 (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();814 expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);815 expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();816 });817818 it("Settings feedback does not transmit when Firefox data consent is denied", async () => {819 sendMessageMock.mockResolvedValue({ ok: true, data: null });820 const fetchMock = vi.fn();821 const requestMock = vi.fn(async () => false);822 vi.stubGlobal("fetch", fetchMock);823 vi.stubGlobal("browser", {824 permissions: {825 getAll: vi.fn(async () => ({ data_collection: [] })),826 request: requestMock,827 },828 });829830 const container = mount(831 <SettingsView832 entriesCount={1}833 breachMonitoring={true}834 lockOnClose={true}835 autoLockMinutes={5}836 trashRetentionDays={30}837 theme="dark"838 accentColor="peach"839 textSize="medium"840 reduceMotion={false}841 initialSection="feedback"842 onBreachMonitoringChange={vi.fn()}843 onLockOnCloseChange={vi.fn()}844 onAutoLockMinutesChange={vi.fn()}845 onTrashRetentionChange={vi.fn()}846 onThemeChange={vi.fn()}847 onAccentChange={vi.fn()}848 onTextSizeChange={vi.fn()}849 onReduceMotionChange={vi.fn()}850 shortcuts={DEFAULT_BINDINGS}851 onShortcutsChange={vi.fn()}852 onBack={vi.fn()}853 onImport={vi.fn()}854 onExport={vi.fn()}855 onBreachCheckDone={vi.fn()}856 />857 );858 await flushView();859860 inputText(861 container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,862 "Please improve the feedback flow."863 );864 const sendButton = Array.from(container.querySelectorAll("button")).find(865 (candidate) => candidate.textContent?.trim() === "Send"866 ) as HTMLButtonElement;867 sendButton.click();868869 await vi.waitFor(() => {870 expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });871 });872 expect(fetchMock).not.toHaveBeenCalled();873 expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();874 });875876 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {877 sendMessageMock.mockImplementation(async (msg: unknown) => {878 const m = msg as { type: string };879 if (m.type === "GET_RECOVERY_WORDS") {880 return {881 ok: true,882 data: [883 "abandon", "ability", "able", "about", "above", "absent",884 "absorb", "abstract", "absurd", "abuse", "access", "accident",885 ],886 };887 }888 return { ok: true, data: null };889 });890 const onAutoLockMinutesChange = vi.fn();891 const onThemeChange = vi.fn();892 const container = mount(893 <SettingsView894 entriesCount={1}895 breachMonitoring={true}896 lockOnClose={true}897 autoLockMinutes={5}898 trashRetentionDays={30}899 theme="dark"900 accentColor="peach"901 textSize="medium"902 reduceMotion={false}903 onBreachMonitoringChange={vi.fn()}904 onLockOnCloseChange={vi.fn()}905 onAutoLockMinutesChange={onAutoLockMinutesChange}906 onTrashRetentionChange={vi.fn()}907 onThemeChange={onThemeChange}908 onAccentChange={vi.fn()}909 onTextSizeChange={vi.fn()}910 onReduceMotionChange={vi.fn()}911 shortcuts={DEFAULT_BINDINGS}912 onShortcutsChange={vi.fn()}913 onBack={vi.fn()}914 onImport={vi.fn()}915 onExport={vi.fn()}916 onBreachCheckDone={vi.fn()}917 />918 );919 await flushView();920 sendMessageMock.mockClear();921922 923 const selects = container.querySelectorAll("select");924 changeSelect(selects[0] as HTMLSelectElement, "15");925 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);926927 const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;928 appearanceCard.click();929 await flushView();930 expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");931 const themeButton = Array.from(container.querySelectorAll("button")).find(932 (candidate) => candidate.textContent?.trim() === "Switch to light"933 ) as HTMLButtonElement;934 themeButton.click();935 expect(onThemeChange).toHaveBeenCalledWith("light");936937 938 const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;939 protectionCard.click();940 await flushView();941942 const changeButton = Array.from(container.querySelectorAll("button")).find(943 (candidate) => candidate.textContent?.trim() === "change"944 ) as HTMLButtonElement;945 changeButton.click();946 await flushView();947948 const inputs = container.querySelectorAll('input[type="password"]');949 inputText(inputs[0] as HTMLInputElement, "current-pass");950 inputText(inputs[1] as HTMLInputElement, "new-password-123");951 inputText(inputs[2] as HTMLInputElement, "new-password-123");952 await flushView();953954 const saveButton = Array.from(container.querySelectorAll("button")).find(955 (candidate) => candidate.textContent?.trim() === "save new password"956 ) as HTMLButtonElement;957 saveButton.click();958 await flushView();959960 expect(sendMessageMock).toHaveBeenCalledWith({961 type: "CHANGE_PASSWORD",962 payload: {963 currentPassword: "current-pass",964 newPassword: "new-password-123",965 },966 });967968 969 expect(container.textContent).toContain("save your recovery words");970 expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });971 });972973 it("Entry copy actions send COPY and canary ping messages for password and username", async () => {974 sendMessageMock.mockResolvedValue({ ok: true });975 const container = mount(976 <EntryView977 entry={sampleEntry()}978 onDelete={vi.fn()}979 onFieldSaved={vi.fn(async () => undefined)}980 />981 );982983 const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;984 passwordButton.click();985 await flushView();986987 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {988 type: "COPY",989 payload: { text: "secret123", label: "password", alreadyWritten: false },990 });991 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {992 type: "FIRE_CANARY_PING",993 payload: {994 webhookUrl: "https://example.com/hook",995 entryTitle: "GitHub",996 trigger: "password_copied",997 },998 });9991000 sendMessageMock.mockClear();1001 const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1002 usernameButton.click();1003 await flushView();10041005 expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1006 type: "COPY",1007 payload: { text: "june", label: "username", alreadyWritten: false },1008 });1009 expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1010 type: "FIRE_CANARY_PING",1011 payload: {1012 webhookUrl: "https://example.com/hook",1013 entryTitle: "GitHub",1014 trigger: "username_copied",1015 },1016 });1017 });10181019 it("Entry shows Advanced for passkey-bearing credential entries", async () => {1020 const container = mount(1021 <EntryView1022 entry={sampleEntry({1023 type: "passkey",1024 username: undefined,1025 password: undefined,1026 totp: undefined,1027 canary: undefined,1028 passkey: {1029 rpId: "vercel.com",1030 rpName: "Vercel",1031 credentialId: "cred-1",1032 publicKeyCose: "cose",1033 userHandle: "handle",1034 userName: "june",1035 userDisplayName: "June",1036 algorithm: -7,1037 signCount: 0,1038 aaguid: "",1039 backupEligible: false,1040 backupState: false,1041 createdAt: 1,1042 },1043 })}1044 onDelete={vi.fn()}1045 onFieldSaved={vi.fn(async () => undefined)}1046 />1047 );10481049 const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1050 (button) => button.textContent?.includes("Advanced")1051 ) as HTMLButtonElement | undefined;1052 expect(advanced).toBeDefined();1053 expect(advanced?.textContent).toContain("+ Advanced");10541055 advanced!.click();1056 await flushView();10571058 expect(container.textContent).toContain("passkey · vercel.com");1059 });10601061 it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1062 sendMessageMock.mockResolvedValue({ ok: true });1063 const onFieldSaved = vi.fn(async () => undefined);1064 const container = mount(1065 <EntryView1066 entry={sampleEntry()}1067 onDelete={vi.fn()}1068 onFieldSaved={onFieldSaved}1069 />1070 );10711072 const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1073 passwordValue.click();1074 await flushView();10751076 const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1077 inputText(passwordInput, "");1078 await flushView();10791080 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1081 saveButton.click();1082 await flushView();10831084 expect(sendMessageMock).not.toHaveBeenCalledWith(1085 expect.objectContaining({ type: "UPDATE_ENTRY" })1086 );1087 expect(onFieldSaved).not.toHaveBeenCalled();10881089 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1090 totpMain.click();10911092 await vi.waitFor(() => {1093 expect(sendMessageMock).toHaveBeenLastCalledWith({1094 type: "COPY",1095 payload: { text: "123456", label: "totp", alreadyWritten: false },1096 });1097 }, { timeout: 500 });1098 });10991100 it("Entry closes an unchanged inline editor when focus leaves it", async () => {1101 sendMessageMock.mockResolvedValue({ ok: true });1102 const container = mount(1103 <EntryView1104 entry={sampleEntry()}1105 onDelete={vi.fn()}1106 onFieldSaved={vi.fn(async () => undefined)}1107 />1108 );11091110 const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1111 usernameValue.click();1112 await flushView();11131114 const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1115 usernameInput.blur();1116 await flushView();11171118 expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1119 expect(sendMessageMock).not.toHaveBeenCalledWith(1120 expect.objectContaining({ type: "UPDATE_ENTRY" })1121 );1122 });11231124 it("Entry keeps the full used-email list available while editing an email", async () => {1125 const container = mount(1126 <EntryView1127 entry={sampleEntry({ email: "[email protected]" })}1128 entries={[1129 sampleEntry({ email: "[email protected]" }),1130 sampleEntry({ id: "entry-2", email: "[email protected]" }),1131 sampleEntry({ id: "entry-3", username: "duckuccino" }),1132 ]}1133 onDelete={vi.fn()}1134 onFieldSaved={vi.fn(async () => undefined)}1135 />1136 );11371138 const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1139 (card) => card.querySelector(".peach-label")?.textContent === "email"1140 )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1141 emailValue.click();1142 await flushView();11431144 const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1145 .map((element) => element.textContent);1146 expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1147 });11481149 it("Entry lets TOTP secrets be edited inline", async () => {1150 sendMessageMock.mockResolvedValue({ ok: true });1151 const onFieldSaved = vi.fn(async () => undefined);1152 const container = mount(1153 <EntryView1154 entry={sampleEntry()}1155 onDelete={vi.fn()}1156 onFieldSaved={onFieldSaved}1157 />1158 );11591160 const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1161 totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1162 await flushView();11631164 const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1165 inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1166 await flushView();11671168 const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1169 saveButton.click();1170 await flushView();11711172 expect(sendMessageMock).toHaveBeenCalledWith({1173 type: "UPDATE_ENTRY",1174 payload: {1175 id: "entry-1",1176 patch: { totp: "NB2W45DFOIZA====" },1177 },1178 });1179 expect(onFieldSaved).toHaveBeenCalled();1180 });11811182 it("Vault seeds and focuses search when a search seed is provided", async () => {1183 const container = mount(1184 <VaultView1185 entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1186 sortOrder="url-first"1187 searchSeed="g"1188 searchSeedNonce={1}1189 onSelectEntry={vi.fn()}1190 onSortOrderChange={vi.fn()}1191 onAdd={vi.fn()}1192 onClose={vi.fn()}1193 />1194 );11951196 await flushView();11971198 const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1199 expect(search.value).toBe("g");1200 expect(document.activeElement).toBe(search);1201 });12021203 it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1204 parseImportSourceMock.mockResolvedValue(1205 sampleImportResult({1206 entries: [1207 {1208 type: "login",1209 title: "Imported site",1210 username: "imported-user",1211 password: "imported-pass",1212 url: "https://imported.test",1213 },1214 ],1215 })1216 );1217 sendMessageMock.mockImplementation(async (msg: unknown) => {1218 const type = (msg as { type?: string }).type;1219 if (type === "GET_ENTRIES") {1220 return {1221 ok: true,1222 data: [1223 sampleEntry({1224 id: "existing-1",1225 title: "Imported site",1226 username: "imported-user",1227 url: "https://imported.test",1228 }),1229 ],1230 };1231 }1232 if (type === "BATCH_ADD_ENTRIES") {1233 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1234 }1235 return { ok: true };1236 });1237 const onImport = vi.fn(async () => undefined);1238 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1239 await flushView();1240 sendMessageMock.mockClear();12411242 await selectImportFile(container);1243 findButton(container, "Import 1").click();1244 await flushView();12451246 expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1247 expect(sendMessageMock).toHaveBeenCalledWith({1248 type: "BATCH_ADD_ENTRIES",1249 payload: {1250 entries: [1251 expect.objectContaining({1252 title: "Imported site",1253 username: "imported-user",1254 password: "imported-pass",1255 url: "https://imported.test",1256 }),1257 ],1258 },1259 });1260 expect(onImport).toHaveBeenCalled();1261 expect(container.textContent).toContain("1 entry appears to be duplicates");1262 });12631264 it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1265 parseImportSourceMock.mockResolvedValue(sampleImportResult());1266 sendMessageMock.mockImplementation(async (msg: unknown) => {1267 const type = (msg as { type?: string }).type;1268 if (type === "GET_ENTRIES") {1269 return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1270 }1271 if (type === "CLEAR_VAULT_ENTRIES") {1272 return { ok: true };1273 }1274 if (type === "BATCH_ADD_ENTRIES") {1275 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1276 }1277 return { ok: true };1278 });1279 const onImport = vi.fn(async () => undefined);1280 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1281 await flushView();1282 sendMessageMock.mockClear();12831284 await selectImportFile(container);1285 (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1286 await flushView();12871288 findButton(container, "Replace vault with 1").click();1289 await flushView();12901291 expect(sendMessageMock).not.toHaveBeenCalledWith(1292 expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1293 );1294 expect(container.textContent).toContain("Replace forever? This cannot be undone.");12951296 findButton(container, "Yes, replace forever").click();1297 await flushView();12981299 expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1300 expect(sendMessageMock).toHaveBeenCalledWith({1301 type: "BATCH_ADD_ENTRIES",1302 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1303 });1304 expect(onImport).toHaveBeenCalled();1305 });13061307 it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1308 parseImportSourceMock.mockResolvedValue(sampleImportResult());1309 sendMessageMock.mockImplementation(async (msg: unknown) => {1310 const type = (msg as { type?: string }).type;1311 if (type === "CREATE_VAULT") return { ok: true };1312 if (type === "BATCH_ADD_ENTRIES") {1313 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1314 }1315 return { ok: true };1316 });1317 const onImport = vi.fn(async () => undefined);1318 const container = mount(1319 <ImportView1320 mode="onboarding"1321 replacesExistingVault={true}1322 onBack={vi.fn()}1323 onImport={onImport}1324 />1325 );13261327 await selectImportFile(container);13281329 const createButton = findButton(container, "Create vault + import 1");1330 expect(createButton.disabled).toBe(true);1331 expect(container.textContent).toContain("Restoring now replaces the locked local vault");1332 expect(container.textContent).toContain("Use at least 8 characters");13331334 const masterInput = container.querySelector(1335 'input[placeholder="new master password"]'1336 ) as HTMLInputElement;1337 const confirmInput = container.querySelector(1338 'input[placeholder="confirm master password"]'1339 ) as HTMLInputElement;1340 const revealButton = container.querySelector(1341 'button[aria-label="Show passwords"]'1342 ) as HTMLButtonElement;1343 expect(revealButton).toBeTruthy();1344 revealButton.click();1345 await flushView();1346 expect(masterInput.type).toBe("text");1347 expect(confirmInput.type).toBe("text");13481349 inputText(masterInput, "fresh-master-password");1350 inputText(confirmInput, "fresh-master-password");1351 await flushView();13521353 expect(createButton.disabled).toBe(false);1354 createButton.click();1355 await flushView();13561357 expect(sendMessageMock).toHaveBeenCalledWith({1358 type: "CREATE_VAULT",1359 payload: { password: "fresh-master-password" },1360 });1361 expect(sendMessageMock).toHaveBeenCalledWith({1362 type: "BATCH_ADD_ENTRIES",1363 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1364 });1365 expect(onImport).toHaveBeenCalled();1366 });13671368 it("Import encrypted backups reparse with the backup password before import", async () => {1369 parseImportSourceMock1370 .mockResolvedValueOnce(1371 sampleImportResult({1372 format: "peach-json",1373 label: "Peach encrypted backup",1374 entries: [],1375 totalItems: 0,1376 requiresPassword: true,1377 })1378 )1379 .mockResolvedValueOnce(1380 sampleImportResult({1381 format: "peach-json",1382 label: "Peach encrypted backup",1383 })1384 );1385 sendMessageMock.mockImplementation(async (msg: unknown) => {1386 const type = (msg as { type?: string }).type;1387 if (type === "GET_ENTRIES") return { ok: true, data: [] };1388 if (type === "BATCH_ADD_ENTRIES") {1389 return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1390 }1391 return { ok: true };1392 });1393 const onImport = vi.fn(async () => undefined);1394 const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1395 await flushView();1396 sendMessageMock.mockClear();13971398 await selectImportFile(container, '{"type":"peach-export"}');1399 const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1400 inputText(password, "backup-passphrase");1401 pressEnter(password);1402 await flushView();1403 await flushView();14041405 expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1406 expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");14071408 findButton(container, "Import 1").click();1409 await flushView();14101411 expect(sendMessageMock).toHaveBeenCalledWith({1412 type: "BATCH_ADD_ENTRIES",1413 payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1414 });1415 expect(onImport).toHaveBeenCalled();1416 });1417});