contracts.test.tsx

54.1 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/bridge", () => ({34  sendMessage: (msg: unknown) => sendMessageMock(msg),35}));3637vi.mock("../../shared/import", () => ({38  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),39}));4041vi.mock("../../shared/favicon", async (importOriginal) => {42  const actual = await importOriginal<typeof import("../../shared/favicon")>();43  return { ...actual, faviconUrl: () => null };44});4546vi.mock("../../crypto/totp", () => ({47  generateTOTP: () => ({ code: "123 456", remaining: 24 }),48  parseOtpauthURI: (value: string) =>49    value.startsWith("otpauth://")50      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }51      : null,52}));5354vi.mock("../components/GeneratorDrawer", () => ({55  GeneratorDrawer: () => null,56}));5758vi.mock("../components/RuneBackground", () => ({59  RuneBackground: () => null,60}));6162vi.mock("@zxcvbn-ts/core", () => ({63  zxcvbn: (value: string) => zxcvbnMock(value),64  zxcvbnOptions: { setOptions: vi.fn() },65}));66vi.mock("@zxcvbn-ts/language-common", () => ({67  adjacencyGraphs: {},68  dictionary: {},69}));70vi.mock("@zxcvbn-ts/language-en", () => ({71  translations: {},72  dictionary: {},73}));7475describe("popup contract tests", () => {76  const disposers: Array<() => void> = [];7778  beforeEach(() => {79    document.body.innerHTML = "";80    sendMessageMock.mockReset();81    triggerRingMock.mockReset();82    triggerPipPulseMock.mockReset();83    parseImportSourceMock.mockReset();84    zxcvbnMock.mockReset();85    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));86    parseImportSourceMock.mockResolvedValue({87      format: "unknown",88      label: "Unknown format",89      entries: [],90      totalItems: 0,91      skippedItems: 0,92      requiresPassword: false,93    });9495    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {96      return window.setTimeout(() => cb(0), 0) as unknown as number;97    });98    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));99    vi.stubGlobal("URL", {100      createObjectURL: vi.fn(() => "blob:peach-test"),101      revokeObjectURL: vi.fn(),102    });103    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});104105    (globalThis as { chrome?: typeof chrome }).chrome = {106      runtime: {107        getManifest: () => ({ version: "1.4.47" }),108        onMessage: { addListener: vi.fn() },109      },110    } as unknown as typeof chrome;111  });112113  afterEach(() => {114    while (disposers.length > 0) {115      disposers.pop()?.();116    }117    vi.restoreAllMocks();118    vi.unstubAllGlobals();119  });120121  function mount(view: JSX.Element | (() => JSX.Element)) {122    const mounted = renderView(view);123    disposers.push(mounted.dispose);124    return mounted.container;125  }126127  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {128    return {129      id: "entry-1",130      type: "login",131      title: "GitHub",132      username: "june",133      password: "secret123",134      url: "https://github.com",135      totp: "JBSWY3DPEHPK3PXP",136      canary: {137        webhookUrl: "https://example.com/hook",138      },139      createdAt: 1,140      modifiedAt: 1,141      ...overrides,142    };143  }144145  function sampleImportResult(146    overrides: Partial<ImportParseResult> = {}147  ): ImportParseResult {148    return {149      format: "csv",150      label: "CSV import",151      entries: [152        {153          type: "login",154          title: "Imported site",155          username: "imported-user",156          password: "imported-pass",157          url: "https://imported.test",158        },159      ],160      totalItems: 1,161      skippedItems: 0,162      requiresPassword: false,163      ...overrides,164    };165  }166167  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {168    return {169      entriesCount: 1,170      breachMonitoring: true,171      lockOnClose: true,172      autoLockMinutes: 5,173      trashRetentionDays: 30,174      theme: "dark" as const,175      accentColor: "peach" as const,176      textSize: "medium" as const,177      reduceMotion: false,178      onBreachMonitoringChange: vi.fn(),179      onLockOnCloseChange: vi.fn(),180      onAutoLockMinutesChange: vi.fn(),181      onTrashRetentionChange: vi.fn(),182      onThemeChange: vi.fn(),183      onAccentChange: vi.fn(),184      onTextSizeChange: vi.fn(),185      onReduceMotionChange: vi.fn(),186      shortcuts: DEFAULT_BINDINGS,187      onShortcutsChange: vi.fn(),188      onBack: vi.fn(),189      onImport: vi.fn(),190      onExport: vi.fn(),191      onBreachCheckDone: vi.fn(),192      ...overrides,193    };194  }195196  async function selectImportFile(197    container: HTMLElement,198    contents = '{"export":"test"}'199  ) {200    // The Import view now opens onto a menu — click into the file subview201    // before locating the file input. Idempotent: a second click won't re-fire202    // because the menu unmounts once the subview renders.203    const fileCard = findButton(container, "Choose file →");204    if (fileCard) {205      fileCard.click();206      await flushView();207    }208    const input = container.querySelector('input[type="file"]') as HTMLInputElement;209    const file = new File([contents], "import.json", { type: "application/json" });210    Object.defineProperty(input, "files", {211      configurable: true,212      value: [file],213    });214    input.dispatchEvent(new Event("change", { bubbles: true }));215    await flushView();216    await flushView();217  }218219  function findButton(container: HTMLElement, text: string): HTMLButtonElement {220    return Array.from(container.querySelectorAll("button")).find(221      (candidate) => candidate.textContent?.trim() === text222    ) as HTMLButtonElement;223  }224225  it("Lock sends UNLOCK with the entered passphrase", async () => {226    sendMessageMock.mockResolvedValue({ ok: true });227    const onUnlock = vi.fn(async () => undefined);228    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);229230    const password = container.querySelector("#unlock-password") as HTMLInputElement;231    inputText(password, "correct horse battery staple");232    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();233    await flushView();234235    expect(sendMessageMock).toHaveBeenCalledWith({236      type: "UNLOCK",237      payload: { password: "correct horse battery staple" },238    });239    expect(onUnlock).toHaveBeenCalled();240  });241242  it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {243    sendMessageMock.mockImplementation(async (msg: unknown) => {244      return (msg as { type: string }).type === "GET_SETTINGS"245        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }246        : { ok: true };247    });248    const onUnlock = vi.fn(async () => undefined);249    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);250    await flushView();251252    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");253    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();254    await flushView();255256    expect(sendMessageMock).toHaveBeenCalledWith({257      type: "UNLOCK_WITH_PIN",258      payload: { pin: "1234" },259    });260    expect(onUnlock).toHaveBeenCalled();261    expect(container.textContent).toContain("use master password");262  });263264  it("places the PIN method switch below the unlock button", async () => {265    sendMessageMock.mockImplementation(async (msg: unknown) => {266      return (msg as { type: string }).type === "GET_SETTINGS"267        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }268        : { ok: true };269    });270    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);271    await flushView();272273    const buttons = Array.from(container.querySelectorAll("button"));274    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");275    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");276    expect(pinIndex).toBeGreaterThan(unlockIndex);277  });278279  it("keeps standalone note creation in Notes mode", async () => {280    const chromeApi = globalThis.chrome as unknown as {281      tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };282    };283    chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };284285    const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);286    await flushView();287288    expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))289      .toEqual(["login", "card"]);290    expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();291  });292293  it("gives Notes mode the full note-formatting toolbar", async () => {294    const container = mount(295      <NotesEditor296        note={null}297        onBack={vi.fn()}298        onSaved={vi.fn()}299        onDelete={vi.fn()}300      />301    );302    await flushView();303304    expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();305    expect(container.querySelector('[title="Divider"]')).toBeTruthy();306    expect(container.querySelector('[title="Quote"]')).toBeTruthy();307    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);308    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);309    expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");310  });311312  it("saves formatting applied by the Notes toolbar before leaving", async () => {313    const savedNote = {314      id: "note-1",315      type: "note" as const,316      title: "Untitled",317      notes: "<b>formatted</b>",318      createdAt: 1,319      modifiedAt: 2,320    };321    sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });322    const originalExecCommand = document.execCommand;323    const execCommandMock = vi.fn(() => {324      const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");325      if (body) body.innerHTML = "<b>formatted</b>";326      return true;327    });328    Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });329330    try {331      const container = mount(332        <NotesEditor333          note={null}334          onBack={vi.fn()}335          onSaved={vi.fn()}336          onDelete={vi.fn()}337        />338      );339      await flushView();340      container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(341        new MouseEvent("mousedown", { bubbles: true, cancelable: true })342      );343      expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);344      container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();345      await flushView();346347      expect(sendMessageMock).toHaveBeenCalledWith({348        type: "SAVE_NOTE",349        payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },350      });351    } finally {352      Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });353    }354  });355356  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {357    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);358    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;359    const password = container.querySelector("#master-password") as HTMLInputElement;360    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;361362    inputText(password, "short");363    inputText(confirm, "short");364    await flushView();365    expect(createButton.disabled).toBe(true);366367    zxcvbnMock.mockImplementation(() => ({ score: 1 }));368    inputText(password, "weakweakweak");369    inputText(confirm, "weakweakweak");370    await flushView();371    expect(createButton.disabled).toBe(true);372  });373374  it("Lock create-vault header pins the wordmark left with back on the right", async () => {375    const container = mount(376      <LockView377        hasVault={false}378        ready={true}379        onUnlock={vi.fn()}380        onBackToOnboarding={() => {}}381      />382    );383    const header = container.querySelector(".peach-newvault-header") as HTMLElement;384    const wordmark = header.querySelector(".peach-wordmark-stack");385    const back = header.querySelector(".peach-newvault-back");386    expect(wordmark).toBeTruthy();387    expect(back).toBeTruthy();388    // Wordmark first so it never shifts between onboarding screens; back389    // takes the opposite corner instead of displacing it.390    expect(header.firstElementChild).toBe(wordmark);391    expect(header.lastElementChild).toBe(back);392    await flushView();393  });394395  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {396    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock397    sendMessageMock.mockImplementation(398      async (msg: unknown) => {399        const m = msg as { type: string; payload?: { password?: string } };400        if (m.type === "CREATE_VAULT") return { ok: true };401        if (m.type === "GET_RECOVERY_WORDS") {402          // Return 12 fake words — must pass findInvalidWords403          return {404            ok: true,405            data: [406              "abandon", "ability", "able", "about", "above", "absent",407              "absorb", "abstract", "absurd", "abuse", "access", "accident",408            ],409          };410        }411        return { ok: true };412      }413    );414    zxcvbnMock.mockImplementation(() => ({ score: 3 }));415    const onUnlock = vi.fn(async () => undefined);416    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);417418    const password = container.querySelector("#master-password") as HTMLInputElement;419    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;420    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;421422    inputText(password, "correct horse battery staple");423    inputText(confirm, "correct horse battery staple");424    await flushView();425    expect(createButton.disabled).toBe(false);426427    pressEnter(confirm);428    await flushView();429430    expect(sendMessageMock).toHaveBeenCalledWith({431      type: "CREATE_VAULT",432      payload: { password: "correct horse battery staple" },433    });434    // After CREATE_VAULT success, the recovery word display is shown.435    // onUnlock is NOT called yet — user must first confirm their recovery words.436    expect(onUnlock).not.toHaveBeenCalled();437  });438439  it("Recovery words flow submits the new master password and completes", async () => {440    sendMessageMock.mockImplementation(async (msg: unknown) => {441      const type = (msg as { type?: string }).type;442      if (type === "VALIDATE_RECOVERY_WORDS") {443        return { ok: true, data: { valid: true, invalid: [] } };444      }445      if (type === "RECOVER_WITH_WORDS") return { ok: true };446      return { ok: true };447    });448    const canvasContext = {449      clearRect: vi.fn(),450      fillText: vi.fn(),451      beginPath: vi.fn(),452      closePath: vi.fn(),453      arc: vi.fn(),454      fill: vi.fn(),455      stroke: vi.fn(),456      save: vi.fn(),457      restore: vi.fn(),458      translate: vi.fn(),459      rotate: vi.fn(),460      scale: vi.fn(),461      drawImage: vi.fn(),462      measureText: vi.fn(() => ({ width: 0 })),463      setTransform: vi.fn(),464      fillRect: vi.fn(),465      strokeRect: vi.fn(),466    } as unknown as CanvasRenderingContext2D;467    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);468469    const onRecoveryComplete = vi.fn(async () => undefined);470    const container = mount(471      <RecoveryWordEntryView472        onCancel={vi.fn()}473        onRecoveryComplete={onRecoveryComplete}474      />475    );476477    const recoveryWords = WORD_LIST.slice(0, 12);478    for (let i = 0; i < recoveryWords.length; i++) {479      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;480      inputText(input, recoveryWords[i]!);481    }482    await flushView();483484    findButton(container, "verify words").click();485    await flushView();486487    expect(container.textContent).toContain("new master password");488489    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;490    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;491    inputText(newPassword, "fresh-recovery-password");492    inputText(confirmPassword, "fresh-recovery-password");493    await flushView();494495    findButton(container, "recover vault").click();496    await flushView();497498    expect(sendMessageMock).toHaveBeenCalledWith({499      type: "RECOVER_WITH_WORDS",500      payload: {501        words: recoveryWords,502        newPassword: "fresh-recovery-password",503      },504    });505    expect(onRecoveryComplete).toHaveBeenCalled();506  });507508  it("Export menu surfaces both backup formats", async () => {509    const container = mount(<ExportView onBack={vi.fn()} />);510    await flushView();511512    expect(container.textContent).toContain("PeachScript");513    expect(container.textContent).toContain("CSV");514  });515516  it("Lock can open directly on backup restore from onboarding", async () => {517    const container = mount(() => (518      <LockView519        hasVault={false}520        ready={true}521        onUnlock={vi.fn()}522        initialRestore={true}523      />524    ));525    await flushView();526527    expect(container.textContent).toContain("PeachScript");528    expect(container.textContent).toContain("Codex");529    expect(container.textContent).toContain("CSV");530    expect(container.querySelector("#master-password")).toBeNull();531  });532533  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {534    const device = {535      deviceId: "phone-1",536      deviceName: "June's Phone",537      deviceType: "android" as const,538      longTermPublicKeyHex: "a".repeat(64),539      signingPublicKeyHex: "b".repeat(64),540      pairedAt: 1,541      trustSource: "direct" as const,542    };543    sendMessageMock.mockImplementation(async (msg: unknown) => {544      const m = msg as { type: string };545      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };546      return { ok: true, data: null };547    });548549    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);550    for (let i = 0; i < 30; i++) await Promise.resolve();551552    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>553      /sync/i.test(b.textContent ?? ""),554    );555    openSync?.click();556    for (let i = 0; i < 30; i++) await Promise.resolve();557558    const remove = Array.from(container.querySelectorAll("button")).find(559      (b) => (b.textContent ?? "").trim() === "remove",560    );561    expect(remove).toBeTruthy();562563    // Guard against a vacuous pass: the disclosure must not already be on564    // screen before the user asks to remove anything.565    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();566567    remove!.click();568    for (let i = 0; i < 10; i++) await Promise.resolve();569570    // Revocation must never be presented as securing the vault against the571    // removed device — recovery words survive it and restore everything.572    const note = container.querySelector(".peach-unpair-confirm-note");573    expect(note).toBeTruthy();574    const text = note!.textContent ?? "";575    expect(text).toContain("master password");576    expect(text).toContain("recovery words");577    expect(text).toContain("future changes");578  });579580  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {581    const device = {582      deviceId: "phone-1",583      deviceName: "June's Phone",584      deviceType: "android" as const,585      longTermPublicKeyHex: "a".repeat(64),586      signingPublicKeyHex: "b".repeat(64),587      pairedAt: 1,588      trustSource: "direct" as const,589    };590    let paired: typeof device[] = [device];591    sendMessageMock.mockImplementation(async (msg: any) => {592      if (msg?.type === "LIST_PAIRED_DEVICES") {593        return { ok: true, data: { devices: [...paired] } };594      }595      if (msg?.type === "UNPAIR_DEVICE") {596        // Mirror the real handler: UNPAIR_DEVICE drops the row from the597        // registry. The next LIST_PAIRED_DEVICES must not see it.598        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);599        return { ok: true };600      }601      if (msg?.type === "RENAME_PAIRED_DEVICE") {602        paired = paired.map((d) =>603          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,604        );605        return { ok: true };606      }607      return { ok: true, data: null };608    });609610    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);611    for (let i = 0; i < 30; i++) await Promise.resolve();612    Array.from(container.querySelectorAll("button"))613      .find((b) => /sync/i.test(b.textContent ?? ""))614      ?.click();615    for (let i = 0; i < 30; i++) await Promise.resolve();616617    // The list itself: extracting the relay selector must not take the hub with it.618    expect(container.textContent).toContain("PAIRED DEVICES");619    expect(container.textContent).toContain("June's Phone");620    expect(container.textContent).toContain("Android");621    Array.from(container.querySelectorAll("button"))622      .find((b) => (b.textContent ?? "").trim() === "rename")!623      .click();624    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;625    renameInput.value = "Travel phone";626    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));627    Array.from(container.querySelectorAll("button"))628      .find((b) => (b.textContent ?? "").trim() === "save")!629      .click();630    for (let i = 0; i < 30; i++) await Promise.resolve();631    expect(sendMessageMock).toHaveBeenCalledWith({632      type: "RENAME_PAIRED_DEVICE",633      payload: { deviceId: "phone-1", name: "Travel phone" },634    });635    expect(container.textContent).toContain("Travel phone");636    const syncNowButton = container.querySelector<HTMLButtonElement>(637      'button[aria-label="Sync this vault now"]',638    )!;639    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));640    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;641    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;642    expect(643      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,644    ).toBeTruthy();645    expect(646      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,647    ).toBeTruthy();648649    Array.from(container.querySelectorAll("button"))650      .find((b) => (b.textContent ?? "").trim() === "remove")!651      .click();652    for (let i = 0; i < 10; i++) await Promise.resolve();653    const confirm = Array.from(654      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),655    ).find((b) => (b.textContent ?? "").trim() === "remove");656    confirm!.click();657    for (let i = 0; i < 30; i++) await Promise.resolve();658659    expect(sendMessageMock).toHaveBeenCalledWith({660      type: "UNPAIR_DEVICE",661      payload: { deviceId: "phone-1" },662    });663    // The list must be refreshed and the device must be gone. The regression664    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but665    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.666    expect(container.textContent).not.toContain("June's Phone");667    expect(container.textContent).not.toContain("PAIRED DEVICES");668  });669670  it("Settings keeps connection and recovery details collapsed until requested", async () => {671    sendMessageMock.mockImplementation(async (msg: any) => {672      if (msg?.type === "LIST_PAIRED_DEVICES") {673        return { ok: true, data: { devices: [] } };674      }675      return { ok: true, data: null };676    });677678    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);679    for (let i = 0; i < 30; i++) await Promise.resolve();680    Array.from(container.querySelectorAll("button"))681      .find((button) => /sync/i.test(button.textContent ?? ""))682      ?.click();683    for (let i = 0; i < 20; i++) await Promise.resolve();684685    expect(container.textContent).toContain("SYNC STATUS");686    expect(container.textContent).toContain("ADD A DEVICE");687    expect(container.textContent).not.toContain("Public relay");688    expect(container.textContent).not.toContain("Rotate sync identity");689690    const connection = Array.from(container.querySelectorAll("button"))691      .find((button) => button.textContent?.includes("CONNECTION"));692    connection?.click();693    await Promise.resolve();694    expect(container.textContent).toContain("Public relay");695696    const recovery = Array.from(container.querySelectorAll("button"))697      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));698    recovery?.click();699    await Promise.resolve();700    expect(container.textContent).toContain("Rotate sync identity");701  });702703  it("Settings requires current, new, and confirm passwords before enabling save", async () => {704    sendMessageMock.mockResolvedValue({ ok: true, data: null });705    const container = mount(706      <SettingsView707        entriesCount={1}708        breachMonitoring={true}709        lockOnClose={true}710        autoLockMinutes={5}711        trashRetentionDays={30}712        theme="dark"713        accentColor="peach"714        textSize="medium"715        reduceMotion={false}716        onBreachMonitoringChange={vi.fn()}717        onLockOnCloseChange={vi.fn()}718        onAutoLockMinutesChange={vi.fn()}719        onTrashRetentionChange={vi.fn()}720        onThemeChange={vi.fn()}721        onAccentChange={vi.fn()}722        onTextSizeChange={vi.fn()}723        onReduceMotionChange={vi.fn()}724        shortcuts={DEFAULT_BINDINGS}725        onShortcutsChange={vi.fn()}726        onBack={vi.fn()}727        onImport={vi.fn()}728        onExport={vi.fn()}729        onBreachCheckDone={vi.fn()}730      />731    );732    await flushView();733734    expect(container.querySelectorAll(".stk-card").length).toBe(7);735    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]736    // "open settings to card I (Peach Pro) instead of card II (Protection)".737    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");738739    const changeButton = Array.from(container.querySelectorAll("button")).find(740      (candidate) => candidate.textContent?.trim() === "change"741    ) as HTMLButtonElement;742    changeButton.click();743    await flushView();744745    const saveButton = Array.from(container.querySelectorAll("button")).find(746      (candidate) => candidate.textContent?.trim() === "save new password"747    ) as HTMLButtonElement;748    expect(saveButton.disabled).toBe(true);749750    const inputs = container.querySelectorAll('input[type="password"]');751    inputText(inputs[0] as HTMLInputElement, "current-pass");752    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");753    await flushView();754    expect(saveButton.disabled).toBe(true);755756    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");757    await flushView();758    expect(saveButton.disabled).toBe(false);759  });760761  it("Settings feedback highlights the selected category and hides the form after submit", async () => {762    sendMessageMock.mockResolvedValue({ ok: true, data: null });763    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({764      ok: true,765      json: async () => ({ success: true }),766    }));767    vi.stubGlobal("fetch", fetchMock);768769    const container = mount(770      <SettingsView771        entriesCount={1}772        breachMonitoring={true}773        lockOnClose={true}774        autoLockMinutes={5}775        trashRetentionDays={30}776        theme="dark"777        accentColor="peach"778        textSize="medium"779        reduceMotion={false}780        initialSection="feedback"781        onBreachMonitoringChange={vi.fn()}782        onLockOnCloseChange={vi.fn()}783        onAutoLockMinutesChange={vi.fn()}784        onTrashRetentionChange={vi.fn()}785        onThemeChange={vi.fn()}786        onAccentChange={vi.fn()}787        onTextSizeChange={vi.fn()}788        onReduceMotionChange={vi.fn()}789        shortcuts={DEFAULT_BINDINGS}790        onShortcutsChange={vi.fn()}791        onBack={vi.fn()}792        onImport={vi.fn()}793        onExport={vi.fn()}794        onBreachCheckDone={vi.fn()}795      />796    );797    await flushView();798799    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;800    expect(feedbackCard.classList.contains("is-active")).toBe(true);801802    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];803    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;804    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;805    expect(bugButton.getAttribute("aria-pressed")).toBe("true");806    expect(featureButton.getAttribute("aria-pressed")).toBe("false");807808    featureButton.click();809    await flushView();810811    expect(bugButton.getAttribute("aria-pressed")).toBe("false");812    expect(featureButton.getAttribute("aria-pressed")).toBe("true");813    expect(featureButton.classList.contains("is-active")).toBe(true);814815    inputText(816      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,817      '<script>alert("xss")</script> Please make this easier to see & safer.'818    );819    const sendButton = Array.from(container.querySelectorAll("button")).find(820      (candidate) => candidate.textContent?.trim() === "Send"821    ) as HTMLButtonElement;822    sendButton.click();823824    await vi.waitFor(() => {825      expect(container.textContent).toContain("Thank you");826    });827828    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));829    expect(requestBody.subject).toContain("Feature request");830    expect(requestBody.message).toBe(831      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."832    );833    expect(requestBody.message).not.toContain("<script>");834    expect(container.textContent).toContain("Someone will process it soon");835    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();836    expect(container.querySelector(".peach-feedback-submit")).toBeNull();837  });838839  it("Settings keeps license activation on the full-screen celebration path", async () => {840    const onLicenseChange = vi.fn(async () => undefined);841    sendMessageMock.mockImplementation(async (msg: unknown) => {842      const type = (msg as { type?: string }).type;843      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };844      if (type === "ACTIVATE_LICENSE") {845        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };846      }847      if (type === "GET_ENTRIES") return { ok: true, data: [] };848      return { ok: true, data: null };849    });850851    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);852    await flushView();853854    inputText(855      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,856      "PEACH-TEST-KEY",857    );858    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;859    apply.click();860861    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));862    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);863    expect(container.textContent).not.toContain("thank you ✦");864  });865866  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {867    sendMessageMock.mockImplementation(async (msg: unknown) => {868      const type = (msg as { type?: string }).type;869      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };870      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };871      if (type === "GET_ENTRIES") return { ok: true, data: [] };872      return { ok: true, data: null };873    });874875    const onLicenseChangeFromStar = vi.fn();876    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);877    await flushView();878    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();879    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);880    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();881882    const onLicenseChangeFromPeach = vi.fn();883    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);884    await flushView();885    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();886    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);887    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();888  });889890  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {891    sendMessageMock.mockResolvedValue({ ok: true, data: null });892    const fetchMock = vi.fn();893    const requestMock = vi.fn(async () => false);894    vi.stubGlobal("fetch", fetchMock);895    vi.stubGlobal("browser", {896      permissions: {897        getAll: vi.fn(async () => ({ data_collection: [] })),898        request: requestMock,899      },900    });901902    const container = mount(903      <SettingsView904        entriesCount={1}905        breachMonitoring={true}906        lockOnClose={true}907        autoLockMinutes={5}908        trashRetentionDays={30}909        theme="dark"910        accentColor="peach"911        textSize="medium"912        reduceMotion={false}913        initialSection="feedback"914        onBreachMonitoringChange={vi.fn()}915        onLockOnCloseChange={vi.fn()}916        onAutoLockMinutesChange={vi.fn()}917        onTrashRetentionChange={vi.fn()}918        onThemeChange={vi.fn()}919        onAccentChange={vi.fn()}920        onTextSizeChange={vi.fn()}921        onReduceMotionChange={vi.fn()}922        shortcuts={DEFAULT_BINDINGS}923        onShortcutsChange={vi.fn()}924        onBack={vi.fn()}925        onImport={vi.fn()}926        onExport={vi.fn()}927        onBreachCheckDone={vi.fn()}928      />929    );930    await flushView();931932    inputText(933      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,934      "Please improve the feedback flow."935    );936    const sendButton = Array.from(container.querySelectorAll("button")).find(937      (candidate) => candidate.textContent?.trim() === "Send"938    ) as HTMLButtonElement;939    sendButton.click();940941    await vi.waitFor(() => {942      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });943    });944    expect(fetchMock).not.toHaveBeenCalled();945    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();946  });947948  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {949    sendMessageMock.mockImplementation(async (msg: unknown) => {950      const m = msg as { type: string };951      if (m.type === "GET_RECOVERY_WORDS") {952        return {953          ok: true,954          data: [955            "abandon", "ability", "able", "about", "above", "absent",956            "absorb", "abstract", "absurd", "abuse", "access", "accident",957          ],958        };959      }960      return { ok: true, data: null };961    });962    const onAutoLockMinutesChange = vi.fn();963    const onThemeChange = vi.fn();964    const container = mount(965      <SettingsView966        entriesCount={1}967        breachMonitoring={true}968        lockOnClose={true}969        autoLockMinutes={5}970        trashRetentionDays={30}971        theme="dark"972        accentColor="peach"973        textSize="medium"974        reduceMotion={false}975        onBreachMonitoringChange={vi.fn()}976        onLockOnCloseChange={vi.fn()}977        onAutoLockMinutesChange={onAutoLockMinutesChange}978        onTrashRetentionChange={vi.fn()}979        onThemeChange={onThemeChange}980        onAccentChange={vi.fn()}981        onTextSizeChange={vi.fn()}982        onReduceMotionChange={vi.fn()}983        shortcuts={DEFAULT_BINDINGS}984        onShortcutsChange={vi.fn()}985        onBack={vi.fn()}986        onImport={vi.fn()}987        onExport={vi.fn()}988        onBreachCheckDone={vi.fn()}989      />990    );991    await flushView();992    sendMessageMock.mockClear();993994    // ── Auto-lock + theme changes (before password change) ─────────────995    const selects = container.querySelectorAll("select");996    changeSelect(selects[0] as HTMLSelectElement, "15");997    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);998999    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1000    appearanceCard.click();1001    await flushView();1002    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1003    const themeButton = Array.from(container.querySelectorAll("button")).find(1004      (candidate) => candidate.textContent?.trim() === "Switch to light"1005    ) as HTMLButtonElement;1006    themeButton.click();1007    expect(onThemeChange).toHaveBeenCalledWith("light");10081009    // ── Password change → recovery words display ────────────────────────1010    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1011    protectionCard.click();1012    await flushView();10131014    const changeButton = Array.from(container.querySelectorAll("button")).find(1015      (candidate) => candidate.textContent?.trim() === "change"1016    ) as HTMLButtonElement;1017    changeButton.click();1018    await flushView();10191020    const inputs = container.querySelectorAll('input[type="password"]');1021    inputText(inputs[0] as HTMLInputElement, "current-pass");1022    inputText(inputs[1] as HTMLInputElement, "new-password-123");1023    inputText(inputs[2] as HTMLInputElement, "new-password-123");1024    await flushView();10251026    const saveButton = Array.from(container.querySelectorAll("button")).find(1027      (candidate) => candidate.textContent?.trim() === "save new password"1028    ) as HTMLButtonElement;1029    saveButton.click();1030    await flushView();10311032    expect(sendMessageMock).toHaveBeenCalledWith({1033      type: "CHANGE_PASSWORD",1034      payload: {1035        currentPassword: "current-pass",1036        newPassword: "new-password-123",1037      },1038    });10391040    // After a successful password change the recovery word display replaces settings.1041    expect(container.textContent).toContain("save your recovery words");1042    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1043  });10441045  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1046    sendMessageMock.mockResolvedValue({ ok: true });1047    const container = mount(1048      <EntryView1049        entry={sampleEntry()}1050        onDelete={vi.fn()}1051        onFieldSaved={vi.fn(async () => undefined)}1052      />1053    );10541055    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1056    passwordButton.click();1057    await flushView();10581059    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1060      type: "COPY",1061      payload: { text: "secret123", label: "password", alreadyWritten: false },1062    });1063    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1064      type: "FIRE_CANARY_PING",1065      payload: {1066        webhookUrl: "https://example.com/hook",1067        entryTitle: "GitHub",1068        trigger: "password_copied",1069      },1070    });10711072    sendMessageMock.mockClear();1073    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1074    usernameButton.click();1075    await flushView();10761077    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1078      type: "COPY",1079      payload: { text: "june", label: "username", alreadyWritten: false },1080    });1081    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1082      type: "FIRE_CANARY_PING",1083      payload: {1084        webhookUrl: "https://example.com/hook",1085        entryTitle: "GitHub",1086        trigger: "username_copied",1087      },1088    });1089  });10901091  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1092    const container = mount(1093      <EntryView1094        entry={sampleEntry({1095          type: "passkey",1096          username: undefined,1097          password: undefined,1098          totp: undefined,1099          canary: undefined,1100          passkey: {1101            rpId: "vercel.com",1102            rpName: "Vercel",1103            credentialId: "cred-1",1104            publicKeyCose: "cose",1105            userHandle: "handle",1106            userName: "june",1107            userDisplayName: "June",1108            algorithm: -7,1109            signCount: 0,1110            aaguid: "",1111            backupEligible: false,1112            backupState: false,1113            createdAt: 1,1114          },1115        })}1116        onDelete={vi.fn()}1117        onFieldSaved={vi.fn(async () => undefined)}1118      />1119    );11201121    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1122      (button) => button.textContent?.includes("Advanced")1123    ) as HTMLButtonElement | undefined;1124    expect(advanced).toBeDefined();1125    expect(advanced?.textContent).toContain("+ Advanced");11261127    advanced!.click();1128    await flushView();11291130    expect(container.textContent).toContain("passkey · vercel.com");1131  });11321133  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1134    sendMessageMock.mockResolvedValue({ ok: true });1135    const onFieldSaved = vi.fn(async () => undefined);1136    const container = mount(1137      <EntryView1138        entry={sampleEntry()}1139        onDelete={vi.fn()}1140        onFieldSaved={onFieldSaved}1141      />1142    );11431144    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1145    passwordValue.click();1146    await flushView();11471148    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1149    inputText(passwordInput, "");1150    await flushView();11511152    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1153    saveButton.click();1154    await flushView();11551156    expect(sendMessageMock).not.toHaveBeenCalledWith(1157      expect.objectContaining({ type: "UPDATE_ENTRY" })1158    );1159    expect(onFieldSaved).not.toHaveBeenCalled();11601161    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1162    totpMain.click();11631164    await vi.waitFor(() => {1165      expect(sendMessageMock).toHaveBeenLastCalledWith({1166        type: "COPY",1167        payload: { text: "123456", label: "totp", alreadyWritten: false },1168      });1169    }, { timeout: 500 });1170  });11711172  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1173    sendMessageMock.mockResolvedValue({ ok: true });1174    const container = mount(1175      <EntryView1176        entry={sampleEntry()}1177        onDelete={vi.fn()}1178        onFieldSaved={vi.fn(async () => undefined)}1179      />1180    );11811182    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1183    usernameValue.click();1184    await flushView();11851186    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1187    usernameInput.blur();1188    await flushView();11891190    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1191    expect(sendMessageMock).not.toHaveBeenCalledWith(1192      expect.objectContaining({ type: "UPDATE_ENTRY" })1193    );1194  });11951196  it("Entry keeps the full used-email list available while editing an email", async () => {1197    const container = mount(1198      <EntryView1199        entry={sampleEntry({ email: "[email protected]" })}1200        entries={[1201          sampleEntry({ email: "[email protected]" }),1202          sampleEntry({ id: "entry-2", email: "[email protected]" }),1203          sampleEntry({ id: "entry-3", username: "duckuccino" }),1204        ]}1205        onDelete={vi.fn()}1206        onFieldSaved={vi.fn(async () => undefined)}1207      />1208    );12091210    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1211      (card) => card.querySelector(".peach-label")?.textContent === "email"1212    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1213    emailValue.click();1214    await flushView();12151216    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1217      .map((element) => element.textContent);1218    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1219  });12201221  it("Entry lets TOTP secrets be edited inline", async () => {1222    sendMessageMock.mockResolvedValue({ ok: true });1223    const onFieldSaved = vi.fn(async () => undefined);1224    const container = mount(1225      <EntryView1226        entry={sampleEntry()}1227        onDelete={vi.fn()}1228        onFieldSaved={onFieldSaved}1229      />1230    );12311232    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1233    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1234    await flushView();12351236    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1237    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1238    await flushView();12391240    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1241    saveButton.click();1242    await flushView();12431244    expect(sendMessageMock).toHaveBeenCalledWith({1245      type: "UPDATE_ENTRY",1246      payload: {1247        id: "entry-1",1248        patch: { totp: "NB2W45DFOIZA====" },1249      },1250    });1251    expect(onFieldSaved).toHaveBeenCalled();1252  });12531254  it("Vault seeds and focuses search when a search seed is provided", async () => {1255    const container = mount(1256      <VaultView1257        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1258        sortOrder="url-first"1259        searchSeed="g"1260        searchSeedNonce={1}1261        onSelectEntry={vi.fn()}1262        onSortOrderChange={vi.fn()}1263        onAdd={vi.fn()}1264        onClose={vi.fn()}1265      />1266    );12671268    await flushView();12691270    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1271    expect(search.value).toBe("g");1272    expect(document.activeElement).toBe(search);1273  });12741275  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1276    parseImportSourceMock.mockResolvedValue(1277      sampleImportResult({1278        entries: [1279          {1280            type: "login",1281            title: "Imported site",1282            username: "imported-user",1283            password: "imported-pass",1284            url: "https://imported.test",1285          },1286        ],1287      })1288    );1289    sendMessageMock.mockImplementation(async (msg: unknown) => {1290      const type = (msg as { type?: string }).type;1291      if (type === "GET_ENTRIES") {1292        return {1293          ok: true,1294          data: [1295            sampleEntry({1296              id: "existing-1",1297              title: "Imported site",1298              username: "imported-user",1299              url: "https://imported.test",1300            }),1301          ],1302        };1303      }1304      if (type === "BATCH_ADD_ENTRIES") {1305        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1306      }1307      return { ok: true };1308    });1309    const onImport = vi.fn(async () => undefined);1310    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1311    await flushView();1312    sendMessageMock.mockClear();13131314    await selectImportFile(container);1315    findButton(container, "Import 1").click();1316    await flushView();13171318    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1319    expect(sendMessageMock).toHaveBeenCalledWith({1320      type: "BATCH_ADD_ENTRIES",1321      payload: {1322        entries: [1323          expect.objectContaining({1324            title: "Imported site",1325            username: "imported-user",1326            password: "imported-pass",1327            url: "https://imported.test",1328          }),1329        ],1330      },1331    });1332    expect(onImport).toHaveBeenCalled();1333    expect(container.textContent).toContain("1 entry appears to be duplicates");1334  });13351336  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1337    parseImportSourceMock.mockResolvedValue(sampleImportResult());1338    sendMessageMock.mockImplementation(async (msg: unknown) => {1339      const type = (msg as { type?: string }).type;1340      if (type === "GET_ENTRIES") {1341        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1342      }1343      if (type === "CLEAR_VAULT_ENTRIES") {1344        return { ok: true };1345      }1346      if (type === "BATCH_ADD_ENTRIES") {1347        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1348      }1349      return { ok: true };1350    });1351    const onImport = vi.fn(async () => undefined);1352    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1353    await flushView();1354    sendMessageMock.mockClear();13551356    await selectImportFile(container);1357    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1358    await flushView();13591360    findButton(container, "Replace vault with 1").click();1361    await flushView();13621363    expect(sendMessageMock).not.toHaveBeenCalledWith(1364      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1365    );1366    expect(container.textContent).toContain("Replace forever? This cannot be undone.");13671368    findButton(container, "Yes, replace forever").click();1369    await flushView();13701371    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1372    expect(sendMessageMock).toHaveBeenCalledWith({1373      type: "BATCH_ADD_ENTRIES",1374      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1375    });1376    expect(onImport).toHaveBeenCalled();1377  });13781379  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1380    parseImportSourceMock.mockResolvedValue(sampleImportResult());1381    sendMessageMock.mockImplementation(async (msg: unknown) => {1382      const type = (msg as { type?: string }).type;1383      if (type === "CREATE_VAULT") return { ok: true };1384      if (type === "BATCH_ADD_ENTRIES") {1385        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1386      }1387      return { ok: true };1388    });1389    const onImport = vi.fn(async () => undefined);1390    const container = mount(1391      <ImportView1392        mode="onboarding"1393        replacesExistingVault={true}1394        onBack={vi.fn()}1395        onImport={onImport}1396      />1397    );13981399    await selectImportFile(container);14001401    const createButton = findButton(container, "Create vault + import 1");1402    expect(createButton.disabled).toBe(true);1403    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1404    expect(container.textContent).toContain("Use at least 8 characters");14051406    const masterInput = container.querySelector(1407      'input[placeholder="new master password"]'1408    ) as HTMLInputElement;1409    const confirmInput = container.querySelector(1410      'input[placeholder="confirm master password"]'1411    ) as HTMLInputElement;1412    const revealButton = container.querySelector(1413      'button[aria-label="Show passwords"]'1414    ) as HTMLButtonElement;1415    expect(revealButton).toBeTruthy();1416    revealButton.click();1417    await flushView();1418    expect(masterInput.type).toBe("text");1419    expect(confirmInput.type).toBe("text");14201421    inputText(masterInput, "fresh-master-password");1422    inputText(confirmInput, "fresh-master-password");1423    await flushView();14241425    expect(createButton.disabled).toBe(false);1426    createButton.click();1427    await flushView();14281429    expect(sendMessageMock).toHaveBeenCalledWith({1430      type: "CREATE_VAULT",1431      payload: { password: "fresh-master-password" },1432    });1433    expect(sendMessageMock).toHaveBeenCalledWith({1434      type: "BATCH_ADD_ENTRIES",1435      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1436    });1437    expect(onImport).toHaveBeenCalled();1438  });14391440  it("Import encrypted backups reparse with the backup password before import", async () => {1441    parseImportSourceMock1442      .mockResolvedValueOnce(1443        sampleImportResult({1444          format: "peach-json",1445          label: "Peach encrypted backup",1446          entries: [],1447          totalItems: 0,1448          requiresPassword: true,1449        })1450      )1451      .mockResolvedValueOnce(1452        sampleImportResult({1453          format: "peach-json",1454          label: "Peach encrypted backup",1455        })1456      );1457    sendMessageMock.mockImplementation(async (msg: unknown) => {1458      const type = (msg as { type?: string }).type;1459      if (type === "GET_ENTRIES") return { ok: true, data: [] };1460      if (type === "BATCH_ADD_ENTRIES") {1461        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1462      }1463      return { ok: true };1464    });1465    const onImport = vi.fn(async () => undefined);1466    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1467    await flushView();1468    sendMessageMock.mockClear();14691470    await selectImportFile(container, '{"type":"peach-export"}');1471    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1472    inputText(password, "backup-passphrase");1473    pressEnter(password);1474    await flushView();1475    await flushView();14761477    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1478    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");14791480    findButton(container, "Import 1").click();1481    await flushView();14821483    expect(sendMessageMock).toHaveBeenCalledWith({1484      type: "BATCH_ADD_ENTRIES",1485      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1486    });1487    expect(onImport).toHaveBeenCalled();1488  });1489});