contracts.test.tsx

25.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 { 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 parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();21const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2223vi.mock("../App", () => ({24  sendMessage: (msg: unknown) => sendMessageMock(msg),25  triggerRing: () => triggerRingMock(),26  triggerPipPulse: () => triggerPipPulseMock(),27}));2829vi.mock("../../shared/import", () => ({30  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),31}));3233vi.mock("../../shared/favicon", () => ({34  faviconUrl: () => null,35}));3637vi.mock("../../crypto/totp", () => ({38  generateTOTP: () => ({ code: "123 456", remaining: 24 }),39  parseOtpauthURI: (value: string) =>40    value.startsWith("otpauth://")41      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }42      : null,43}));4445vi.mock("../components/GeneratorDrawer", () => ({46  GeneratorDrawer: () => null,47}));4849vi.mock("./CJKImport", () => ({50  CJKImportView: () => null,51}));5253vi.mock("zxcvbn", () => ({54  default: (value: string) => zxcvbnMock(value),55}));5657describe("popup contract tests", () => {58  const disposers: Array<() => void> = [];5960  beforeEach(() => {61    document.body.innerHTML = "";62    sendMessageMock.mockReset();63    triggerRingMock.mockReset();64    triggerPipPulseMock.mockReset();65    parseImportSourceMock.mockReset();66    zxcvbnMock.mockReset();67    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));68    parseImportSourceMock.mockResolvedValue({69      format: "unknown",70      label: "Unknown format",71      entries: [],72      totalItems: 0,73      skippedItems: 0,74      requiresPassword: false,75    });7677    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {78      return window.setTimeout(() => cb(0), 0) as unknown as number;79    });80    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));81    vi.stubGlobal("URL", {82      createObjectURL: vi.fn(() => "blob:peach-test"),83      revokeObjectURL: vi.fn(),84    });85    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8687    (globalThis as { chrome?: typeof chrome }).chrome = {88      runtime: {89        getManifest: () => ({ version: "1.4.47" }),90        onMessage: { addListener: vi.fn() },91      },92    } as unknown as typeof chrome;93  });9495  afterEach(() => {96    while (disposers.length > 0) {97      disposers.pop()?.();98    }99    vi.unstubAllGlobals();100  });101102  function mount(view: JSX.Element) {103    const mounted = renderView(view);104    disposers.push(mounted.dispose);105    return mounted.container;106  }107108  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {109    return {110      id: "entry-1",111      type: "login",112      title: "GitHub",113      username: "june",114      password: "secret123",115      url: "https://github.com",116      totp: "JBSWY3DPEHPK3PXP",117      canary: {118        webhookUrl: "https://example.com/hook",119      },120      createdAt: 1,121      modifiedAt: 1,122      ...overrides,123    };124  }125126  function sampleImportResult(127    overrides: Partial<ImportParseResult> = {}128  ): ImportParseResult {129    return {130      format: "csv",131      label: "CSV import",132      entries: [133        {134          type: "login",135          title: "Imported site",136          username: "imported-user",137          password: "imported-pass",138          url: "https://imported.test",139        },140      ],141      totalItems: 1,142      skippedItems: 0,143      requiresPassword: false,144      ...overrides,145    };146  }147148  async function selectImportFile(149    container: HTMLElement,150    contents = '{"export":"test"}'151  ) {152    const input = container.querySelector('input[type="file"]') as HTMLInputElement;153    const file = new File([contents], "import.json", { type: "application/json" });154    Object.defineProperty(input, "files", {155      configurable: true,156      value: [file],157    });158    input.dispatchEvent(new Event("change", { bubbles: true }));159    await flushView();160    await flushView();161  }162163  function findButton(container: HTMLElement, text: string): HTMLButtonElement {164    return Array.from(container.querySelectorAll("button")).find(165      (candidate) => candidate.textContent?.trim() === text166    ) as HTMLButtonElement;167  }168169  it("Lock sends UNLOCK with the entered passphrase", async () => {170    sendMessageMock.mockResolvedValue({ ok: true });171    const onUnlock = vi.fn(async () => undefined);172    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);173174    const password = container.querySelector("#unlock-password") as HTMLInputElement;175    inputText(password, "correct horse battery staple");176    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();177    await flushView();178179    expect(sendMessageMock).toHaveBeenCalledWith({180      type: "UNLOCK",181      payload: { password: "correct horse battery staple" },182    });183    expect(onUnlock).toHaveBeenCalled();184  });185186  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {187    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);188    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;189    const password = container.querySelector("#master-password") as HTMLInputElement;190    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;191192    inputText(password, "short");193    inputText(confirm, "short");194    await flushView();195    expect(createButton.disabled).toBe(true);196197    zxcvbnMock.mockImplementation(() => ({ score: 1 }));198    inputText(password, "weakweakweak");199    inputText(confirm, "weakweakweak");200    await flushView();201    expect(createButton.disabled).toBe(true);202  });203204  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {205    sendMessageMock.mockResolvedValue({ ok: true });206    zxcvbnMock.mockImplementation(() => ({ score: 3 }));207    const onUnlock = vi.fn(async () => undefined);208    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);209210    const password = container.querySelector("#master-password") as HTMLInputElement;211    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;212    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;213214    inputText(password, "correct horse battery staple");215    inputText(confirm, "correct horse battery staple");216    await flushView();217    expect(createButton.disabled).toBe(false);218219    pressEnter(confirm);220    await flushView();221222    expect(sendMessageMock).toHaveBeenCalledWith({223      type: "CREATE_VAULT",224      payload: { password: "correct horse battery staple" },225    });226    expect(onUnlock).toHaveBeenCalled();227  });228229  it("Export menu surfaces the three v2.5.0 backup formats", async () => {230    const container = mount(<ExportView onBack={vi.fn()} />);231    await flushView();232233    expect(container.textContent).toContain("PeachScript");234    expect(container.textContent).toContain("print backup");235    expect(container.textContent).toContain("CSV");236  });237238  it("Settings requires current, new, and confirm passwords before enabling save", async () => {239    sendMessageMock.mockResolvedValue({ ok: true, data: null });240    const container = mount(241      <SettingsView242        entriesCount={1}243        breachMonitoring={true}244        lockOnClose={true}245        autoLockMinutes={5}246        trashRetentionDays={30}247        theme="dark"248        accentColor="peach"249        onBreachMonitoringChange={vi.fn()}250        onLockOnCloseChange={vi.fn()}251        onAutoLockMinutesChange={vi.fn()}252        onTrashRetentionChange={vi.fn()}253        onThemeChange={vi.fn()}254        onAccentChange={vi.fn()}255        shortcuts={DEFAULT_BINDINGS}256        onShortcutsChange={vi.fn()}257        onBack={vi.fn()}258        onImport={vi.fn()}259        onExport={vi.fn()}260        onBreachCheckDone={vi.fn()}261      />262    );263    await flushView();264265    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(266      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"267    ) as HTMLButtonElement;268    securityRow.click();269    await flushView();270271    const changeButton = Array.from(container.querySelectorAll("button")).find(272      (candidate) => candidate.textContent?.trim() === "change"273    ) as HTMLButtonElement;274    changeButton.click();275    await flushView();276277    const saveButton = Array.from(container.querySelectorAll("button")).find(278      (candidate) => candidate.textContent?.trim() === "save new password"279    ) as HTMLButtonElement;280    expect(saveButton.disabled).toBe(true);281282    const inputs = container.querySelectorAll('input[type="password"]');283    inputText(inputs[0] as HTMLInputElement, "current-pass");284    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");285    await flushView();286    expect(saveButton.disabled).toBe(true);287288    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");289    await flushView();290    expect(saveButton.disabled).toBe(false);291  });292293  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {294    sendMessageMock.mockResolvedValue({ ok: true, data: null });295    const onAutoLockMinutesChange = vi.fn();296    const onThemeChange = vi.fn();297    const container = mount(298      <SettingsView299        entriesCount={1}300        breachMonitoring={true}301        lockOnClose={true}302        autoLockMinutes={5}303        trashRetentionDays={30}304        theme="dark"305        accentColor="peach"306        onBreachMonitoringChange={vi.fn()}307        onLockOnCloseChange={vi.fn()}308        onAutoLockMinutesChange={onAutoLockMinutesChange}309        onTrashRetentionChange={vi.fn()}310        onThemeChange={onThemeChange}311        onAccentChange={vi.fn()}312        shortcuts={DEFAULT_BINDINGS}313        onShortcutsChange={vi.fn()}314        onBack={vi.fn()}315        onImport={vi.fn()}316        onExport={vi.fn()}317        onBreachCheckDone={vi.fn()}318      />319    );320    await flushView();321    sendMessageMock.mockClear();322323    const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;324325    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(326      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"327    ) as HTMLButtonElement;328    securityRow.click();329    await flushView();330331    const selects = container.querySelectorAll("select");332    changeSelect(selects[0] as HTMLSelectElement, "15");333    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);334335    const changeButton = Array.from(container.querySelectorAll("button")).find(336      (candidate) => candidate.textContent?.trim() === "change"337    ) as HTMLButtonElement;338    changeButton.click();339    await flushView();340341    const inputs = container.querySelectorAll('input[type="password"]');342    inputText(inputs[0] as HTMLInputElement, "current-pass");343    inputText(inputs[1] as HTMLInputElement, "new-password-123");344    inputText(inputs[2] as HTMLInputElement, "new-password-123");345    await flushView();346347    const saveButton = Array.from(container.querySelectorAll("button")).find(348      (candidate) => candidate.textContent?.trim() === "save new password"349    ) as HTMLButtonElement;350    saveButton.click();351    await flushView();352353    headerBack.click();354    await flushView();355    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(356      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"357    ) as HTMLButtonElement;358    appearanceRow.click();359    await flushView();360    const themeButton = Array.from(container.querySelectorAll("button")).find(361      (candidate) => candidate.textContent?.trim() === "Switch to light"362    ) as HTMLButtonElement;363    themeButton.click();364    expect(onThemeChange).toHaveBeenCalledWith("light");365366    expect(sendMessageMock).toHaveBeenCalledWith({367      type: "CHANGE_PASSWORD",368      payload: {369        currentPassword: "current-pass",370        newPassword: "new-password-123",371      },372    });373  });374375  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {376    sendMessageMock.mockResolvedValue({ ok: true });377    const container = mount(378      <EntryView379        entry={sampleEntry()}380        onDelete={vi.fn()}381        onFieldSaved={vi.fn(async () => undefined)}382      />383    );384385    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;386    passwordButton.click();387    await flushView();388389    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {390      type: "COPY",391      payload: { text: "secret123", label: "password", alreadyWritten: false },392    });393    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {394      type: "FIRE_CANARY_PING",395      payload: {396        webhookUrl: "https://example.com/hook",397        entryTitle: "GitHub",398        trigger: "password_copied",399      },400    });401402    sendMessageMock.mockClear();403    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;404    usernameButton.click();405    await flushView();406407    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {408      type: "COPY",409      payload: { text: "june", label: "username", alreadyWritten: false },410    });411    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {412      type: "FIRE_CANARY_PING",413      payload: {414        webhookUrl: "https://example.com/hook",415        entryTitle: "GitHub",416        trigger: "username_copied",417      },418    });419  });420421  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {422    sendMessageMock.mockResolvedValue({ ok: true });423    const onFieldSaved = vi.fn(async () => undefined);424    const container = mount(425      <EntryView426        entry={sampleEntry()}427        onDelete={vi.fn()}428        onFieldSaved={onFieldSaved}429      />430    );431432    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;433    passwordValue.click();434    await flushView();435436    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;437    inputText(passwordInput, "");438    await flushView();439440    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;441    saveButton.click();442    await flushView();443444    expect(sendMessageMock).not.toHaveBeenCalledWith(445      expect.objectContaining({ type: "UPDATE_ENTRY" })446    );447    expect(onFieldSaved).not.toHaveBeenCalled();448449    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;450    totpMain.click();451    await flushView();452453    expect(sendMessageMock).toHaveBeenLastCalledWith({454      type: "COPY",455      payload: { text: "123456", label: "totp", alreadyWritten: false },456    });457  });458459  it("Entry closes an unchanged inline editor when focus leaves it", async () => {460    sendMessageMock.mockResolvedValue({ ok: true });461    const container = mount(462      <EntryView463        entry={sampleEntry()}464        onDelete={vi.fn()}465        onFieldSaved={vi.fn(async () => undefined)}466      />467    );468469    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;470    usernameValue.click();471    await flushView();472473    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;474    usernameInput.blur();475    await flushView();476477    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();478    expect(sendMessageMock).not.toHaveBeenCalledWith(479      expect.objectContaining({ type: "UPDATE_ENTRY" })480    );481  });482483  it("Entry lets TOTP secrets be edited inline", async () => {484    sendMessageMock.mockResolvedValue({ ok: true });485    const onFieldSaved = vi.fn(async () => undefined);486    const container = mount(487      <EntryView488        entry={sampleEntry()}489        onDelete={vi.fn()}490        onFieldSaved={onFieldSaved}491      />492    );493494    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;495    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));496    await flushView();497498    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;499    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");500    await flushView();501502    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;503    saveButton.click();504    await flushView();505506    expect(sendMessageMock).toHaveBeenCalledWith({507      type: "UPDATE_ENTRY",508      payload: {509        id: "entry-1",510        patch: { totp: "NB2W45DFOIZA====" },511      },512    });513    expect(onFieldSaved).toHaveBeenCalled();514  });515516  it("Vault seeds and focuses search when a search seed is provided", async () => {517    const container = mount(518      <VaultView519        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}520        sortOrder="url-first"521        searchSeed="g"522        searchSeedNonce={1}523        onSelectEntry={vi.fn()}524        onSortOrderChange={vi.fn()}525        onAdd={vi.fn()}526        onClose={vi.fn()}527      />528    );529530    await flushView();531532    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;533    expect(search.value).toBe("g");534    expect(document.activeElement).toBe(search);535  });536537  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {538    parseImportSourceMock.mockResolvedValue(539      sampleImportResult({540        entries: [541          {542            type: "login",543            title: "Imported site",544            username: "imported-user",545            password: "imported-pass",546            url: "https://imported.test",547          },548        ],549      })550    );551    sendMessageMock.mockImplementation(async (msg: unknown) => {552      const type = (msg as { type?: string }).type;553      if (type === "GET_ENTRIES") {554        return {555          ok: true,556          data: [557            sampleEntry({558              id: "existing-1",559              title: "Imported site",560              username: "imported-user",561              url: "https://imported.test",562            }),563          ],564        };565      }566      if (type === "BATCH_ADD_ENTRIES") {567        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };568      }569      return { ok: true };570    });571    const onImport = vi.fn(async () => undefined);572    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);573    await flushView();574    sendMessageMock.mockClear();575576    await selectImportFile(container);577    findButton(container, "Import 1").click();578    await flushView();579580    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);581    expect(sendMessageMock).toHaveBeenCalledWith({582      type: "BATCH_ADD_ENTRIES",583      payload: {584        entries: [585          expect.objectContaining({586            title: "Imported site",587            username: "imported-user",588            password: "imported-pass",589            url: "https://imported.test",590          }),591        ],592      },593    });594    expect(onImport).toHaveBeenCalled();595    expect(container.textContent).toContain("1 entry appears to be duplicates");596  });597598  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {599    parseImportSourceMock.mockResolvedValue(sampleImportResult());600    sendMessageMock.mockImplementation(async (msg: unknown) => {601      const type = (msg as { type?: string }).type;602      if (type === "GET_ENTRIES") {603        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };604      }605      if (type === "CLEAR_VAULT_ENTRIES") {606        return { ok: true };607      }608      if (type === "BATCH_ADD_ENTRIES") {609        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };610      }611      return { ok: true };612    });613    const onImport = vi.fn(async () => undefined);614    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);615    await flushView();616    sendMessageMock.mockClear();617618    await selectImportFile(container);619    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();620    await flushView();621622    findButton(container, "Replace vault with 1").click();623    await flushView();624625    expect(sendMessageMock).not.toHaveBeenCalledWith(626      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })627    );628    expect(container.textContent).toContain("Replace forever? This cannot be undone.");629630    findButton(container, "Yes, replace forever").click();631    await flushView();632633    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });634    expect(sendMessageMock).toHaveBeenCalledWith({635      type: "BATCH_ADD_ENTRIES",636      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },637    });638    expect(onImport).toHaveBeenCalled();639  });640641  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {642    parseImportSourceMock.mockResolvedValue(sampleImportResult());643    sendMessageMock.mockImplementation(async (msg: unknown) => {644      const type = (msg as { type?: string }).type;645      if (type === "CREATE_VAULT") return { ok: true };646      if (type === "BATCH_ADD_ENTRIES") {647        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };648      }649      return { ok: true };650    });651    const onImport = vi.fn(async () => undefined);652    const container = mount(653      <ImportView654        mode="onboarding"655        replacesExistingVault={true}656        onBack={vi.fn()}657        onImport={onImport}658      />659    );660661    await selectImportFile(container);662663    const createButton = findButton(container, "Create vault + import 1");664    expect(createButton.disabled).toBe(true);665    expect(container.textContent).toContain("Restoring now replaces the locked local vault");666667    const masterInput = container.querySelector(668      'input[placeholder="new master password"]'669    ) as HTMLInputElement;670    const confirmInput = container.querySelector(671      'input[placeholder="confirm master password"]'672    ) as HTMLInputElement;673    inputText(masterInput, "fresh-master-password");674    inputText(confirmInput, "fresh-master-password");675    await flushView();676677    expect(createButton.disabled).toBe(false);678    createButton.click();679    await flushView();680681    expect(sendMessageMock).toHaveBeenCalledWith({682      type: "CREATE_VAULT",683      payload: { password: "fresh-master-password" },684    });685    expect(sendMessageMock).toHaveBeenCalledWith({686      type: "BATCH_ADD_ENTRIES",687      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },688    });689    expect(onImport).toHaveBeenCalled();690  });691692  it("Import encrypted backups reparse with the backup password before import", async () => {693    parseImportSourceMock694      .mockResolvedValueOnce(695        sampleImportResult({696          format: "peach-json",697          label: "Peach encrypted backup",698          entries: [],699          totalItems: 0,700          requiresPassword: true,701        })702      )703      .mockResolvedValueOnce(704        sampleImportResult({705          format: "peach-json",706          label: "Peach encrypted backup",707        })708      );709    sendMessageMock.mockImplementation(async (msg: unknown) => {710      const type = (msg as { type?: string }).type;711      if (type === "GET_ENTRIES") return { ok: true, data: [] };712      if (type === "BATCH_ADD_ENTRIES") {713        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };714      }715      return { ok: true };716    });717    const onImport = vi.fn(async () => undefined);718    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);719    await flushView();720    sendMessageMock.mockClear();721722    await selectImportFile(container, '{"type":"peach-export"}');723    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;724    inputText(password, "backup-passphrase");725    pressEnter(password);726    await flushView();727    await flushView();728729    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);730    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");731732    findButton(container, "Import 1").click();733    await flushView();734735    expect(sendMessageMock).toHaveBeenCalledWith({736      type: "BATCH_ADD_ENTRIES",737      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },738    });739    expect(onImport).toHaveBeenCalled();740  });741});