contracts.test.tsx

51.7 KB · raw

1// @vitest-environment jsdom23import 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    // The Import view now opens onto a menu — click into the file subview197    // before locating the file input. Idempotent: a second click won't re-fire198    // because the menu unmounts once the subview renders.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    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock328    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          // Return 12 fake words — must pass findInvalidWords334          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    // After CREATE_VAULT success, the recovery word display is shown.366    // onUnlock is NOT called yet — user must first confirm their recovery words.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    // Guard against a vacuous pass: the disclosure must not already be on495    // screen before the user asks to remove anything.496    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();497498    remove!.click();499    for (let i = 0; i < 10; i++) await Promise.resolve();500501    // Revocation must never be presented as securing the vault against the502    // removed device — recovery words survive it and restore everything.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        // Mirror the real handler: UNPAIR_DEVICE drops the row from the528        // registry. The next LIST_PAIRED_DEVICES must not see it.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    // The list itself: extracting the relay selector must not take the hub with it.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    // The list must be refreshed and the device must be gone. The regression595    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but596    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.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    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]667    // "open settings to card I (Peach Pro) instead of card II (Protection)".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      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; 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    // ── Auto-lock + theme changes (before password change) ─────────────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    // ── Password change → recovery words display ────────────────────────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    // After a successful password change the recovery word display replaces settings.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});