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 { DEFAULT_BINDINGS } from "../../shared/shortcuts";12import { EntryView } from "./Entry";13import { ImportView } from "./Import";14import { VaultView } from "./Vault";15import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1617const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();18const triggerRingMock = 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  triggerPipPulse: () => triggerPipPulseMock(),28}));2930vi.mock("../../shared/backup", () => ({31  exportEntriesToPeachJson: (...args: unknown[]) => exportEntriesToPeachJsonMock(...args),32}));3334vi.mock("../../shared/import", () => ({35  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),36}));3738vi.mock("../../shared/favicon", () => ({39  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("./CJKImport", () => ({55  CJKImportView: () => null,56}));5758vi.mock("./CJKExport", () => ({59  CJKExportView: () => null,60}));6162vi.mock("zxcvbn", () => ({63  default: (value: string) => zxcvbnMock(value),64}));6566describe("popup contract tests", () => {67  const disposers: Array<() => void> = [];6869  beforeEach(() => {70    document.body.innerHTML = "";71    sendMessageMock.mockReset();72    triggerRingMock.mockReset();73    triggerPipPulseMock.mockReset();74    exportEntriesToPeachJsonMock.mockReset();75    parseImportSourceMock.mockReset();76    zxcvbnMock.mockReset();77    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));78    parseImportSourceMock.mockResolvedValue({79      format: "unknown",80      label: "Unknown format",81      entries: [],82      totalItems: 0,83      skippedItems: 0,84      requiresPassword: false,85    });8687    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {88      return window.setTimeout(() => cb(0), 0) as unknown as number;89    });90    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));91    vi.stubGlobal("URL", {92      createObjectURL: vi.fn(() => "blob:peach-test"),93      revokeObjectURL: vi.fn(),94    });95    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});9697    (globalThis as { chrome?: typeof chrome }).chrome = {98      runtime: {99        getManifest: () => ({ version: "1.4.47" }),100        onMessage: { addListener: vi.fn() },101      },102    } as unknown as typeof chrome;103  });104105  afterEach(() => {106    while (disposers.length > 0) {107      disposers.pop()?.();108    }109    vi.unstubAllGlobals();110  });111112  function mount(view: JSX.Element) {113    const mounted = renderView(view);114    disposers.push(mounted.dispose);115    return mounted.container;116  }117118  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {119    return {120      id: "entry-1",121      type: "login",122      title: "GitHub",123      username: "june",124      password: "secret123",125      url: "https://github.com",126      totp: "JBSWY3DPEHPK3PXP",127      canary: {128        webhookUrl: "https://example.com/hook",129      },130      createdAt: 1,131      modifiedAt: 1,132      ...overrides,133    };134  }135136  function sampleImportResult(137    overrides: Partial<ImportParseResult> = {}138  ): ImportParseResult {139    return {140      format: "csv",141      label: "CSV import",142      entries: [143        {144          type: "login",145          title: "Imported site",146          username: "imported-user",147          password: "imported-pass",148          url: "https://imported.test",149        },150      ],151      totalItems: 1,152      skippedItems: 0,153      requiresPassword: false,154      ...overrides,155    };156  }157158  async function selectImportFile(159    container: HTMLElement,160    contents = '{"export":"test"}'161  ) {162    const input = container.querySelector('input[type="file"]') as HTMLInputElement;163    const file = new File([contents], "import.json", { type: "application/json" });164    Object.defineProperty(input, "files", {165      configurable: true,166      value: [file],167    });168    input.dispatchEvent(new Event("change", { bubbles: true }));169    await flushView();170    await flushView();171  }172173  function findButton(container: HTMLElement, text: string): HTMLButtonElement {174    return Array.from(container.querySelectorAll("button")).find(175      (candidate) => candidate.textContent?.trim() === text176    ) as HTMLButtonElement;177  }178179  it("Lock sends UNLOCK with the entered passphrase", async () => {180    sendMessageMock.mockResolvedValue({ ok: true });181    const onUnlock = vi.fn(async () => undefined);182    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);183184    const password = container.querySelector("#unlock-password") as HTMLInputElement;185    inputText(password, "correct horse battery staple");186    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();187    await flushView();188189    expect(sendMessageMock).toHaveBeenCalledWith({190      type: "UNLOCK",191      payload: { password: "correct horse battery staple" },192    });193    expect(onUnlock).toHaveBeenCalled();194  });195196  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {197    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);198    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;199    const password = container.querySelector("#master-password") as HTMLInputElement;200    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;201202    inputText(password, "short");203    inputText(confirm, "short");204    await flushView();205    expect(createButton.disabled).toBe(true);206207    zxcvbnMock.mockImplementation(() => ({ score: 1 }));208    inputText(password, "weakweakweak");209    inputText(confirm, "weakweakweak");210    await flushView();211    expect(createButton.disabled).toBe(true);212  });213214  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {215    sendMessageMock.mockResolvedValue({ ok: true });216    zxcvbnMock.mockImplementation(() => ({ score: 3 }));217    const onUnlock = vi.fn(async () => undefined);218    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);219220    const password = container.querySelector("#master-password") as HTMLInputElement;221    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;222    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;223224    inputText(password, "correct horse battery staple");225    inputText(confirm, "correct horse battery staple");226    await flushView();227    expect(createButton.disabled).toBe(false);228229    pressEnter(confirm);230    await flushView();231232    expect(sendMessageMock).toHaveBeenCalledWith({233      type: "CREATE_VAULT",234      payload: { password: "correct horse battery staple" },235    });236    expect(onUnlock).toHaveBeenCalled();237  });238239  it("Export menu surfaces the three v2.5.0 backup formats", async () => {240    const container = mount(<ExportView onBack={vi.fn()} />);241    await flushView();242243    expect(container.textContent).toContain("PeachScript");244    expect(container.textContent).toContain("print backup");245    expect(container.textContent).toContain("CSV");246  });247248  it("Settings requires current, new, and confirm passwords before enabling save", async () => {249    sendMessageMock.mockResolvedValue({ ok: true, data: null });250    const container = mount(251      <SettingsView252        entriesCount={1}253        breachMonitoring={true}254        lockOnClose={true}255        autoLockMinutes={5}256        trashRetentionDays={30}257        theme="dark"258        accentColor="peach"259        onBreachMonitoringChange={vi.fn()}260        onLockOnCloseChange={vi.fn()}261        onAutoLockMinutesChange={vi.fn()}262        onTrashRetentionChange={vi.fn()}263        onThemeChange={vi.fn()}264        onAccentChange={vi.fn()}265        shortcuts={DEFAULT_BINDINGS}266        onShortcutsChange={vi.fn()}267        onBack={vi.fn()}268        onImport={vi.fn()}269        onExport={vi.fn()}270        onBreachCheckDone={vi.fn()}271      />272    );273    await flushView();274275    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(276      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"277    ) as HTMLButtonElement;278    securityRow.click();279    await flushView();280281    const changeButton = Array.from(container.querySelectorAll("button")).find(282      (candidate) => candidate.textContent?.trim() === "change"283    ) as HTMLButtonElement;284    changeButton.click();285    await flushView();286287    const saveButton = Array.from(container.querySelectorAll("button")).find(288      (candidate) => candidate.textContent?.trim() === "save new password"289    ) as HTMLButtonElement;290    expect(saveButton.disabled).toBe(true);291292    const inputs = container.querySelectorAll('input[type="password"]');293    inputText(inputs[0] as HTMLInputElement, "current-pass");294    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");295    await flushView();296    expect(saveButton.disabled).toBe(true);297298    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");299    await flushView();300    expect(saveButton.disabled).toBe(false);301  });302303  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {304    sendMessageMock.mockResolvedValue({ ok: true, data: null });305    const onAutoLockMinutesChange = vi.fn();306    const onThemeChange = vi.fn();307    const container = mount(308      <SettingsView309        entriesCount={1}310        breachMonitoring={true}311        lockOnClose={true}312        autoLockMinutes={5}313        trashRetentionDays={30}314        theme="dark"315        accentColor="peach"316        onBreachMonitoringChange={vi.fn()}317        onLockOnCloseChange={vi.fn()}318        onAutoLockMinutesChange={onAutoLockMinutesChange}319        onTrashRetentionChange={vi.fn()}320        onThemeChange={onThemeChange}321        onAccentChange={vi.fn()}322        shortcuts={DEFAULT_BINDINGS}323        onShortcutsChange={vi.fn()}324        onBack={vi.fn()}325        onImport={vi.fn()}326        onExport={vi.fn()}327        onBreachCheckDone={vi.fn()}328      />329    );330    await flushView();331    sendMessageMock.mockClear();332333    const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;334335    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(336      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"337    ) as HTMLButtonElement;338    securityRow.click();339    await flushView();340341    const selects = container.querySelectorAll("select");342    changeSelect(selects[0] as HTMLSelectElement, "15");343    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);344345    const changeButton = Array.from(container.querySelectorAll("button")).find(346      (candidate) => candidate.textContent?.trim() === "change"347    ) as HTMLButtonElement;348    changeButton.click();349    await flushView();350351    const inputs = container.querySelectorAll('input[type="password"]');352    inputText(inputs[0] as HTMLInputElement, "current-pass");353    inputText(inputs[1] as HTMLInputElement, "new-password-123");354    inputText(inputs[2] as HTMLInputElement, "new-password-123");355    await flushView();356357    const saveButton = Array.from(container.querySelectorAll("button")).find(358      (candidate) => candidate.textContent?.trim() === "save new password"359    ) as HTMLButtonElement;360    saveButton.click();361    await flushView();362363    headerBack.click();364    await flushView();365    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(366      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"367    ) as HTMLButtonElement;368    appearanceRow.click();369    await flushView();370    const themeButton = Array.from(container.querySelectorAll("button")).find(371      (candidate) => candidate.textContent?.trim() === "Switch to light"372    ) as HTMLButtonElement;373    themeButton.click();374    expect(onThemeChange).toHaveBeenCalledWith("light");375376    expect(sendMessageMock).toHaveBeenCalledWith({377      type: "CHANGE_PASSWORD",378      payload: {379        currentPassword: "current-pass",380        newPassword: "new-password-123",381      },382    });383  });384385  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {386    sendMessageMock.mockResolvedValue({ ok: true });387    const container = mount(388      <EntryView389        entry={sampleEntry()}390        onDelete={vi.fn()}391        onFieldSaved={vi.fn(async () => undefined)}392      />393    );394395    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;396    passwordButton.click();397    await flushView();398399    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {400      type: "COPY",401      payload: { text: "secret123", label: "password", alreadyWritten: false },402    });403    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {404      type: "FIRE_CANARY_PING",405      payload: {406        webhookUrl: "https://example.com/hook",407        entryTitle: "GitHub",408        trigger: "password_copied",409      },410    });411412    sendMessageMock.mockClear();413    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;414    usernameButton.click();415    await flushView();416417    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {418      type: "COPY",419      payload: { text: "june", label: "username", alreadyWritten: false },420    });421    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {422      type: "FIRE_CANARY_PING",423      payload: {424        webhookUrl: "https://example.com/hook",425        entryTitle: "GitHub",426        trigger: "username_copied",427      },428    });429  });430431  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {432    sendMessageMock.mockResolvedValue({ ok: true });433    const onFieldSaved = vi.fn(async () => undefined);434    const container = mount(435      <EntryView436        entry={sampleEntry()}437        onDelete={vi.fn()}438        onFieldSaved={onFieldSaved}439      />440    );441442    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;443    passwordValue.click();444    await flushView();445446    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;447    inputText(passwordInput, "");448    await flushView();449450    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;451    saveButton.click();452    await flushView();453454    expect(sendMessageMock).not.toHaveBeenCalledWith(455      expect.objectContaining({ type: "UPDATE_ENTRY" })456    );457    expect(onFieldSaved).not.toHaveBeenCalled();458459    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;460    totpMain.click();461    await flushView();462463    expect(sendMessageMock).toHaveBeenLastCalledWith({464      type: "COPY",465      payload: { text: "123456", label: "totp", alreadyWritten: false },466    });467  });468469  it("Entry closes an unchanged inline editor when focus leaves it", async () => {470    sendMessageMock.mockResolvedValue({ ok: true });471    const container = mount(472      <EntryView473        entry={sampleEntry()}474        onDelete={vi.fn()}475        onFieldSaved={vi.fn(async () => undefined)}476      />477    );478479    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;480    usernameValue.click();481    await flushView();482483    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;484    usernameInput.blur();485    await flushView();486487    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();488    expect(sendMessageMock).not.toHaveBeenCalledWith(489      expect.objectContaining({ type: "UPDATE_ENTRY" })490    );491  });492493  it("Entry lets TOTP secrets be edited inline", async () => {494    sendMessageMock.mockResolvedValue({ ok: true });495    const onFieldSaved = vi.fn(async () => undefined);496    const container = mount(497      <EntryView498        entry={sampleEntry()}499        onDelete={vi.fn()}500        onFieldSaved={onFieldSaved}501      />502    );503504    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;505    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));506    await flushView();507508    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;509    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");510    await flushView();511512    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;513    saveButton.click();514    await flushView();515516    expect(sendMessageMock).toHaveBeenCalledWith({517      type: "UPDATE_ENTRY",518      payload: {519        id: "entry-1",520        patch: { totp: "NB2W45DFOIZA====" },521      },522    });523    expect(onFieldSaved).toHaveBeenCalled();524  });525526  it("Vault seeds and focuses search when a search seed is provided", async () => {527    const container = mount(528      <VaultView529        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}530        sortOrder="url-first"531        searchSeed="g"532        searchSeedNonce={1}533        onSelectEntry={vi.fn()}534        onSortOrderChange={vi.fn()}535        onAdd={vi.fn()}536        onClose={vi.fn()}537      />538    );539540    await flushView();541542    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;543    expect(search.value).toBe("g");544    expect(document.activeElement).toBe(search);545  });546547  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {548    parseImportSourceMock.mockResolvedValue(549      sampleImportResult({550        entries: [551          {552            type: "login",553            title: "Imported site",554            username: "imported-user",555            password: "imported-pass",556            url: "https://imported.test",557          },558        ],559      })560    );561    sendMessageMock.mockImplementation(async (msg: unknown) => {562      const type = (msg as { type?: string }).type;563      if (type === "GET_ENTRIES") {564        return {565          ok: true,566          data: [567            sampleEntry({568              id: "existing-1",569              title: "Imported site",570              username: "imported-user",571              url: "https://imported.test",572            }),573          ],574        };575      }576      if (type === "BATCH_ADD_ENTRIES") {577        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };578      }579      return { ok: true };580    });581    const onImport = vi.fn(async () => undefined);582    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);583    await flushView();584    sendMessageMock.mockClear();585586    await selectImportFile(container);587    findButton(container, "Import 1").click();588    await flushView();589590    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);591    expect(sendMessageMock).toHaveBeenCalledWith({592      type: "BATCH_ADD_ENTRIES",593      payload: {594        entries: [595          expect.objectContaining({596            title: "Imported site",597            username: "imported-user",598            password: "imported-pass",599            url: "https://imported.test",600          }),601        ],602      },603    });604    expect(onImport).toHaveBeenCalled();605    expect(container.textContent).toContain("1 entry appears to be duplicates");606  });607608  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {609    parseImportSourceMock.mockResolvedValue(sampleImportResult());610    sendMessageMock.mockImplementation(async (msg: unknown) => {611      const type = (msg as { type?: string }).type;612      if (type === "GET_ENTRIES") {613        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };614      }615      if (type === "CLEAR_VAULT_ENTRIES") {616        return { ok: true };617      }618      if (type === "BATCH_ADD_ENTRIES") {619        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };620      }621      return { ok: true };622    });623    const onImport = vi.fn(async () => undefined);624    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);625    await flushView();626    sendMessageMock.mockClear();627628    await selectImportFile(container);629    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();630    await flushView();631632    findButton(container, "Replace vault with 1").click();633    await flushView();634635    expect(sendMessageMock).not.toHaveBeenCalledWith(636      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })637    );638    expect(container.textContent).toContain("Replace forever? This cannot be undone.");639640    findButton(container, "Yes, replace forever").click();641    await flushView();642643    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });644    expect(sendMessageMock).toHaveBeenCalledWith({645      type: "BATCH_ADD_ENTRIES",646      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },647    });648    expect(onImport).toHaveBeenCalled();649  });650651  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {652    parseImportSourceMock.mockResolvedValue(sampleImportResult());653    sendMessageMock.mockImplementation(async (msg: unknown) => {654      const type = (msg as { type?: string }).type;655      if (type === "CREATE_VAULT") return { ok: true };656      if (type === "BATCH_ADD_ENTRIES") {657        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };658      }659      return { ok: true };660    });661    const onImport = vi.fn(async () => undefined);662    const container = mount(663      <ImportView664        mode="onboarding"665        replacesExistingVault={true}666        onBack={vi.fn()}667        onImport={onImport}668      />669    );670671    await selectImportFile(container);672673    const createButton = findButton(container, "Create vault + import 1");674    expect(createButton.disabled).toBe(true);675    expect(container.textContent).toContain("Restoring now replaces the locked local vault");676677    const masterInput = container.querySelector(678      'input[placeholder="new master password"]'679    ) as HTMLInputElement;680    const confirmInput = container.querySelector(681      'input[placeholder="confirm master password"]'682    ) as HTMLInputElement;683    inputText(masterInput, "fresh-master-password");684    inputText(confirmInput, "fresh-master-password");685    await flushView();686687    expect(createButton.disabled).toBe(false);688    createButton.click();689    await flushView();690691    expect(sendMessageMock).toHaveBeenCalledWith({692      type: "CREATE_VAULT",693      payload: { password: "fresh-master-password" },694    });695    expect(sendMessageMock).toHaveBeenCalledWith({696      type: "BATCH_ADD_ENTRIES",697      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },698    });699    expect(onImport).toHaveBeenCalled();700  });701702  it("Import encrypted backups reparse with the backup password before import", async () => {703    parseImportSourceMock704      .mockResolvedValueOnce(705        sampleImportResult({706          format: "peach-json",707          label: "Peach encrypted backup",708          entries: [],709          totalItems: 0,710          requiresPassword: true,711        })712      )713      .mockResolvedValueOnce(714        sampleImportResult({715          format: "peach-json",716          label: "Peach encrypted backup",717        })718      );719    sendMessageMock.mockImplementation(async (msg: unknown) => {720      const type = (msg as { type?: string }).type;721      if (type === "GET_ENTRIES") return { ok: true, data: [] };722      if (type === "BATCH_ADD_ENTRIES") {723        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };724      }725      return { ok: true };726    });727    const onImport = vi.fn(async () => undefined);728    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);729    await flushView();730    sendMessageMock.mockClear();731732    await selectImportFile(container, '{"type":"peach-export"}');733    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;734    inputText(password, "backup-passphrase");735    pressEnter(password);736    await flushView();737    await flushView();738739    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);740    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");741742    findButton(container, "Import 1").click();743    await flushView();744745    expect(sendMessageMock).toHaveBeenCalledWith({746      type: "BATCH_ADD_ENTRIES",747      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },748    });749    expect(onImport).toHaveBeenCalled();750  });751});