contracts.test.tsx

25.7 KB · raw

1// @vitest-environment jsdom23import type { JSX } from "solid-js";4import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";56import type { ImportParseResult } from "../../shared/import";7import type { VaultEntry } from "../../shared/types";8import { 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("zxcvbn", () => ({50  default: (value: string) => zxcvbnMock(value),51}));5253describe("popup contract tests", () => {54  const disposers: Array<() => void> = [];5556  beforeEach(() => {57    document.body.innerHTML = "";58    sendMessageMock.mockReset();59    triggerRingMock.mockReset();60    triggerPipPulseMock.mockReset();61    parseImportSourceMock.mockReset();62    zxcvbnMock.mockReset();63    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));64    parseImportSourceMock.mockResolvedValue({65      format: "unknown",66      label: "Unknown format",67      entries: [],68      totalItems: 0,69      skippedItems: 0,70      requiresPassword: false,71    });7273    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {74      return window.setTimeout(() => cb(0), 0) as unknown as number;75    });76    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));77    vi.stubGlobal("URL", {78      createObjectURL: vi.fn(() => "blob:peach-test"),79      revokeObjectURL: vi.fn(),80    });81    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});8283    (globalThis as { chrome?: typeof chrome }).chrome = {84      runtime: {85        getManifest: () => ({ version: "1.4.47" }),86        onMessage: { addListener: vi.fn() },87      },88    } as unknown as typeof chrome;89  });9091  afterEach(() => {92    while (disposers.length > 0) {93      disposers.pop()?.();94    }95    vi.unstubAllGlobals();96  });9798  function mount(view: JSX.Element) {99    const mounted = renderView(view);100    disposers.push(mounted.dispose);101    return mounted.container;102  }103104  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {105    return {106      id: "entry-1",107      type: "login",108      title: "GitHub",109      username: "june",110      password: "secret123",111      url: "https://github.com",112      totp: "JBSWY3DPEHPK3PXP",113      canary: {114        webhookUrl: "https://example.com/hook",115      },116      createdAt: 1,117      modifiedAt: 1,118      ...overrides,119    };120  }121122  function sampleImportResult(123    overrides: Partial<ImportParseResult> = {}124  ): ImportParseResult {125    return {126      format: "csv",127      label: "CSV import",128      entries: [129        {130          type: "login",131          title: "Imported site",132          username: "imported-user",133          password: "imported-pass",134          url: "https://imported.test",135        },136      ],137      totalItems: 1,138      skippedItems: 0,139      requiresPassword: false,140      ...overrides,141    };142  }143144  async function selectImportFile(145    container: HTMLElement,146    contents = '{"export":"test"}'147  ) {148    // The Import view now opens onto a menu — click into the file subview149    // before locating the file input. Idempotent: a second click won't re-fire150    // because the menu unmounts once the subview renders.151    const fileCard = findButton(container, "Choose file →");152    if (fileCard) {153      fileCard.click();154      await flushView();155    }156    const input = container.querySelector('input[type="file"]') as HTMLInputElement;157    const file = new File([contents], "import.json", { type: "application/json" });158    Object.defineProperty(input, "files", {159      configurable: true,160      value: [file],161    });162    input.dispatchEvent(new Event("change", { bubbles: true }));163    await flushView();164    await flushView();165  }166167  function findButton(container: HTMLElement, text: string): HTMLButtonElement {168    return Array.from(container.querySelectorAll("button")).find(169      (candidate) => candidate.textContent?.trim() === text170    ) as HTMLButtonElement;171  }172173  it("Lock sends UNLOCK with the entered passphrase", async () => {174    sendMessageMock.mockResolvedValue({ ok: true });175    const onUnlock = vi.fn(async () => undefined);176    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);177178    const password = container.querySelector("#unlock-password") as HTMLInputElement;179    inputText(password, "correct horse battery staple");180    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();181    await flushView();182183    expect(sendMessageMock).toHaveBeenCalledWith({184      type: "UNLOCK",185      payload: { password: "correct horse battery staple" },186    });187    expect(onUnlock).toHaveBeenCalled();188  });189190  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {191    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);192    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;193    const password = container.querySelector("#master-password") as HTMLInputElement;194    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;195196    inputText(password, "short");197    inputText(confirm, "short");198    await flushView();199    expect(createButton.disabled).toBe(true);200201    zxcvbnMock.mockImplementation(() => ({ score: 1 }));202    inputText(password, "weakweakweak");203    inputText(confirm, "weakweakweak");204    await flushView();205    expect(createButton.disabled).toBe(true);206  });207208  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {209    sendMessageMock.mockResolvedValue({ ok: true });210    zxcvbnMock.mockImplementation(() => ({ score: 3 }));211    const onUnlock = vi.fn(async () => undefined);212    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);213214    const password = container.querySelector("#master-password") as HTMLInputElement;215    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;216    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;217218    inputText(password, "correct horse battery staple");219    inputText(confirm, "correct horse battery staple");220    await flushView();221    expect(createButton.disabled).toBe(false);222223    pressEnter(confirm);224    await flushView();225226    expect(sendMessageMock).toHaveBeenCalledWith({227      type: "CREATE_VAULT",228      payload: { password: "correct horse battery staple" },229    });230    expect(onUnlock).toHaveBeenCalled();231  });232233  it("Export menu surfaces the three v2.5.0 backup formats", async () => {234    const container = mount(<ExportView onBack={vi.fn()} />);235    await flushView();236237    expect(container.textContent).toContain("PeachScript");238    expect(container.textContent).toContain("print backup");239    expect(container.textContent).toContain("CSV");240  });241242  it("Settings requires current, new, and confirm passwords before enabling save", async () => {243    sendMessageMock.mockResolvedValue({ ok: true, data: null });244    const container = mount(245      <SettingsView246        entriesCount={1}247        breachMonitoring={true}248        lockOnClose={true}249        autoLockMinutes={5}250        trashRetentionDays={30}251        theme="dark"252        accentColor="peach"253        displayFont="cormorant-italic"254        textSize="medium"255        reduceMotion={false}256        onBreachMonitoringChange={vi.fn()}257        onLockOnCloseChange={vi.fn()}258        onAutoLockMinutesChange={vi.fn()}259        onTrashRetentionChange={vi.fn()}260        onThemeChange={vi.fn()}261        onAccentChange={vi.fn()}262        onDisplayFontChange={vi.fn()}263        onTextSizeChange={vi.fn()}264        onReduceMotionChange={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        displayFont="cormorant-italic"317        textSize="medium"318        reduceMotion={false}319        onBreachMonitoringChange={vi.fn()}320        onLockOnCloseChange={vi.fn()}321        onAutoLockMinutesChange={onAutoLockMinutesChange}322        onTrashRetentionChange={vi.fn()}323        onThemeChange={onThemeChange}324        onAccentChange={vi.fn()}325        onDisplayFontChange={vi.fn()}326        onTextSizeChange={vi.fn()}327        onReduceMotionChange={vi.fn()}328        shortcuts={DEFAULT_BINDINGS}329        onShortcutsChange={vi.fn()}330        onBack={vi.fn()}331        onImport={vi.fn()}332        onExport={vi.fn()}333        onBreachCheckDone={vi.fn()}334      />335    );336    await flushView();337    sendMessageMock.mockClear();338339    const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;340341    const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(342      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"343    ) as HTMLButtonElement;344    securityRow.click();345    await flushView();346347    const selects = container.querySelectorAll("select");348    changeSelect(selects[0] as HTMLSelectElement, "15");349    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);350351    const changeButton = Array.from(container.querySelectorAll("button")).find(352      (candidate) => candidate.textContent?.trim() === "change"353    ) as HTMLButtonElement;354    changeButton.click();355    await flushView();356357    const inputs = container.querySelectorAll('input[type="password"]');358    inputText(inputs[0] as HTMLInputElement, "current-pass");359    inputText(inputs[1] as HTMLInputElement, "new-password-123");360    inputText(inputs[2] as HTMLInputElement, "new-password-123");361    await flushView();362363    const saveButton = Array.from(container.querySelectorAll("button")).find(364      (candidate) => candidate.textContent?.trim() === "save new password"365    ) as HTMLButtonElement;366    saveButton.click();367    await flushView();368369    headerBack.click();370    await flushView();371    const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(372      (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"373    ) as HTMLButtonElement;374    appearanceRow.click();375    await flushView();376    const themeButton = Array.from(container.querySelectorAll("button")).find(377      (candidate) => candidate.textContent?.trim() === "Switch to light"378    ) as HTMLButtonElement;379    themeButton.click();380    expect(onThemeChange).toHaveBeenCalledWith("light");381382    expect(sendMessageMock).toHaveBeenCalledWith({383      type: "CHANGE_PASSWORD",384      payload: {385        currentPassword: "current-pass",386        newPassword: "new-password-123",387      },388    });389  });390391  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {392    sendMessageMock.mockResolvedValue({ ok: true });393    const container = mount(394      <EntryView395        entry={sampleEntry()}396        onDelete={vi.fn()}397        onFieldSaved={vi.fn(async () => undefined)}398      />399    );400401    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;402    passwordButton.click();403    await flushView();404405    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {406      type: "COPY",407      payload: { text: "secret123", label: "password", alreadyWritten: false },408    });409    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {410      type: "FIRE_CANARY_PING",411      payload: {412        webhookUrl: "https://example.com/hook",413        entryTitle: "GitHub",414        trigger: "password_copied",415      },416    });417418    sendMessageMock.mockClear();419    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;420    usernameButton.click();421    await flushView();422423    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {424      type: "COPY",425      payload: { text: "june", label: "username", alreadyWritten: false },426    });427    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {428      type: "FIRE_CANARY_PING",429      payload: {430        webhookUrl: "https://example.com/hook",431        entryTitle: "GitHub",432        trigger: "username_copied",433      },434    });435  });436437  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {438    sendMessageMock.mockResolvedValue({ ok: true });439    const onFieldSaved = vi.fn(async () => undefined);440    const container = mount(441      <EntryView442        entry={sampleEntry()}443        onDelete={vi.fn()}444        onFieldSaved={onFieldSaved}445      />446    );447448    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;449    passwordValue.click();450    await flushView();451452    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;453    inputText(passwordInput, "");454    await flushView();455456    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;457    saveButton.click();458    await flushView();459460    expect(sendMessageMock).not.toHaveBeenCalledWith(461      expect.objectContaining({ type: "UPDATE_ENTRY" })462    );463    expect(onFieldSaved).not.toHaveBeenCalled();464465    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;466    totpMain.click();467    await flushView();468469    expect(sendMessageMock).toHaveBeenLastCalledWith({470      type: "COPY",471      payload: { text: "123456", label: "totp", alreadyWritten: false },472    });473  });474475  it("Entry closes an unchanged inline editor when focus leaves it", async () => {476    sendMessageMock.mockResolvedValue({ ok: true });477    const container = mount(478      <EntryView479        entry={sampleEntry()}480        onDelete={vi.fn()}481        onFieldSaved={vi.fn(async () => undefined)}482      />483    );484485    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;486    usernameValue.click();487    await flushView();488489    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;490    usernameInput.blur();491    await flushView();492493    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();494    expect(sendMessageMock).not.toHaveBeenCalledWith(495      expect.objectContaining({ type: "UPDATE_ENTRY" })496    );497  });498499  it("Entry lets TOTP secrets be edited inline", async () => {500    sendMessageMock.mockResolvedValue({ ok: true });501    const onFieldSaved = vi.fn(async () => undefined);502    const container = mount(503      <EntryView504        entry={sampleEntry()}505        onDelete={vi.fn()}506        onFieldSaved={onFieldSaved}507      />508    );509510    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;511    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));512    await flushView();513514    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;515    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");516    await flushView();517518    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;519    saveButton.click();520    await flushView();521522    expect(sendMessageMock).toHaveBeenCalledWith({523      type: "UPDATE_ENTRY",524      payload: {525        id: "entry-1",526        patch: { totp: "NB2W45DFOIZA====" },527      },528    });529    expect(onFieldSaved).toHaveBeenCalled();530  });531532  it("Vault seeds and focuses search when a search seed is provided", async () => {533    const container = mount(534      <VaultView535        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}536        sortOrder="url-first"537        searchSeed="g"538        searchSeedNonce={1}539        onSelectEntry={vi.fn()}540        onSortOrderChange={vi.fn()}541        onAdd={vi.fn()}542        onClose={vi.fn()}543      />544    );545546    await flushView();547548    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;549    expect(search.value).toBe("g");550    expect(document.activeElement).toBe(search);551  });552553  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {554    parseImportSourceMock.mockResolvedValue(555      sampleImportResult({556        entries: [557          {558            type: "login",559            title: "Imported site",560            username: "imported-user",561            password: "imported-pass",562            url: "https://imported.test",563          },564        ],565      })566    );567    sendMessageMock.mockImplementation(async (msg: unknown) => {568      const type = (msg as { type?: string }).type;569      if (type === "GET_ENTRIES") {570        return {571          ok: true,572          data: [573            sampleEntry({574              id: "existing-1",575              title: "Imported site",576              username: "imported-user",577              url: "https://imported.test",578            }),579          ],580        };581      }582      if (type === "BATCH_ADD_ENTRIES") {583        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };584      }585      return { ok: true };586    });587    const onImport = vi.fn(async () => undefined);588    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);589    await flushView();590    sendMessageMock.mockClear();591592    await selectImportFile(container);593    findButton(container, "Import 1").click();594    await flushView();595596    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);597    expect(sendMessageMock).toHaveBeenCalledWith({598      type: "BATCH_ADD_ENTRIES",599      payload: {600        entries: [601          expect.objectContaining({602            title: "Imported site",603            username: "imported-user",604            password: "imported-pass",605            url: "https://imported.test",606          }),607        ],608      },609    });610    expect(onImport).toHaveBeenCalled();611    expect(container.textContent).toContain("1 entry appears to be duplicates");612  });613614  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {615    parseImportSourceMock.mockResolvedValue(sampleImportResult());616    sendMessageMock.mockImplementation(async (msg: unknown) => {617      const type = (msg as { type?: string }).type;618      if (type === "GET_ENTRIES") {619        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };620      }621      if (type === "CLEAR_VAULT_ENTRIES") {622        return { ok: true };623      }624      if (type === "BATCH_ADD_ENTRIES") {625        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };626      }627      return { ok: true };628    });629    const onImport = vi.fn(async () => undefined);630    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);631    await flushView();632    sendMessageMock.mockClear();633634    await selectImportFile(container);635    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();636    await flushView();637638    findButton(container, "Replace vault with 1").click();639    await flushView();640641    expect(sendMessageMock).not.toHaveBeenCalledWith(642      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })643    );644    expect(container.textContent).toContain("Replace forever? This cannot be undone.");645646    findButton(container, "Yes, replace forever").click();647    await flushView();648649    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });650    expect(sendMessageMock).toHaveBeenCalledWith({651      type: "BATCH_ADD_ENTRIES",652      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },653    });654    expect(onImport).toHaveBeenCalled();655  });656657  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {658    parseImportSourceMock.mockResolvedValue(sampleImportResult());659    sendMessageMock.mockImplementation(async (msg: unknown) => {660      const type = (msg as { type?: string }).type;661      if (type === "CREATE_VAULT") return { ok: true };662      if (type === "BATCH_ADD_ENTRIES") {663        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };664      }665      return { ok: true };666    });667    const onImport = vi.fn(async () => undefined);668    const container = mount(669      <ImportView670        mode="onboarding"671        replacesExistingVault={true}672        onBack={vi.fn()}673        onImport={onImport}674      />675    );676677    await selectImportFile(container);678679    const createButton = findButton(container, "Create vault + import 1");680    expect(createButton.disabled).toBe(true);681    expect(container.textContent).toContain("Restoring now replaces the locked local vault");682683    const masterInput = container.querySelector(684      'input[placeholder="new master password"]'685    ) as HTMLInputElement;686    const confirmInput = container.querySelector(687      'input[placeholder="confirm master password"]'688    ) as HTMLInputElement;689    inputText(masterInput, "fresh-master-password");690    inputText(confirmInput, "fresh-master-password");691    await flushView();692693    expect(createButton.disabled).toBe(false);694    createButton.click();695    await flushView();696697    expect(sendMessageMock).toHaveBeenCalledWith({698      type: "CREATE_VAULT",699      payload: { password: "fresh-master-password" },700    });701    expect(sendMessageMock).toHaveBeenCalledWith({702      type: "BATCH_ADD_ENTRIES",703      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },704    });705    expect(onImport).toHaveBeenCalled();706  });707708  it("Import encrypted backups reparse with the backup password before import", async () => {709    parseImportSourceMock710      .mockResolvedValueOnce(711        sampleImportResult({712          format: "peach-json",713          label: "Peach encrypted backup",714          entries: [],715          totalItems: 0,716          requiresPassword: true,717        })718      )719      .mockResolvedValueOnce(720        sampleImportResult({721          format: "peach-json",722          label: "Peach encrypted backup",723        })724      );725    sendMessageMock.mockImplementation(async (msg: unknown) => {726      const type = (msg as { type?: string }).type;727      if (type === "GET_ENTRIES") return { ok: true, data: [] };728      if (type === "BATCH_ADD_ENTRIES") {729        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };730      }731      return { ok: true };732    });733    const onImport = vi.fn(async () => undefined);734    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);735    await flushView();736    sendMessageMock.mockClear();737738    await selectImportFile(container, '{"type":"peach-export"}');739    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;740    inputText(password, "backup-passphrase");741    pressEnter(password);742    await flushView();743    await flushView();744745    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);746    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");747748    findButton(container, "Import 1").click();749    await flushView();750751    expect(sendMessageMock).toHaveBeenCalledWith({752      type: "BATCH_ADD_ENTRIES",753      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },754    });755    expect(onImport).toHaveBeenCalled();756  });757});