contracts.test.tsx

53.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 { WORD_LIST } from "../../shared/recovery-word-list";9import { LockView } from "./Lock";10import { ExportView } from "./Export";11import { SettingsView } from "./Settings";12import { DEFAULT_BINDINGS } from "../../shared/shortcuts";13import { EntryView } from "./Entry";14import { ImportView } from "./Import";15import { AddView } from "./Add";16import { NotesEditor } from "./NotesEditor";17import { RecoveryWordEntryView } from "./RecoveryWordEntry";18import { VaultView } from "./Vault";19import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2021const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();22const triggerRingMock = vi.fn();23const triggerPipPulseMock = vi.fn();24const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();25const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2627vi.mock("../App", () => ({28  sendMessage: (msg: unknown) => sendMessageMock(msg),29  triggerRing: () => triggerRingMock(),30  triggerPipPulse: () => triggerPipPulseMock(),31}));3233vi.mock("../../shared/bridge", () => ({34  sendMessage: (msg: unknown) => sendMessageMock(msg),35}));3637vi.mock("../../shared/import", () => ({38  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),39}));4041vi.mock("../../shared/favicon", async (importOriginal) => {42  const actual = await importOriginal<typeof import("../../shared/favicon")>();43  return { ...actual, faviconUrl: () => null };44});4546vi.mock("../../crypto/totp", () => ({47  generateTOTP: () => ({ code: "123 456", remaining: 24 }),48  parseOtpauthURI: (value: string) =>49    value.startsWith("otpauth://")50      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }51      : null,52}));5354vi.mock("../components/GeneratorDrawer", () => ({55  GeneratorDrawer: () => null,56}));5758vi.mock("../components/RuneBackground", () => ({59  RuneBackground: () => null,60}));6162vi.mock("@zxcvbn-ts/core", () => ({63  zxcvbn: (value: string) => zxcvbnMock(value),64  zxcvbnOptions: { setOptions: vi.fn() },65}));66vi.mock("@zxcvbn-ts/language-common", () => ({67  adjacencyGraphs: {},68  dictionary: {},69}));70vi.mock("@zxcvbn-ts/language-en", () => ({71  translations: {},72  dictionary: {},73}));7475describe("popup contract tests", () => {76  const disposers: Array<() => void> = [];7778  beforeEach(() => {79    document.body.innerHTML = "";80    sendMessageMock.mockReset();81    triggerRingMock.mockReset();82    triggerPipPulseMock.mockReset();83    parseImportSourceMock.mockReset();84    zxcvbnMock.mockReset();85    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));86    parseImportSourceMock.mockResolvedValue({87      format: "unknown",88      label: "Unknown format",89      entries: [],90      totalItems: 0,91      skippedItems: 0,92      requiresPassword: false,93    });9495    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {96      return window.setTimeout(() => cb(0), 0) as unknown as number;97    });98    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));99    vi.stubGlobal("URL", {100      createObjectURL: vi.fn(() => "blob:peach-test"),101      revokeObjectURL: vi.fn(),102    });103    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});104105    (globalThis as { chrome?: typeof chrome }).chrome = {106      runtime: {107        getManifest: () => ({ version: "1.4.47" }),108        onMessage: { addListener: vi.fn() },109      },110    } as unknown as typeof chrome;111  });112113  afterEach(() => {114    while (disposers.length > 0) {115      disposers.pop()?.();116    }117    vi.restoreAllMocks();118    vi.unstubAllGlobals();119  });120121  function mount(view: JSX.Element | (() => JSX.Element)) {122    const mounted = renderView(view);123    disposers.push(mounted.dispose);124    return mounted.container;125  }126127  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {128    return {129      id: "entry-1",130      type: "login",131      title: "GitHub",132      username: "june",133      password: "secret123",134      url: "https://github.com",135      totp: "JBSWY3DPEHPK3PXP",136      canary: {137        webhookUrl: "https://example.com/hook",138      },139      createdAt: 1,140      modifiedAt: 1,141      ...overrides,142    };143  }144145  function sampleImportResult(146    overrides: Partial<ImportParseResult> = {}147  ): ImportParseResult {148    return {149      format: "csv",150      label: "CSV import",151      entries: [152        {153          type: "login",154          title: "Imported site",155          username: "imported-user",156          password: "imported-pass",157          url: "https://imported.test",158        },159      ],160      totalItems: 1,161      skippedItems: 0,162      requiresPassword: false,163      ...overrides,164    };165  }166167  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {168    return {169      entriesCount: 1,170      breachMonitoring: true,171      lockOnClose: true,172      autoLockMinutes: 5,173      trashRetentionDays: 30,174      theme: "dark" as const,175      accentColor: "peach" as const,176      textSize: "medium" as const,177      reduceMotion: false,178      onBreachMonitoringChange: vi.fn(),179      onLockOnCloseChange: vi.fn(),180      onAutoLockMinutesChange: vi.fn(),181      onTrashRetentionChange: vi.fn(),182      onThemeChange: vi.fn(),183      onAccentChange: vi.fn(),184      onTextSizeChange: vi.fn(),185      onReduceMotionChange: vi.fn(),186      shortcuts: DEFAULT_BINDINGS,187      onShortcutsChange: vi.fn(),188      onBack: vi.fn(),189      onImport: vi.fn(),190      onExport: vi.fn(),191      onBreachCheckDone: vi.fn(),192      ...overrides,193    };194  }195196  async function selectImportFile(197    container: HTMLElement,198    contents = '{"export":"test"}'199  ) {200    // The Import view now opens onto a menu — click into the file subview201    // before locating the file input. Idempotent: a second click won't re-fire202    // because the menu unmounts once the subview renders.203    const fileCard = findButton(container, "Choose file →");204    if (fileCard) {205      fileCard.click();206      await flushView();207    }208    const input = container.querySelector('input[type="file"]') as HTMLInputElement;209    const file = new File([contents], "import.json", { type: "application/json" });210    Object.defineProperty(input, "files", {211      configurable: true,212      value: [file],213    });214    input.dispatchEvent(new Event("change", { bubbles: true }));215    await flushView();216    await flushView();217  }218219  function findButton(container: HTMLElement, text: string): HTMLButtonElement {220    return Array.from(container.querySelectorAll("button")).find(221      (candidate) => candidate.textContent?.trim() === text222    ) as HTMLButtonElement;223  }224225  it("Lock sends UNLOCK with the entered passphrase", async () => {226    sendMessageMock.mockResolvedValue({ ok: true });227    const onUnlock = vi.fn(async () => undefined);228    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);229230    const password = container.querySelector("#unlock-password") as HTMLInputElement;231    inputText(password, "correct horse battery staple");232    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();233    await flushView();234235    expect(sendMessageMock).toHaveBeenCalledWith({236      type: "UNLOCK",237      payload: { password: "correct horse battery staple" },238    });239    expect(onUnlock).toHaveBeenCalled();240  });241242  it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {243    sendMessageMock.mockImplementation(async (msg: unknown) => {244      return (msg as { type: string }).type === "GET_SETTINGS"245        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }246        : { ok: true };247    });248    const onUnlock = vi.fn(async () => undefined);249    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);250    await flushView();251252    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");253    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();254    await flushView();255256    expect(sendMessageMock).toHaveBeenCalledWith({257      type: "UNLOCK_WITH_PIN",258      payload: { pin: "1234" },259    });260    expect(onUnlock).toHaveBeenCalled();261    expect(container.textContent).toContain("use master password");262  });263264  it("places the PIN method switch below the unlock button", async () => {265    sendMessageMock.mockImplementation(async (msg: unknown) => {266      return (msg as { type: string }).type === "GET_SETTINGS"267        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }268        : { ok: true };269    });270    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);271    await flushView();272273    const buttons = Array.from(container.querySelectorAll("button"));274    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");275    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");276    expect(pinIndex).toBeGreaterThan(unlockIndex);277  });278279  it("keeps standalone note creation in Notes mode", async () => {280    const chromeApi = globalThis.chrome as unknown as {281      tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };282    };283    chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };284285    const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);286    await flushView();287288    expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))289      .toEqual(["login", "card"]);290    expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();291  });292293  it("gives Notes mode the full note-formatting toolbar", async () => {294    const container = mount(295      <NotesEditor296        note={null}297        onBack={vi.fn()}298        onSaved={vi.fn()}299        onDelete={vi.fn()}300      />301    );302    await flushView();303304    expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();305    expect(container.querySelector('[title="Divider"]')).toBeTruthy();306    expect(container.querySelector('[title="Quote"]')).toBeTruthy();307    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);308    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);309    expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");310  });311312  it("saves formatting applied by the Notes toolbar before leaving", async () => {313    const savedNote = {314      id: "note-1",315      type: "note" as const,316      title: "Untitled",317      notes: "<b>formatted</b>",318      createdAt: 1,319      modifiedAt: 2,320    };321    sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });322    const originalExecCommand = document.execCommand;323    const execCommandMock = vi.fn(() => {324      const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");325      if (body) body.innerHTML = "<b>formatted</b>";326      return true;327    });328    Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });329330    try {331      const container = mount(332        <NotesEditor333          note={null}334          onBack={vi.fn()}335          onSaved={vi.fn()}336          onDelete={vi.fn()}337        />338      );339      await flushView();340      container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(341        new MouseEvent("mousedown", { bubbles: true, cancelable: true })342      );343      expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);344      container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();345      await flushView();346347      expect(sendMessageMock).toHaveBeenCalledWith({348        type: "SAVE_NOTE",349        payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },350      });351    } finally {352      Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });353    }354  });355356  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {357    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);358    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;359    const password = container.querySelector("#master-password") as HTMLInputElement;360    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;361362    inputText(password, "short");363    inputText(confirm, "short");364    await flushView();365    expect(createButton.disabled).toBe(true);366367    zxcvbnMock.mockImplementation(() => ({ score: 1 }));368    inputText(password, "weakweakweak");369    inputText(confirm, "weakweakweak");370    await flushView();371    expect(createButton.disabled).toBe(true);372  });373374  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {375    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock376    sendMessageMock.mockImplementation(377      async (msg: unknown) => {378        const m = msg as { type: string; payload?: { password?: string } };379        if (m.type === "CREATE_VAULT") return { ok: true };380        if (m.type === "GET_RECOVERY_WORDS") {381          // Return 12 fake words — must pass findInvalidWords382          return {383            ok: true,384            data: [385              "abandon", "ability", "able", "about", "above", "absent",386              "absorb", "abstract", "absurd", "abuse", "access", "accident",387            ],388          };389        }390        return { ok: true };391      }392    );393    zxcvbnMock.mockImplementation(() => ({ score: 3 }));394    const onUnlock = vi.fn(async () => undefined);395    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);396397    const password = container.querySelector("#master-password") as HTMLInputElement;398    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;399    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;400401    inputText(password, "correct horse battery staple");402    inputText(confirm, "correct horse battery staple");403    await flushView();404    expect(createButton.disabled).toBe(false);405406    pressEnter(confirm);407    await flushView();408409    expect(sendMessageMock).toHaveBeenCalledWith({410      type: "CREATE_VAULT",411      payload: { password: "correct horse battery staple" },412    });413    // After CREATE_VAULT success, the recovery word display is shown.414    // onUnlock is NOT called yet — user must first confirm their recovery words.415    expect(onUnlock).not.toHaveBeenCalled();416  });417418  it("Recovery words flow submits the new master password and completes", async () => {419    sendMessageMock.mockImplementation(async (msg: unknown) => {420      const type = (msg as { type?: string }).type;421      if (type === "VALIDATE_RECOVERY_WORDS") {422        return { ok: true, data: { valid: true, invalid: [] } };423      }424      if (type === "RECOVER_WITH_WORDS") return { ok: true };425      return { ok: true };426    });427    const canvasContext = {428      clearRect: vi.fn(),429      fillText: vi.fn(),430      beginPath: vi.fn(),431      closePath: vi.fn(),432      arc: vi.fn(),433      fill: vi.fn(),434      stroke: vi.fn(),435      save: vi.fn(),436      restore: vi.fn(),437      translate: vi.fn(),438      rotate: vi.fn(),439      scale: vi.fn(),440      drawImage: vi.fn(),441      measureText: vi.fn(() => ({ width: 0 })),442      setTransform: vi.fn(),443      fillRect: vi.fn(),444      strokeRect: vi.fn(),445    } as unknown as CanvasRenderingContext2D;446    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);447448    const onRecoveryComplete = vi.fn(async () => undefined);449    const container = mount(450      <RecoveryWordEntryView451        onCancel={vi.fn()}452        onRecoveryComplete={onRecoveryComplete}453      />454    );455456    const recoveryWords = WORD_LIST.slice(0, 12);457    for (let i = 0; i < recoveryWords.length; i++) {458      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;459      inputText(input, recoveryWords[i]!);460    }461    await flushView();462463    findButton(container, "verify words").click();464    await flushView();465466    expect(container.textContent).toContain("new master password");467468    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;469    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;470    inputText(newPassword, "fresh-recovery-password");471    inputText(confirmPassword, "fresh-recovery-password");472    await flushView();473474    findButton(container, "recover vault").click();475    await flushView();476477    expect(sendMessageMock).toHaveBeenCalledWith({478      type: "RECOVER_WITH_WORDS",479      payload: {480        words: recoveryWords,481        newPassword: "fresh-recovery-password",482      },483    });484    expect(onRecoveryComplete).toHaveBeenCalled();485  });486487  it("Export menu surfaces both backup formats", async () => {488    const container = mount(<ExportView onBack={vi.fn()} />);489    await flushView();490491    expect(container.textContent).toContain("PeachScript");492    expect(container.textContent).toContain("CSV");493  });494495  it("Lock can open directly on backup restore from onboarding", async () => {496    const container = mount(() => (497      <LockView498        hasVault={false}499        ready={true}500        onUnlock={vi.fn()}501        initialRestore={true}502      />503    ));504    await flushView();505506    expect(container.textContent).toContain("PeachScript");507    expect(container.textContent).toContain("Codex");508    expect(container.textContent).toContain("CSV");509    expect(container.querySelector("#master-password")).toBeNull();510  });511512  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {513    const device = {514      deviceId: "phone-1",515      deviceName: "June's Phone",516      deviceType: "android" as const,517      longTermPublicKeyHex: "a".repeat(64),518      signingPublicKeyHex: "b".repeat(64),519      pairedAt: 1,520      trustSource: "direct" as const,521    };522    sendMessageMock.mockImplementation(async (msg: unknown) => {523      const m = msg as { type: string };524      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };525      return { ok: true, data: null };526    });527528    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);529    for (let i = 0; i < 30; i++) await Promise.resolve();530531    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>532      /sync/i.test(b.textContent ?? ""),533    );534    openSync?.click();535    for (let i = 0; i < 30; i++) await Promise.resolve();536537    const remove = Array.from(container.querySelectorAll("button")).find(538      (b) => (b.textContent ?? "").trim() === "remove",539    );540    expect(remove).toBeTruthy();541542    // Guard against a vacuous pass: the disclosure must not already be on543    // screen before the user asks to remove anything.544    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();545546    remove!.click();547    for (let i = 0; i < 10; i++) await Promise.resolve();548549    // Revocation must never be presented as securing the vault against the550    // removed device — recovery words survive it and restore everything.551    const note = container.querySelector(".peach-unpair-confirm-note");552    expect(note).toBeTruthy();553    const text = note!.textContent ?? "";554    expect(text).toContain("master password");555    expect(text).toContain("recovery words");556    expect(text).toContain("future changes");557  });558559  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {560    const device = {561      deviceId: "phone-1",562      deviceName: "June's Phone",563      deviceType: "android" as const,564      longTermPublicKeyHex: "a".repeat(64),565      signingPublicKeyHex: "b".repeat(64),566      pairedAt: 1,567      trustSource: "direct" as const,568    };569    let paired: typeof device[] = [device];570    sendMessageMock.mockImplementation(async (msg: any) => {571      if (msg?.type === "LIST_PAIRED_DEVICES") {572        return { ok: true, data: { devices: [...paired] } };573      }574      if (msg?.type === "UNPAIR_DEVICE") {575        // Mirror the real handler: UNPAIR_DEVICE drops the row from the576        // registry. The next LIST_PAIRED_DEVICES must not see it.577        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);578        return { ok: true };579      }580      if (msg?.type === "RENAME_PAIRED_DEVICE") {581        paired = paired.map((d) =>582          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,583        );584        return { ok: true };585      }586      return { ok: true, data: null };587    });588589    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);590    for (let i = 0; i < 30; i++) await Promise.resolve();591    Array.from(container.querySelectorAll("button"))592      .find((b) => /sync/i.test(b.textContent ?? ""))593      ?.click();594    for (let i = 0; i < 30; i++) await Promise.resolve();595596    // The list itself: extracting the relay selector must not take the hub with it.597    expect(container.textContent).toContain("PAIRED DEVICES");598    expect(container.textContent).toContain("June's Phone");599    expect(container.textContent).toContain("Android");600    Array.from(container.querySelectorAll("button"))601      .find((b) => (b.textContent ?? "").trim() === "rename")!602      .click();603    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;604    renameInput.value = "Travel phone";605    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));606    Array.from(container.querySelectorAll("button"))607      .find((b) => (b.textContent ?? "").trim() === "save")!608      .click();609    for (let i = 0; i < 30; i++) await Promise.resolve();610    expect(sendMessageMock).toHaveBeenCalledWith({611      type: "RENAME_PAIRED_DEVICE",612      payload: { deviceId: "phone-1", name: "Travel phone" },613    });614    expect(container.textContent).toContain("Travel phone");615    const syncNowButton = container.querySelector<HTMLButtonElement>(616      'button[aria-label="Sync this vault now"]',617    )!;618    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));619    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;620    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;621    expect(622      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,623    ).toBeTruthy();624    expect(625      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,626    ).toBeTruthy();627628    Array.from(container.querySelectorAll("button"))629      .find((b) => (b.textContent ?? "").trim() === "remove")!630      .click();631    for (let i = 0; i < 10; i++) await Promise.resolve();632    const confirm = Array.from(633      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),634    ).find((b) => (b.textContent ?? "").trim() === "remove");635    confirm!.click();636    for (let i = 0; i < 30; i++) await Promise.resolve();637638    expect(sendMessageMock).toHaveBeenCalledWith({639      type: "UNPAIR_DEVICE",640      payload: { deviceId: "phone-1" },641    });642    // The list must be refreshed and the device must be gone. The regression643    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but644    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.645    expect(container.textContent).not.toContain("June's Phone");646    expect(container.textContent).not.toContain("PAIRED DEVICES");647  });648649  it("Settings keeps connection and recovery details collapsed until requested", async () => {650    sendMessageMock.mockImplementation(async (msg: any) => {651      if (msg?.type === "LIST_PAIRED_DEVICES") {652        return { ok: true, data: { devices: [] } };653      }654      return { ok: true, data: null };655    });656657    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);658    for (let i = 0; i < 30; i++) await Promise.resolve();659    Array.from(container.querySelectorAll("button"))660      .find((button) => /sync/i.test(button.textContent ?? ""))661      ?.click();662    for (let i = 0; i < 20; i++) await Promise.resolve();663664    expect(container.textContent).toContain("SYNC STATUS");665    expect(container.textContent).toContain("ADD A DEVICE");666    expect(container.textContent).not.toContain("Public relay");667    expect(container.textContent).not.toContain("Rotate sync identity");668669    const connection = Array.from(container.querySelectorAll("button"))670      .find((button) => button.textContent?.includes("CONNECTION"));671    connection?.click();672    await Promise.resolve();673    expect(container.textContent).toContain("Public relay");674675    const recovery = Array.from(container.querySelectorAll("button"))676      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));677    recovery?.click();678    await Promise.resolve();679    expect(container.textContent).toContain("Rotate sync identity");680  });681682  it("Settings requires current, new, and confirm passwords before enabling save", async () => {683    sendMessageMock.mockResolvedValue({ ok: true, data: null });684    const container = mount(685      <SettingsView686        entriesCount={1}687        breachMonitoring={true}688        lockOnClose={true}689        autoLockMinutes={5}690        trashRetentionDays={30}691        theme="dark"692        accentColor="peach"693        textSize="medium"694        reduceMotion={false}695        onBreachMonitoringChange={vi.fn()}696        onLockOnCloseChange={vi.fn()}697        onAutoLockMinutesChange={vi.fn()}698        onTrashRetentionChange={vi.fn()}699        onThemeChange={vi.fn()}700        onAccentChange={vi.fn()}701        onTextSizeChange={vi.fn()}702        onReduceMotionChange={vi.fn()}703        shortcuts={DEFAULT_BINDINGS}704        onShortcutsChange={vi.fn()}705        onBack={vi.fn()}706        onImport={vi.fn()}707        onExport={vi.fn()}708        onBreachCheckDone={vi.fn()}709      />710    );711    await flushView();712713    expect(container.querySelectorAll(".stk-card").length).toBe(7);714    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]715    // "open settings to card I (Peach Pro) instead of card II (Protection)".716    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");717718    const changeButton = Array.from(container.querySelectorAll("button")).find(719      (candidate) => candidate.textContent?.trim() === "change"720    ) as HTMLButtonElement;721    changeButton.click();722    await flushView();723724    const saveButton = Array.from(container.querySelectorAll("button")).find(725      (candidate) => candidate.textContent?.trim() === "save new password"726    ) as HTMLButtonElement;727    expect(saveButton.disabled).toBe(true);728729    const inputs = container.querySelectorAll('input[type="password"]');730    inputText(inputs[0] as HTMLInputElement, "current-pass");731    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");732    await flushView();733    expect(saveButton.disabled).toBe(true);734735    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");736    await flushView();737    expect(saveButton.disabled).toBe(false);738  });739740  it("Settings feedback highlights the selected category and hides the form after submit", async () => {741    sendMessageMock.mockResolvedValue({ ok: true, data: null });742    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({743      ok: true,744      json: async () => ({ success: true }),745    }));746    vi.stubGlobal("fetch", fetchMock);747748    const container = mount(749      <SettingsView750        entriesCount={1}751        breachMonitoring={true}752        lockOnClose={true}753        autoLockMinutes={5}754        trashRetentionDays={30}755        theme="dark"756        accentColor="peach"757        textSize="medium"758        reduceMotion={false}759        initialSection="feedback"760        onBreachMonitoringChange={vi.fn()}761        onLockOnCloseChange={vi.fn()}762        onAutoLockMinutesChange={vi.fn()}763        onTrashRetentionChange={vi.fn()}764        onThemeChange={vi.fn()}765        onAccentChange={vi.fn()}766        onTextSizeChange={vi.fn()}767        onReduceMotionChange={vi.fn()}768        shortcuts={DEFAULT_BINDINGS}769        onShortcutsChange={vi.fn()}770        onBack={vi.fn()}771        onImport={vi.fn()}772        onExport={vi.fn()}773        onBreachCheckDone={vi.fn()}774      />775    );776    await flushView();777778    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;779    expect(feedbackCard.classList.contains("is-active")).toBe(true);780781    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];782    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;783    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;784    expect(bugButton.getAttribute("aria-pressed")).toBe("true");785    expect(featureButton.getAttribute("aria-pressed")).toBe("false");786787    featureButton.click();788    await flushView();789790    expect(bugButton.getAttribute("aria-pressed")).toBe("false");791    expect(featureButton.getAttribute("aria-pressed")).toBe("true");792    expect(featureButton.classList.contains("is-active")).toBe(true);793794    inputText(795      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,796      '<script>alert("xss")</script> Please make this easier to see & safer.'797    );798    const sendButton = Array.from(container.querySelectorAll("button")).find(799      (candidate) => candidate.textContent?.trim() === "Send"800    ) as HTMLButtonElement;801    sendButton.click();802803    await vi.waitFor(() => {804      expect(container.textContent).toContain("Thank you");805    });806807    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));808    expect(requestBody.subject).toContain("Feature request");809    expect(requestBody.message).toBe(810      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."811    );812    expect(requestBody.message).not.toContain("<script>");813    expect(container.textContent).toContain("Someone will process it soon");814    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();815    expect(container.querySelector(".peach-feedback-submit")).toBeNull();816  });817818  it("Settings keeps license activation on the full-screen celebration path", async () => {819    const onLicenseChange = vi.fn(async () => undefined);820    sendMessageMock.mockImplementation(async (msg: unknown) => {821      const type = (msg as { type?: string }).type;822      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };823      if (type === "ACTIVATE_LICENSE") {824        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };825      }826      if (type === "GET_ENTRIES") return { ok: true, data: [] };827      return { ok: true, data: null };828    });829830    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);831    await flushView();832833    inputText(834      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,835      "PEACH-TEST-KEY",836    );837    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;838    apply.click();839840    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));841    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);842    expect(container.textContent).not.toContain("thank you ✦");843  });844845  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {846    sendMessageMock.mockImplementation(async (msg: unknown) => {847      const type = (msg as { type?: string }).type;848      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };849      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };850      if (type === "GET_ENTRIES") return { ok: true, data: [] };851      return { ok: true, data: null };852    });853854    const onLicenseChangeFromStar = vi.fn();855    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);856    await flushView();857    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();858    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);859    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();860861    const onLicenseChangeFromPeach = vi.fn();862    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);863    await flushView();864    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();865    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);866    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();867  });868869  it("Settings feedback does not transmit when Firefox data consent is denied", async () => {870    sendMessageMock.mockResolvedValue({ ok: true, data: null });871    const fetchMock = vi.fn();872    const requestMock = vi.fn(async () => false);873    vi.stubGlobal("fetch", fetchMock);874    vi.stubGlobal("browser", {875      permissions: {876        getAll: vi.fn(async () => ({ data_collection: [] })),877        request: requestMock,878      },879    });880881    const container = mount(882      <SettingsView883        entriesCount={1}884        breachMonitoring={true}885        lockOnClose={true}886        autoLockMinutes={5}887        trashRetentionDays={30}888        theme="dark"889        accentColor="peach"890        textSize="medium"891        reduceMotion={false}892        initialSection="feedback"893        onBreachMonitoringChange={vi.fn()}894        onLockOnCloseChange={vi.fn()}895        onAutoLockMinutesChange={vi.fn()}896        onTrashRetentionChange={vi.fn()}897        onThemeChange={vi.fn()}898        onAccentChange={vi.fn()}899        onTextSizeChange={vi.fn()}900        onReduceMotionChange={vi.fn()}901        shortcuts={DEFAULT_BINDINGS}902        onShortcutsChange={vi.fn()}903        onBack={vi.fn()}904        onImport={vi.fn()}905        onExport={vi.fn()}906        onBreachCheckDone={vi.fn()}907      />908    );909    await flushView();910911    inputText(912      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,913      "Please improve the feedback flow."914    );915    const sendButton = Array.from(container.querySelectorAll("button")).find(916      (candidate) => candidate.textContent?.trim() === "Send"917    ) as HTMLButtonElement;918    sendButton.click();919920    await vi.waitFor(() => {921      expect(requestMock).toHaveBeenCalledWith({ data_collection: ["technicalAndInteraction"] });922    });923    expect(fetchMock).not.toHaveBeenCalled();924    expect(container.querySelector(".peach-feedback-textarea")).not.toBeNull();925  });926927  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {928    sendMessageMock.mockImplementation(async (msg: unknown) => {929      const m = msg as { type: string };930      if (m.type === "GET_RECOVERY_WORDS") {931        return {932          ok: true,933          data: [934            "abandon", "ability", "able", "about", "above", "absent",935            "absorb", "abstract", "absurd", "abuse", "access", "accident",936          ],937        };938      }939      return { ok: true, data: null };940    });941    const onAutoLockMinutesChange = vi.fn();942    const onThemeChange = vi.fn();943    const container = mount(944      <SettingsView945        entriesCount={1}946        breachMonitoring={true}947        lockOnClose={true}948        autoLockMinutes={5}949        trashRetentionDays={30}950        theme="dark"951        accentColor="peach"952        textSize="medium"953        reduceMotion={false}954        onBreachMonitoringChange={vi.fn()}955        onLockOnCloseChange={vi.fn()}956        onAutoLockMinutesChange={onAutoLockMinutesChange}957        onTrashRetentionChange={vi.fn()}958        onThemeChange={onThemeChange}959        onAccentChange={vi.fn()}960        onTextSizeChange={vi.fn()}961        onReduceMotionChange={vi.fn()}962        shortcuts={DEFAULT_BINDINGS}963        onShortcutsChange={vi.fn()}964        onBack={vi.fn()}965        onImport={vi.fn()}966        onExport={vi.fn()}967        onBreachCheckDone={vi.fn()}968      />969    );970    await flushView();971    sendMessageMock.mockClear();972973    // ── Auto-lock + theme changes (before password change) ─────────────974    const selects = container.querySelectorAll("select");975    changeSelect(selects[0] as HTMLSelectElement, "15");976    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);977978    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;979    appearanceCard.click();980    await flushView();981    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");982    const themeButton = Array.from(container.querySelectorAll("button")).find(983      (candidate) => candidate.textContent?.trim() === "Switch to light"984    ) as HTMLButtonElement;985    themeButton.click();986    expect(onThemeChange).toHaveBeenCalledWith("light");987988    // ── Password change → recovery words display ────────────────────────989    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;990    protectionCard.click();991    await flushView();992993    const changeButton = Array.from(container.querySelectorAll("button")).find(994      (candidate) => candidate.textContent?.trim() === "change"995    ) as HTMLButtonElement;996    changeButton.click();997    await flushView();998999    const inputs = container.querySelectorAll('input[type="password"]');1000    inputText(inputs[0] as HTMLInputElement, "current-pass");1001    inputText(inputs[1] as HTMLInputElement, "new-password-123");1002    inputText(inputs[2] as HTMLInputElement, "new-password-123");1003    await flushView();10041005    const saveButton = Array.from(container.querySelectorAll("button")).find(1006      (candidate) => candidate.textContent?.trim() === "save new password"1007    ) as HTMLButtonElement;1008    saveButton.click();1009    await flushView();10101011    expect(sendMessageMock).toHaveBeenCalledWith({1012      type: "CHANGE_PASSWORD",1013      payload: {1014        currentPassword: "current-pass",1015        newPassword: "new-password-123",1016      },1017    });10181019    // After a successful password change the recovery word display replaces settings.1020    expect(container.textContent).toContain("save your recovery words");1021    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1022  });10231024  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1025    sendMessageMock.mockResolvedValue({ ok: true });1026    const container = mount(1027      <EntryView1028        entry={sampleEntry()}1029        onDelete={vi.fn()}1030        onFieldSaved={vi.fn(async () => undefined)}1031      />1032    );10331034    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1035    passwordButton.click();1036    await flushView();10371038    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1039      type: "COPY",1040      payload: { text: "secret123", label: "password", alreadyWritten: false },1041    });1042    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1043      type: "FIRE_CANARY_PING",1044      payload: {1045        webhookUrl: "https://example.com/hook",1046        entryTitle: "GitHub",1047        trigger: "password_copied",1048      },1049    });10501051    sendMessageMock.mockClear();1052    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1053    usernameButton.click();1054    await flushView();10551056    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1057      type: "COPY",1058      payload: { text: "june", label: "username", alreadyWritten: false },1059    });1060    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1061      type: "FIRE_CANARY_PING",1062      payload: {1063        webhookUrl: "https://example.com/hook",1064        entryTitle: "GitHub",1065        trigger: "username_copied",1066      },1067    });1068  });10691070  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1071    const container = mount(1072      <EntryView1073        entry={sampleEntry({1074          type: "passkey",1075          username: undefined,1076          password: undefined,1077          totp: undefined,1078          canary: undefined,1079          passkey: {1080            rpId: "vercel.com",1081            rpName: "Vercel",1082            credentialId: "cred-1",1083            publicKeyCose: "cose",1084            userHandle: "handle",1085            userName: "june",1086            userDisplayName: "June",1087            algorithm: -7,1088            signCount: 0,1089            aaguid: "",1090            backupEligible: false,1091            backupState: false,1092            createdAt: 1,1093          },1094        })}1095        onDelete={vi.fn()}1096        onFieldSaved={vi.fn(async () => undefined)}1097      />1098    );10991100    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1101      (button) => button.textContent?.includes("Advanced")1102    ) as HTMLButtonElement | undefined;1103    expect(advanced).toBeDefined();1104    expect(advanced?.textContent).toContain("+ Advanced");11051106    advanced!.click();1107    await flushView();11081109    expect(container.textContent).toContain("passkey · vercel.com");1110  });11111112  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1113    sendMessageMock.mockResolvedValue({ ok: true });1114    const onFieldSaved = vi.fn(async () => undefined);1115    const container = mount(1116      <EntryView1117        entry={sampleEntry()}1118        onDelete={vi.fn()}1119        onFieldSaved={onFieldSaved}1120      />1121    );11221123    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1124    passwordValue.click();1125    await flushView();11261127    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1128    inputText(passwordInput, "");1129    await flushView();11301131    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1132    saveButton.click();1133    await flushView();11341135    expect(sendMessageMock).not.toHaveBeenCalledWith(1136      expect.objectContaining({ type: "UPDATE_ENTRY" })1137    );1138    expect(onFieldSaved).not.toHaveBeenCalled();11391140    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1141    totpMain.click();11421143    await vi.waitFor(() => {1144      expect(sendMessageMock).toHaveBeenLastCalledWith({1145        type: "COPY",1146        payload: { text: "123456", label: "totp", alreadyWritten: false },1147      });1148    }, { timeout: 500 });1149  });11501151  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1152    sendMessageMock.mockResolvedValue({ ok: true });1153    const container = mount(1154      <EntryView1155        entry={sampleEntry()}1156        onDelete={vi.fn()}1157        onFieldSaved={vi.fn(async () => undefined)}1158      />1159    );11601161    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1162    usernameValue.click();1163    await flushView();11641165    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1166    usernameInput.blur();1167    await flushView();11681169    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1170    expect(sendMessageMock).not.toHaveBeenCalledWith(1171      expect.objectContaining({ type: "UPDATE_ENTRY" })1172    );1173  });11741175  it("Entry keeps the full used-email list available while editing an email", async () => {1176    const container = mount(1177      <EntryView1178        entry={sampleEntry({ email: "[email protected]" })}1179        entries={[1180          sampleEntry({ email: "[email protected]" }),1181          sampleEntry({ id: "entry-2", email: "[email protected]" }),1182          sampleEntry({ id: "entry-3", username: "duckuccino" }),1183        ]}1184        onDelete={vi.fn()}1185        onFieldSaved={vi.fn(async () => undefined)}1186      />1187    );11881189    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1190      (card) => card.querySelector(".peach-label")?.textContent === "email"1191    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1192    emailValue.click();1193    await flushView();11941195    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1196      .map((element) => element.textContent);1197    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1198  });11991200  it("Entry lets TOTP secrets be edited inline", async () => {1201    sendMessageMock.mockResolvedValue({ ok: true });1202    const onFieldSaved = vi.fn(async () => undefined);1203    const container = mount(1204      <EntryView1205        entry={sampleEntry()}1206        onDelete={vi.fn()}1207        onFieldSaved={onFieldSaved}1208      />1209    );12101211    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1212    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1213    await flushView();12141215    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1216    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1217    await flushView();12181219    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1220    saveButton.click();1221    await flushView();12221223    expect(sendMessageMock).toHaveBeenCalledWith({1224      type: "UPDATE_ENTRY",1225      payload: {1226        id: "entry-1",1227        patch: { totp: "NB2W45DFOIZA====" },1228      },1229    });1230    expect(onFieldSaved).toHaveBeenCalled();1231  });12321233  it("Vault seeds and focuses search when a search seed is provided", async () => {1234    const container = mount(1235      <VaultView1236        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1237        sortOrder="url-first"1238        searchSeed="g"1239        searchSeedNonce={1}1240        onSelectEntry={vi.fn()}1241        onSortOrderChange={vi.fn()}1242        onAdd={vi.fn()}1243        onClose={vi.fn()}1244      />1245    );12461247    await flushView();12481249    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1250    expect(search.value).toBe("g");1251    expect(document.activeElement).toBe(search);1252  });12531254  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1255    parseImportSourceMock.mockResolvedValue(1256      sampleImportResult({1257        entries: [1258          {1259            type: "login",1260            title: "Imported site",1261            username: "imported-user",1262            password: "imported-pass",1263            url: "https://imported.test",1264          },1265        ],1266      })1267    );1268    sendMessageMock.mockImplementation(async (msg: unknown) => {1269      const type = (msg as { type?: string }).type;1270      if (type === "GET_ENTRIES") {1271        return {1272          ok: true,1273          data: [1274            sampleEntry({1275              id: "existing-1",1276              title: "Imported site",1277              username: "imported-user",1278              url: "https://imported.test",1279            }),1280          ],1281        };1282      }1283      if (type === "BATCH_ADD_ENTRIES") {1284        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1285      }1286      return { ok: true };1287    });1288    const onImport = vi.fn(async () => undefined);1289    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1290    await flushView();1291    sendMessageMock.mockClear();12921293    await selectImportFile(container);1294    findButton(container, "Import 1").click();1295    await flushView();12961297    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1298    expect(sendMessageMock).toHaveBeenCalledWith({1299      type: "BATCH_ADD_ENTRIES",1300      payload: {1301        entries: [1302          expect.objectContaining({1303            title: "Imported site",1304            username: "imported-user",1305            password: "imported-pass",1306            url: "https://imported.test",1307          }),1308        ],1309      },1310    });1311    expect(onImport).toHaveBeenCalled();1312    expect(container.textContent).toContain("1 entry appears to be duplicates");1313  });13141315  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1316    parseImportSourceMock.mockResolvedValue(sampleImportResult());1317    sendMessageMock.mockImplementation(async (msg: unknown) => {1318      const type = (msg as { type?: string }).type;1319      if (type === "GET_ENTRIES") {1320        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1321      }1322      if (type === "CLEAR_VAULT_ENTRIES") {1323        return { ok: true };1324      }1325      if (type === "BATCH_ADD_ENTRIES") {1326        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1327      }1328      return { ok: true };1329    });1330    const onImport = vi.fn(async () => undefined);1331    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1332    await flushView();1333    sendMessageMock.mockClear();13341335    await selectImportFile(container);1336    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1337    await flushView();13381339    findButton(container, "Replace vault with 1").click();1340    await flushView();13411342    expect(sendMessageMock).not.toHaveBeenCalledWith(1343      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1344    );1345    expect(container.textContent).toContain("Replace forever? This cannot be undone.");13461347    findButton(container, "Yes, replace forever").click();1348    await flushView();13491350    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1351    expect(sendMessageMock).toHaveBeenCalledWith({1352      type: "BATCH_ADD_ENTRIES",1353      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1354    });1355    expect(onImport).toHaveBeenCalled();1356  });13571358  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1359    parseImportSourceMock.mockResolvedValue(sampleImportResult());1360    sendMessageMock.mockImplementation(async (msg: unknown) => {1361      const type = (msg as { type?: string }).type;1362      if (type === "CREATE_VAULT") return { ok: true };1363      if (type === "BATCH_ADD_ENTRIES") {1364        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1365      }1366      return { ok: true };1367    });1368    const onImport = vi.fn(async () => undefined);1369    const container = mount(1370      <ImportView1371        mode="onboarding"1372        replacesExistingVault={true}1373        onBack={vi.fn()}1374        onImport={onImport}1375      />1376    );13771378    await selectImportFile(container);13791380    const createButton = findButton(container, "Create vault + import 1");1381    expect(createButton.disabled).toBe(true);1382    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1383    expect(container.textContent).toContain("Use at least 8 characters");13841385    const masterInput = container.querySelector(1386      'input[placeholder="new master password"]'1387    ) as HTMLInputElement;1388    const confirmInput = container.querySelector(1389      'input[placeholder="confirm master password"]'1390    ) as HTMLInputElement;1391    const revealButton = container.querySelector(1392      'button[aria-label="Show passwords"]'1393    ) as HTMLButtonElement;1394    expect(revealButton).toBeTruthy();1395    revealButton.click();1396    await flushView();1397    expect(masterInput.type).toBe("text");1398    expect(confirmInput.type).toBe("text");13991400    inputText(masterInput, "fresh-master-password");1401    inputText(confirmInput, "fresh-master-password");1402    await flushView();14031404    expect(createButton.disabled).toBe(false);1405    createButton.click();1406    await flushView();14071408    expect(sendMessageMock).toHaveBeenCalledWith({1409      type: "CREATE_VAULT",1410      payload: { password: "fresh-master-password" },1411    });1412    expect(sendMessageMock).toHaveBeenCalledWith({1413      type: "BATCH_ADD_ENTRIES",1414      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1415    });1416    expect(onImport).toHaveBeenCalled();1417  });14181419  it("Import encrypted backups reparse with the backup password before import", async () => {1420    parseImportSourceMock1421      .mockResolvedValueOnce(1422        sampleImportResult({1423          format: "peach-json",1424          label: "Peach encrypted backup",1425          entries: [],1426          totalItems: 0,1427          requiresPassword: true,1428        })1429      )1430      .mockResolvedValueOnce(1431        sampleImportResult({1432          format: "peach-json",1433          label: "Peach encrypted backup",1434        })1435      );1436    sendMessageMock.mockImplementation(async (msg: unknown) => {1437      const type = (msg as { type?: string }).type;1438      if (type === "GET_ENTRIES") return { ok: true, data: [] };1439      if (type === "BATCH_ADD_ENTRIES") {1440        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1441      }1442      return { ok: true };1443    });1444    const onImport = vi.fn(async () => undefined);1445    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1446    await flushView();1447    sendMessageMock.mockClear();14481449    await selectImportFile(container, '{"type":"peach-export"}');1450    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1451    inputText(password, "backup-passphrase");1452    pressEnter(password);1453    await flushView();1454    await flushView();14551456    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1457    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");14581459    findButton(container, "Import 1").click();1460    await flushView();14611462    expect(sendMessageMock).toHaveBeenCalledWith({1463      type: "BATCH_ADD_ENTRIES",1464      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1465    });1466    expect(onImport).toHaveBeenCalled();1467  });1468});