contracts.test.tsx

25.3 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 { LockView } from "./Lock";9import { ExportView } from "./Export";10import { SettingsView } from "./Settings";11import { EntryView } from "./Entry";12import { ImportView } from "./Import";13import { VaultView } from "./Vault";14import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1516const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();17const triggerRingMock = vi.fn();18const triggerTotpRingMock = vi.fn();19const triggerPipPulseMock = vi.fn();20const exportEntriesToPeachJsonMock = vi.fn();21const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();22const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2324vi.mock("../App", () => ({25  sendMessage: (msg: unknown) => sendMessageMock(msg),26  triggerRing: () => triggerRingMock(),27  triggerTotpRing: () => triggerTotpRingMock(),28  triggerPipPulse: () => triggerPipPulseMock(),29}));3031vi.mock("../../shared/backup", () => ({32  exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),33}));3435vi.mock("../../shared/import", () => ({36  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),37}));3839vi.mock("../../shared/favicon", () => ({40  faviconUrl: () => null,41}));4243vi.mock("../../crypto/totp", () => ({44  generateTOTP: () => ({ code: "123 456", remaining: 24 }),45  parseOtpauthURI: (value: string) =>46    value.startsWith("otpauth://")47      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }48      : null,49}));5051vi.mock("../components/GeneratorDrawer", () => ({52  GeneratorDrawer: () => null,53}));5455vi.mock("./CJKImport", () => ({56  CJKImportView: () => null,57}));5859vi.mock("./CJKExport", () => ({60  CJKExportView: () => null,61}));6263vi.mock("zxcvbn", () => ({64  default: (value: string) => zxcvbnMock(value),65}));6667describe("popup contract tests", () => {68  const disposers: Array<() => void> = [];6970  beforeEach(() => {71    document.body.innerHTML = "";72    sendMessageMock.mockReset();73    triggerRingMock.mockReset();74    triggerTotpRingMock.mockReset();75    triggerPipPulseMock.mockReset();76    exportEntriesToPeachJsonMock.mockReset();77    parseImportSourceMock.mockReset();78    zxcvbnMock.mockReset();79    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));80    parseImportSourceMock.mockResolvedValue({81      format: "unknown",82      label: "Unknown format",83      entries: [],84      totalItems: 0,85      skippedItems: 0,86      requiresPassword: false,87    });8889    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {90      return window.setTimeout(() => cb(0), 0) as unknown as number;91    });92    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));93    vi.stubGlobal("URL", {94      createObjectURL: vi.fn(() => "blob:peach-test"),95      revokeObjectURL: vi.fn(),96    });97    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9899    (globalThis as { chrome?: typeof chrome }).chrome = {100      runtime: {101        getManifest: () => ({ version: "1.4.47" }),102        onMessage: { addListener: vi.fn() },103      },104    } as unknown as typeof chrome;105  });106107  afterEach(() => {108    while (disposers.length > 0) {109      disposers.pop()?.();110    }111    vi.unstubAllGlobals();112  });113114  function mount(view: JSX.Element) {115    const mounted = renderView(view);116    disposers.push(mounted.dispose);117    return mounted.container;118  }119120  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {121    return {122      id: "entry-1",123      type: "login",124      title: "GitHub",125      username: "june",126      password: "secret123",127      url: "https://github.com",128      totp: "JBSWY3DPEHPK3PXP",129      canary: {130        webhookUrl: "https://example.com/hook",131      },132      createdAt: 1,133      modifiedAt: 1,134      ...overrides,135    };136  }137138  function sampleImportResult(139    overrides: Partial<ImportParseResult> = {}140  ): ImportParseResult {141    return {142      format: "csv",143      label: "CSV import",144      entries: [145        {146          type: "login",147          title: "Imported site",148          username: "imported-user",149          password: "imported-pass",150          url: "https://imported.test",151        },152      ],153      totalItems: 1,154      skippedItems: 0,155      requiresPassword: false,156      ...overrides,157    };158  }159160  async function selectImportFile(161    container: HTMLElement,162    contents = '{"export":"test"}'163  ) {164    const input = container.querySelector('input[type="file"]') as HTMLInputElement;165    const file = new File([contents], "import.json", { type: "application/json" });166    Object.defineProperty(input, "files", {167      configurable: true,168      value: [file],169    });170    input.dispatchEvent(new Event("change", { bubbles: true }));171    await flushView();172    await flushView();173  }174175  function findButton(container: HTMLElement, text: string): HTMLButtonElement {176    return Array.from(container.querySelectorAll("button")).find(177      (candidate) => candidate.textContent?.trim() === text178    ) as HTMLButtonElement;179  }180181  it("Lock sends UNLOCK with the entered passphrase", async () => {182    sendMessageMock.mockResolvedValue({ ok: true });183    const onUnlock = vi.fn(async () => undefined);184    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);185186    const password = container.querySelector("#unlock-password") as HTMLInputElement;187    inputText(password, "correct horse battery staple");188    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();189    await flushView();190191    expect(sendMessageMock).toHaveBeenCalledWith({192      type: "UNLOCK",193      payload: { password: "correct horse battery staple" },194    });195    expect(onUnlock).toHaveBeenCalled();196  });197198  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {199    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);200    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;201    const password = container.querySelector("#master-password") as HTMLInputElement;202    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;203204    inputText(password, "short");205    inputText(confirm, "short");206    await flushView();207    expect(createButton.disabled).toBe(true);208209    zxcvbnMock.mockImplementation(() => ({ score: 1 }));210    inputText(password, "weakweakweak");211    inputText(confirm, "weakweakweak");212    await flushView();213    expect(createButton.disabled).toBe(true);214  });215216  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {217    sendMessageMock.mockResolvedValue({ ok: true });218    zxcvbnMock.mockImplementation(() => ({ score: 3 }));219    const onUnlock = vi.fn(async () => undefined);220    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);221222    const password = container.querySelector("#master-password") as HTMLInputElement;223    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;224    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;225226    inputText(password, "correct horse battery staple");227    inputText(confirm, "correct horse battery staple");228    await flushView();229    expect(createButton.disabled).toBe(false);230231    pressEnter(confirm);232    await flushView();233234    expect(sendMessageMock).toHaveBeenCalledWith({235      type: "CREATE_VAULT",236      payload: { password: "correct horse battery staple" },237    });238    expect(onUnlock).toHaveBeenCalled();239  });240241  it("Export keeps encrypted export disabled until the password meets the minimum length", async () => {242    const container = mount(<ExportView onBack={vi.fn()} />);243244    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;245    const button = Array.from(container.querySelectorAll("button")).find(246      (candidate) => candidate.textContent?.trim() === "Download backup file"247    ) as HTMLButtonElement;248249    inputText(password, "too-short");250    await flushView();251252    expect(button.disabled).toBe(true);253    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();254  });255256  it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {257    sendMessageMock.mockResolvedValue({258      ok: true,259      data: [sampleEntry(), sampleEntry({ id: "deleted", deletedAt: 2 })],260    });261    exportEntriesToPeachJsonMock.mockResolvedValue({262      version: 2,263      type: "peach-export",264    });265    const container = mount(<ExportView onBack={vi.fn()} />);266267    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;268    const button = Array.from(container.querySelectorAll("button")).find(269      (candidate) => candidate.textContent?.trim() === "Download backup file"270    ) as HTMLButtonElement;271272    inputText(password, "correct horse battery staple");273    await flushView();274    button.click();275    await flushView();276277    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_ENTRIES" });278    expect(exportEntriesToPeachJsonMock).toHaveBeenCalledWith(279      [expect.objectContaining({ id: "entry-1" })],280      "correct horse battery staple"281    );282  });283284  it("Settings requires current, new, and confirm passwords before enabling save", async () => {285    sendMessageMock.mockResolvedValue({ ok: true, data: null });286    const container = mount(287      <SettingsView288        entriesCount={1}289        breachMonitoring={true}290        lockOnClose={true}291        autoLockMinutes={5}292        trashRetentionDays={30}293        theme="dark"294        onBreachMonitoringChange={vi.fn()}295        onLockOnCloseChange={vi.fn()}296        onAutoLockMinutesChange={vi.fn()}297        onTrashRetentionChange={vi.fn()}298        onThemeChange={vi.fn()}299        onBack={vi.fn()}300        onImport={vi.fn()}301        onExport={vi.fn()}302        onBreachCheckDone={vi.fn()}303      />304    );305    await flushView();306307    const changeButton = Array.from(container.querySelectorAll("button")).find(308      (candidate) => candidate.textContent?.trim() === "change"309    ) as HTMLButtonElement;310    changeButton.click();311    await flushView();312313    const saveButton = Array.from(container.querySelectorAll("button")).find(314      (candidate) => candidate.textContent?.trim() === "save new password"315    ) as HTMLButtonElement;316    expect(saveButton.disabled).toBe(true);317318    const inputs = container.querySelectorAll('input[type="password"]');319    inputText(inputs[0] as HTMLInputElement, "current-pass");320    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");321    await flushView();322    expect(saveButton.disabled).toBe(true);323324    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");325    await flushView();326    expect(saveButton.disabled).toBe(false);327  });328329  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {330    sendMessageMock.mockResolvedValue({ ok: true, data: null });331    const onAutoLockMinutesChange = vi.fn();332    const onThemeChange = vi.fn();333    const container = mount(334      <SettingsView335        entriesCount={1}336        breachMonitoring={true}337        lockOnClose={true}338        autoLockMinutes={5}339        trashRetentionDays={30}340        theme="dark"341        onBreachMonitoringChange={vi.fn()}342        onLockOnCloseChange={vi.fn()}343        onAutoLockMinutesChange={onAutoLockMinutesChange}344        onTrashRetentionChange={vi.fn()}345        onThemeChange={onThemeChange}346        onBack={vi.fn()}347        onImport={vi.fn()}348        onExport={vi.fn()}349        onBreachCheckDone={vi.fn()}350      />351    );352    await flushView();353    sendMessageMock.mockClear();354355    const selects = container.querySelectorAll("select");356    changeSelect(selects[0] as HTMLSelectElement, "15");357    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);358359    const themeButton = Array.from(container.querySelectorAll("button")).find(360      (candidate) => candidate.textContent?.trim() === "Switch to light"361    ) as HTMLButtonElement;362    themeButton.click();363    expect(onThemeChange).toHaveBeenCalledWith("light");364365    const changeButton = Array.from(container.querySelectorAll("button")).find(366      (candidate) => candidate.textContent?.trim() === "change"367    ) as HTMLButtonElement;368    changeButton.click();369    await flushView();370371    const inputs = container.querySelectorAll('input[type="password"]');372    inputText(inputs[0] as HTMLInputElement, "current-pass");373    inputText(inputs[1] as HTMLInputElement, "new-password-123");374    inputText(inputs[2] as HTMLInputElement, "new-password-123");375    await flushView();376377    const saveButton = Array.from(container.querySelectorAll("button")).find(378      (candidate) => candidate.textContent?.trim() === "save new password"379    ) as HTMLButtonElement;380    saveButton.click();381    await flushView();382383    expect(sendMessageMock).toHaveBeenCalledWith({384      type: "CHANGE_PASSWORD",385      payload: {386        currentPassword: "current-pass",387        newPassword: "new-password-123",388      },389    });390  });391392  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {393    sendMessageMock.mockResolvedValue({ ok: true });394    const container = mount(395      <EntryView396        entry={sampleEntry()}397        onDelete={vi.fn()}398        onFieldSaved={vi.fn(async () => undefined)}399      />400    );401402    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;403    passwordButton.click();404    await flushView();405406    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {407      type: "COPY",408      payload: { text: "secret123", label: "password" },409    });410    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {411      type: "FIRE_CANARY_PING",412      payload: {413        webhookUrl: "https://example.com/hook",414        entryTitle: "GitHub",415        trigger: "password_copied",416      },417    });418419    sendMessageMock.mockClear();420    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;421    usernameButton.click();422    await flushView();423424    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {425      type: "COPY",426      payload: { text: "june", label: "username" },427    });428    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {429      type: "FIRE_CANARY_PING",430      payload: {431        webhookUrl: "https://example.com/hook",432        entryTitle: "GitHub",433        trigger: "username_copied",434      },435    });436  });437438  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {439    sendMessageMock.mockResolvedValue({ ok: true });440    const onFieldSaved = vi.fn(async () => undefined);441    const container = mount(442      <EntryView443        entry={sampleEntry()}444        onDelete={vi.fn()}445        onFieldSaved={onFieldSaved}446      />447    );448449    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;450    passwordValue.click();451    await flushView();452453    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;454    inputText(passwordInput, "");455    await flushView();456457    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;458    saveButton.click();459    await flushView();460461    expect(sendMessageMock).not.toHaveBeenCalledWith(462      expect.objectContaining({ type: "UPDATE_ENTRY" })463    );464    expect(onFieldSaved).not.toHaveBeenCalled();465466    const totpButton = container.querySelector('button[aria-label="Copy TOTP code"]') as HTMLButtonElement;467    totpButton.click();468    await flushView();469470    expect(sendMessageMock).toHaveBeenLastCalledWith({471      type: "COPY",472      payload: { text: "123456", label: "totp" },473    });474  });475476  it("Entry closes an unchanged inline editor when focus leaves it", async () => {477    sendMessageMock.mockResolvedValue({ ok: true });478    const container = mount(479      <EntryView480        entry={sampleEntry()}481        onDelete={vi.fn()}482        onFieldSaved={vi.fn(async () => undefined)}483      />484    );485486    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;487    usernameValue.click();488    await flushView();489490    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;491    usernameInput.blur();492    await flushView();493494    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();495    expect(sendMessageMock).not.toHaveBeenCalledWith(496      expect.objectContaining({ type: "UPDATE_ENTRY" })497    );498  });499500  it("Entry lets TOTP secrets be edited inline", async () => {501    sendMessageMock.mockResolvedValue({ ok: true });502    const onFieldSaved = vi.fn(async () => undefined);503    const container = mount(504      <EntryView505        entry={sampleEntry()}506        onDelete={vi.fn()}507        onFieldSaved={onFieldSaved}508      />509    );510511    const totpMain = container.querySelector(".peach-totp-main.peach-inline-clickable") as HTMLDivElement;512    totpMain.click();513    await flushView();514515    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;516    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");517    await flushView();518519    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;520    saveButton.click();521    await flushView();522523    expect(sendMessageMock).toHaveBeenCalledWith({524      type: "UPDATE_ENTRY",525      payload: {526        id: "entry-1",527        patch: { totp: "NB2W45DFOIZA====" },528      },529    });530    expect(onFieldSaved).toHaveBeenCalled();531  });532533  it("Vault seeds and focuses search when a search seed is provided", async () => {534    const container = mount(535      <VaultView536        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}537        sortOrder="url-first"538        searchSeed="g"539        searchSeedNonce={1}540        onSelectEntry={vi.fn()}541        onSortOrderChange={vi.fn()}542        onAdd={vi.fn()}543        onClose={vi.fn()}544      />545    );546547    await flushView();548549    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;550    expect(search.value).toBe("g");551    expect(document.activeElement).toBe(search);552  });553554  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {555    parseImportSourceMock.mockResolvedValue(556      sampleImportResult({557        entries: [558          {559            type: "login",560            title: "Imported site",561            username: "imported-user",562            password: "imported-pass",563            url: "https://imported.test",564          },565        ],566      })567    );568    sendMessageMock.mockImplementation(async (msg: unknown) => {569      const type = (msg as { type?: string }).type;570      if (type === "GET_ENTRIES") {571        return {572          ok: true,573          data: [574            sampleEntry({575              id: "existing-1",576              title: "Imported site",577              username: "imported-user",578              url: "https://imported.test",579            }),580          ],581        };582      }583      if (type === "BATCH_ADD_ENTRIES") {584        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };585      }586      return { ok: true };587    });588    const onImport = vi.fn(async () => undefined);589    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);590    await flushView();591    sendMessageMock.mockClear();592593    await selectImportFile(container);594    findButton(container, "Import 1").click();595    await flushView();596597    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);598    expect(sendMessageMock).toHaveBeenCalledWith({599      type: "BATCH_ADD_ENTRIES",600      payload: {601        entries: [602          expect.objectContaining({603            title: "Imported site",604            username: "imported-user",605            password: "imported-pass",606            url: "https://imported.test",607          }),608        ],609      },610    });611    expect(onImport).toHaveBeenCalled();612    expect(container.textContent).toContain("1 entry appears to be duplicates");613  });614615  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {616    parseImportSourceMock.mockResolvedValue(sampleImportResult());617    sendMessageMock.mockImplementation(async (msg: unknown) => {618      const type = (msg as { type?: string }).type;619      if (type === "GET_ENTRIES") {620        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };621      }622      if (type === "CLEAR_VAULT_ENTRIES") {623        return { ok: true };624      }625      if (type === "BATCH_ADD_ENTRIES") {626        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };627      }628      return { ok: true };629    });630    const onImport = vi.fn(async () => undefined);631    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);632    await flushView();633    sendMessageMock.mockClear();634635    await selectImportFile(container);636    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();637    await flushView();638639    findButton(container, "Replace vault with 1").click();640    await flushView();641642    expect(sendMessageMock).not.toHaveBeenCalledWith(643      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })644    );645    expect(container.textContent).toContain("Replace forever? This cannot be undone.");646647    findButton(container, "Yes, replace forever").click();648    await flushView();649650    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });651    expect(sendMessageMock).toHaveBeenCalledWith({652      type: "BATCH_ADD_ENTRIES",653      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },654    });655    expect(onImport).toHaveBeenCalled();656  });657658  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {659    parseImportSourceMock.mockResolvedValue(sampleImportResult());660    sendMessageMock.mockImplementation(async (msg: unknown) => {661      const type = (msg as { type?: string }).type;662      if (type === "CREATE_VAULT") return { ok: true };663      if (type === "BATCH_ADD_ENTRIES") {664        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };665      }666      return { ok: true };667    });668    const onImport = vi.fn(async () => undefined);669    const container = mount(670      <ImportView671        mode="onboarding"672        replacesExistingVault={true}673        onBack={vi.fn()}674        onImport={onImport}675      />676    );677678    await selectImportFile(container);679680    const createButton = findButton(container, "Create vault + import 1");681    expect(createButton.disabled).toBe(true);682    expect(container.textContent).toContain("Restoring now replaces the locked local vault");683684    const inputs = container.querySelectorAll('input[type="password"]');685    inputText(inputs[0] as HTMLInputElement, "fresh-master-password");686    inputText(inputs[1] as HTMLInputElement, "fresh-master-password");687    await flushView();688689    expect(createButton.disabled).toBe(false);690    createButton.click();691    await flushView();692693    expect(sendMessageMock).toHaveBeenCalledWith({694      type: "CREATE_VAULT",695      payload: { password: "fresh-master-password" },696    });697    expect(sendMessageMock).toHaveBeenCalledWith({698      type: "BATCH_ADD_ENTRIES",699      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },700    });701    expect(onImport).toHaveBeenCalled();702  });703704  it("Import encrypted backups reparse with the backup password before import", async () => {705    parseImportSourceMock706      .mockResolvedValueOnce(707        sampleImportResult({708          format: "peach-json",709          label: "Peach encrypted backup",710          entries: [],711          totalItems: 0,712          requiresPassword: true,713        })714      )715      .mockResolvedValueOnce(716        sampleImportResult({717          format: "peach-json",718          label: "Peach encrypted backup",719        })720      );721    sendMessageMock.mockImplementation(async (msg: unknown) => {722      const type = (msg as { type?: string }).type;723      if (type === "GET_ENTRIES") return { ok: true, data: [] };724      if (type === "BATCH_ADD_ENTRIES") {725        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };726      }727      return { ok: true };728    });729    const onImport = vi.fn(async () => undefined);730    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);731    await flushView();732    sendMessageMock.mockClear();733734    await selectImportFile(container, '{"type":"peach-export"}');735    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;736    inputText(password, "backup-passphrase");737    pressEnter(password);738    await flushView();739    await flushView();740741    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);742    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");743744    findButton(container, "Import 1").click();745    await flushView();746747    expect(sendMessageMock).toHaveBeenCalledWith({748      type: "BATCH_ADD_ENTRIES",749      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },750    });751    expect(onImport).toHaveBeenCalled();752  });753});