contracts.test.tsx

56.5 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 { RecoveryWordDisplayView } from "./RecoveryWordDisplay";19import { VaultView } from "./Vault";20import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2122const sendMessageMock = vi.fn<(msg: unknown) => Promise<unknown>>();23const triggerRingMock = vi.fn();24const triggerPipPulseMock = vi.fn();25const parseImportSourceMock = vi.fn<(raw: string, password?: string) => Promise<ImportParseResult>>();26const zxcvbnMock = vi.fn((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));2728vi.mock("../App", () => ({29  sendMessage: (msg: unknown) => sendMessageMock(msg),30  triggerRing: () => triggerRingMock(),31  triggerPipPulse: () => triggerPipPulseMock(),32}));3334vi.mock("../../shared/bridge", () => ({35  sendMessage: (msg: unknown) => sendMessageMock(msg),36}));3738vi.mock("../../shared/import", () => ({39  parseImportSource: (...args: [string, string | undefined]) => parseImportSourceMock(...args),40}));4142vi.mock("../../shared/favicon", async (importOriginal) => {43  const actual = await importOriginal<typeof import("../../shared/favicon")>();44  return { ...actual, faviconUrl: () => null };45});4647vi.mock("../../crypto/totp", () => ({48  generateTOTP: () => ({ code: "123 456", remaining: 24 }),49  parseOtpauthURI: (value: string) =>50    value.startsWith("otpauth://")51      ? { secret: "NB2W45DFOIZA====", algorithm: "SHA1" as const }52      : null,53}));5455vi.mock("../components/GeneratorDrawer", () => ({56  GeneratorDrawer: () => null,57}));5859vi.mock("../components/RuneBackground", () => ({60  RuneBackground: () => null,61}));6263vi.mock("@zxcvbn-ts/core", () => ({64  zxcvbn: (value: string) => zxcvbnMock(value),65  zxcvbnOptions: { setOptions: vi.fn() },66}));67vi.mock("@zxcvbn-ts/language-common", () => ({68  adjacencyGraphs: {},69  dictionary: {},70}));71vi.mock("@zxcvbn-ts/language-en", () => ({72  translations: {},73  dictionary: {},74}));7576describe("popup contract tests", () => {77  const disposers: Array<() => void> = [];7879  beforeEach(() => {80    document.body.innerHTML = "";81    sendMessageMock.mockReset();82    triggerRingMock.mockReset();83    triggerPipPulseMock.mockReset();84    parseImportSourceMock.mockReset();85    zxcvbnMock.mockReset();86    zxcvbnMock.mockImplementation((value: string) => ({ score: value.length >= 16 ? 3 : 1 }));87    parseImportSourceMock.mockResolvedValue({88      format: "unknown",89      label: "Unknown format",90      entries: [],91      totalItems: 0,92      skippedItems: 0,93      requiresPassword: false,94    });9596    vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {97      return window.setTimeout(() => cb(0), 0) as unknown as number;98    });99    vi.stubGlobal("cancelAnimationFrame", (id: number) => window.clearTimeout(id));100    vi.stubGlobal("URL", {101      createObjectURL: vi.fn(() => "blob:peach-test"),102      revokeObjectURL: vi.fn(),103    });104    vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});105106    (globalThis as { chrome?: typeof chrome }).chrome = {107      runtime: {108        getManifest: () => ({ version: "1.4.47" }),109        onMessage: { addListener: vi.fn() },110      },111    } as unknown as typeof chrome;112  });113114  afterEach(() => {115    while (disposers.length > 0) {116      disposers.pop()?.();117    }118    vi.restoreAllMocks();119    vi.unstubAllGlobals();120  });121122  function mount(view: JSX.Element | (() => JSX.Element)) {123    const mounted = renderView(view);124    disposers.push(mounted.dispose);125    return mounted.container;126  }127128  function sampleEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {129    return {130      id: "entry-1",131      type: "login",132      title: "GitHub",133      username: "june",134      password: "secret123",135      url: "https://github.com",136      totp: "JBSWY3DPEHPK3PXP",137      canary: {138        webhookUrl: "https://example.com/hook",139      },140      createdAt: 1,141      modifiedAt: 1,142      ...overrides,143    };144  }145146  function sampleImportResult(147    overrides: Partial<ImportParseResult> = {}148  ): ImportParseResult {149    return {150      format: "csv",151      label: "CSV import",152      entries: [153        {154          type: "login",155          title: "Imported site",156          username: "imported-user",157          password: "imported-pass",158          url: "https://imported.test",159        },160      ],161      totalItems: 1,162      skippedItems: 0,163      requiresPassword: false,164      ...overrides,165    };166  }167168  function settingsProps(overrides: Partial<Parameters<typeof SettingsView>[0]> = {}) {169    return {170      entriesCount: 1,171      breachMonitoring: true,172      lockOnClose: true,173      autoLockMinutes: 5,174      trashRetentionDays: 30,175      theme: "dark" as const,176      accentColor: "peach" as const,177      textSize: "medium" as const,178      reduceMotion: false,179      onBreachMonitoringChange: vi.fn(),180      onLockOnCloseChange: vi.fn(),181      onAutoLockMinutesChange: vi.fn(),182      onTrashRetentionChange: vi.fn(),183      onThemeChange: vi.fn(),184      onAccentChange: vi.fn(),185      onTextSizeChange: vi.fn(),186      onReduceMotionChange: vi.fn(),187      shortcuts: DEFAULT_BINDINGS,188      onShortcutsChange: vi.fn(),189      onBack: vi.fn(),190      onImport: vi.fn(),191      onExport: vi.fn(),192      onBreachCheckDone: vi.fn(),193      ...overrides,194    };195  }196197  async function selectImportFile(198    container: HTMLElement,199    contents = '{"export":"test"}'200  ) {201    // The Import view now opens onto a menu — click into the file subview202    // before locating the file input. Idempotent: a second click won't re-fire203    // because the menu unmounts once the subview renders.204    const fileCard = findButton(container, "Choose file →");205    if (fileCard) {206      fileCard.click();207      await flushView();208    }209    const input = container.querySelector('input[type="file"]') as HTMLInputElement;210    const file = new File([contents], "import.json", { type: "application/json" });211    Object.defineProperty(input, "files", {212      configurable: true,213      value: [file],214    });215    input.dispatchEvent(new Event("change", { bubbles: true }));216    await flushView();217    await flushView();218  }219220  function findButton(container: HTMLElement, text: string): HTMLButtonElement {221    return Array.from(container.querySelectorAll("button")).find(222      (candidate) => candidate.textContent?.trim() === text223    ) as HTMLButtonElement;224  }225226  it("Lock sends UNLOCK with the entered passphrase", async () => {227    sendMessageMock.mockResolvedValue({ ok: true });228    const onUnlock = vi.fn(async () => undefined);229    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);230231    const password = container.querySelector("#unlock-password") as HTMLInputElement;232    inputText(password, "correct horse battery staple");233    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();234    await flushView();235236    expect(sendMessageMock).toHaveBeenCalledWith({237      type: "UNLOCK",238      payload: { password: "correct horse battery staple" },239    });240    expect(onUnlock).toHaveBeenCalled();241  });242243  it("Lock uses a configured preferred PIN and keeps the password fallback available", async () => {244    sendMessageMock.mockImplementation(async (msg: unknown) => {245      return (msg as { type: string }).type === "GET_SETTINGS"246        ? { ok: true, data: { pinEnabled: true, pinPreferred: true } }247        : { ok: true };248    });249    const onUnlock = vi.fn(async () => undefined);250    const container = mount(<LockView hasVault={true} ready={true} onUnlock={onUnlock} />);251    await flushView();252253    inputText(container.querySelector("#unlock-pin") as HTMLInputElement, "1234");254    (container.querySelector(".peach-lock-submit") as HTMLButtonElement).click();255    await flushView();256257    expect(sendMessageMock).toHaveBeenCalledWith({258      type: "UNLOCK_WITH_PIN",259      payload: { pin: "1234" },260    });261    expect(onUnlock).toHaveBeenCalled();262    expect(container.textContent).toContain("use master password");263  });264265  it("places the PIN method switch below the unlock button", async () => {266    sendMessageMock.mockImplementation(async (msg: unknown) => {267      return (msg as { type: string }).type === "GET_SETTINGS"268        ? { ok: true, data: { pinEnabled: true, pinPreferred: false } }269        : { ok: true };270    });271    const container = mount(<LockView hasVault={true} ready={true} onUnlock={vi.fn()} />);272    await flushView();273274    const buttons = Array.from(container.querySelectorAll("button"));275    const unlockIndex = buttons.findIndex((button) => button.textContent?.trim() === "unlock");276    const pinIndex = buttons.findIndex((button) => button.textContent?.trim() === "use PIN");277    expect(pinIndex).toBeGreaterThan(unlockIndex);278  });279280  it("keeps standalone note creation in Notes mode", async () => {281    const chromeApi = globalThis.chrome as unknown as {282      tabs?: { query: (queryInfo: object, callback: (tabs: unknown[]) => void) => void };283    };284    chromeApi.tabs = { query: vi.fn((_queryInfo, callback) => callback([])) };285286    const addContainer = mount(<AddView onBack={vi.fn()} onSave={vi.fn()} />);287    await flushView();288289    expect(Array.from(addContainer.querySelectorAll(".peach-new-type-btn")).map((button) => button.textContent?.trim()))290      .toEqual(["login", "card"]);291    expect(addContainer.querySelector(".peach-note-editor-body--inline")).toBeNull();292  });293294  it("gives Notes mode the full note-formatting toolbar", async () => {295    const container = mount(296      <NotesEditor297        note={null}298        onBack={vi.fn()}299        onSaved={vi.fn()}300        onDelete={vi.fn()}301      />302    );303    await flushView();304305    expect(container.querySelector('[title="Strikethrough"]')).toBeTruthy();306    expect(container.querySelector('[title="Divider"]')).toBeTruthy();307    expect(container.querySelector('[title="Quote"]')).toBeTruthy();308    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button")).toHaveLength(9);309    expect(container.querySelectorAll(".peach-note-toolbar-desktop-inline button svg")).toHaveLength(9);310    expect(container.querySelector(".peach-notes-header-title")?.textContent).toBe("notes.");311  });312313  it("saves formatting applied by the Notes toolbar before leaving", async () => {314    const savedNote = {315      id: "note-1",316      type: "note" as const,317      title: "Untitled",318      notes: "<b>formatted</b>",319      createdAt: 1,320      modifiedAt: 2,321    };322    sendMessageMock.mockResolvedValue({ ok: true, data: savedNote });323    const originalExecCommand = document.execCommand;324    const execCommandMock = vi.fn(() => {325      const body = document.querySelector<HTMLDivElement>(".peach-note-body-field");326      if (body) body.innerHTML = "<b>formatted</b>";327      return true;328    });329    Object.defineProperty(document, "execCommand", { configurable: true, value: execCommandMock });330331    try {332      const container = mount(333        <NotesEditor334          note={null}335          onBack={vi.fn()}336          onSaved={vi.fn()}337          onDelete={vi.fn()}338        />339      );340      await flushView();341      container.querySelector<HTMLButtonElement>('[title="Bold"]')?.dispatchEvent(342        new MouseEvent("mousedown", { bubbles: true, cancelable: true })343      );344      expect(execCommandMock).toHaveBeenCalledWith("bold", false, undefined);345      container.querySelector<HTMLButtonElement>('[aria-label="Back to notes list"]')?.click();346      await flushView();347348      expect(sendMessageMock).toHaveBeenCalledWith({349        type: "SAVE_NOTE",350        payload: { id: undefined, title: "Untitled", body: "<b>formatted</b>" },351      });352    } finally {353      Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });354    }355  });356357  it("Lock keeps create-vault disabled for short passwords and weak passphrases", async () => {358    const container = mount(<LockView hasVault={false} ready={true} onUnlock={vi.fn()} />);359    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;360    const password = container.querySelector("#master-password") as HTMLInputElement;361    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;362363    inputText(password, "short");364    inputText(confirm, "short");365    await flushView();366    expect(createButton.disabled).toBe(true);367368    zxcvbnMock.mockImplementation(() => ({ score: 1 }));369    inputText(password, "weakweakweak");370    inputText(confirm, "weakweakweak");371    await flushView();372    expect(createButton.disabled).toBe(true);373  });374375  it("Lock create-vault header pins the wordmark left with back on the right", async () => {376    const container = mount(377      <LockView378        hasVault={false}379        ready={true}380        onUnlock={vi.fn()}381        onBackToOnboarding={() => {}}382      />383    );384    const header = container.querySelector(".peach-newvault-header") as HTMLElement;385    const wordmark = header.querySelector(".peach-wordmark-stack");386    const back = header.querySelector(".peach-newvault-back");387    expect(wordmark).toBeTruthy();388    expect(back).toBeTruthy();389    // Wordmark first so it never shifts between onboarding screens; back390    // takes the opposite corner instead of displacing it.391    expect(header.firstElementChild).toBe(wordmark);392    expect(header.lastElementChild).toBe(back);393    await flushView();394  });395396  it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {397    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock398    sendMessageMock.mockImplementation(399      async (msg: unknown) => {400        const m = msg as { type: string; payload?: { password?: string } };401        if (m.type === "CREATE_VAULT") return { ok: true };402        if (m.type === "GET_RECOVERY_WORDS") {403          // Return 12 fake words — must pass findInvalidWords404          return {405            ok: true,406            data: [407              "abandon", "ability", "able", "about", "above", "absent",408              "absorb", "abstract", "absurd", "abuse", "access", "accident",409            ],410          };411        }412        return { ok: true };413      }414    );415    zxcvbnMock.mockImplementation(() => ({ score: 3 }));416    const onUnlock = vi.fn(async () => undefined);417    const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);418419    const password = container.querySelector("#master-password") as HTMLInputElement;420    const confirm = container.querySelector("#confirm-password") as HTMLInputElement;421    const createButton = container.querySelector(".peach-lock-submit") as HTMLButtonElement;422423    inputText(password, "correct horse battery staple");424    inputText(confirm, "correct horse battery staple");425    await flushView();426    expect(createButton.disabled).toBe(false);427428    pressEnter(confirm);429    await flushView();430431    expect(sendMessageMock).toHaveBeenCalledWith({432      type: "CREATE_VAULT",433      payload: { password: "correct horse battery staple" },434    });435    // After CREATE_VAULT success, the recovery word display is shown.436    // onUnlock is NOT called yet — user must first confirm their recovery words.437    expect(onUnlock).not.toHaveBeenCalled();438  });439440  it("recovery display asks for three distinct randomized word positions and confirms", async () => {441    const words12 = [442      "abandon", "ability", "able", "about", "above", "absent",443      "absorb", "abstract", "absurd", "abuse", "access", "accident",444    ];445    sendMessageMock.mockImplementation(async (msg: unknown) => {446      const type = (msg as { type?: string }).type;447      if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };448      return { ok: true };449    });450451    vi.useFakeTimers();452    const onDone = vi.fn();453    try {454      const container = mount(<RecoveryWordDisplayView onDone={onDone} />);455      // onMount fires GET_RECOVERY_WORDS; let the resolved promise + render settle456      await vi.advanceTimersByTimeAsync(0);457458      const prompts = Array.from(459        container.querySelectorAll(".peach-recovery-confirm-num")460      ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));461      expect(prompts).toHaveLength(3);462      expect(new Set(prompts).size).toBe(3);463      for (const p of prompts) {464        expect(p).toBeGreaterThanOrEqual(1);465        expect(p).toBeLessThanOrEqual(12);466      }467468      // Inputs stay disabled until the 5s countdown expires469      const inputs = Array.from(470        container.querySelectorAll(".peach-recovery-confirm-input")471      ) as HTMLInputElement[];472      expect(inputs).toHaveLength(3);473      expect(inputs[0]!.disabled).toBe(true);474      await vi.advanceTimersByTimeAsync(5000);475      expect(inputs[0]!.disabled).toBe(false);476477      prompts.forEach((pos, i) => {478        inputText(inputs[i]!, words12[pos - 1]!);479      });480481      const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;482      expect(confirmBtn.disabled).toBe(false);483      confirmBtn.click();484      await vi.advanceTimersByTimeAsync(700); // success pause before onDone485486      expect(onDone).toHaveBeenCalledTimes(1);487    } finally {488      vi.useRealTimers();489    }490  });491492  it("Recovery words flow submits the new master password and completes", async () => {493    sendMessageMock.mockImplementation(async (msg: unknown) => {494      const type = (msg as { type?: string }).type;495      if (type === "VALIDATE_RECOVERY_WORDS") {496        return { ok: true, data: { valid: true, invalid: [] } };497      }498      if (type === "RECOVER_WITH_WORDS") return { ok: true };499      return { ok: true };500    });501    const canvasContext = {502      clearRect: vi.fn(),503      fillText: vi.fn(),504      beginPath: vi.fn(),505      closePath: vi.fn(),506      arc: vi.fn(),507      fill: vi.fn(),508      stroke: vi.fn(),509      save: vi.fn(),510      restore: vi.fn(),511      translate: vi.fn(),512      rotate: vi.fn(),513      scale: vi.fn(),514      drawImage: vi.fn(),515      measureText: vi.fn(() => ({ width: 0 })),516      setTransform: vi.fn(),517      fillRect: vi.fn(),518      strokeRect: vi.fn(),519    } as unknown as CanvasRenderingContext2D;520    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);521522    const onRecoveryComplete = vi.fn(async () => undefined);523    const container = mount(524      <RecoveryWordEntryView525        onCancel={vi.fn()}526        onRecoveryComplete={onRecoveryComplete}527      />528    );529530    const recoveryWords = WORD_LIST.slice(0, 12);531    for (let i = 0; i < recoveryWords.length; i++) {532      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;533      inputText(input, recoveryWords[i]!);534    }535    await flushView();536537    findButton(container, "verify words").click();538    await flushView();539540    expect(container.textContent).toContain("new master password");541542    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;543    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;544    inputText(newPassword, "fresh-recovery-password");545    inputText(confirmPassword, "fresh-recovery-password");546    await flushView();547548    findButton(container, "recover vault").click();549    await flushView();550551    expect(sendMessageMock).toHaveBeenCalledWith({552      type: "RECOVER_WITH_WORDS",553      payload: {554        words: recoveryWords,555        newPassword: "fresh-recovery-password",556      },557    });558    expect(onRecoveryComplete).toHaveBeenCalled();559  });560561  it("Export menu surfaces both backup formats", async () => {562    const container = mount(<ExportView onBack={vi.fn()} />);563    await flushView();564565    expect(container.textContent).toContain("PeachScript");566    expect(container.textContent).toContain("CSV");567  });568569  it("Lock can open directly on backup restore from onboarding", async () => {570    const container = mount(() => (571      <LockView572        hasVault={false}573        ready={true}574        onUnlock={vi.fn()}575        initialRestore={true}576      />577    ));578    await flushView();579580    expect(container.textContent).toContain("PeachScript");581    expect(container.textContent).toContain("Codex");582    expect(container.textContent).toContain("CSV");583    expect(container.querySelector("#master-password")).toBeNull();584  });585586  it("HOLE 4: removing a device discloses that recovery words survive revocation", async () => {587    const device = {588      deviceId: "phone-1",589      deviceName: "June's Phone",590      deviceType: "android" as const,591      longTermPublicKeyHex: "a".repeat(64),592      signingPublicKeyHex: "b".repeat(64),593      pairedAt: 1,594      trustSource: "direct" as const,595    };596    sendMessageMock.mockImplementation(async (msg: unknown) => {597      const m = msg as { type: string };598      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };599      return { ok: true, data: null };600    });601602    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);603    for (let i = 0; i < 30; i++) await Promise.resolve();604605    const openSync = Array.from(container.querySelectorAll("button")).find((b) =>606      /sync/i.test(b.textContent ?? ""),607    );608    openSync?.click();609    for (let i = 0; i < 30; i++) await Promise.resolve();610611    const remove = Array.from(container.querySelectorAll("button")).find(612      (b) => (b.textContent ?? "").trim() === "remove",613    );614    expect(remove).toBeTruthy();615616    // Guard against a vacuous pass: the disclosure must not already be on617    // screen before the user asks to remove anything.618    expect(container.querySelector(".peach-unpair-confirm-note")).toBeNull();619620    remove!.click();621    for (let i = 0; i < 10; i++) await Promise.resolve();622623    // Revocation must never be presented as securing the vault against the624    // removed device — recovery words survive it and restore everything.625    const note = container.querySelector(".peach-unpair-confirm-note");626    expect(note).toBeTruthy();627    const text = note!.textContent ?? "";628    expect(text).toContain("master password");629    expect(text).toContain("recovery words");630    expect(text).toContain("future changes");631  });632633  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {634    const device = {635      deviceId: "phone-1",636      deviceName: "June's Phone",637      deviceType: "android" as const,638      longTermPublicKeyHex: "a".repeat(64),639      signingPublicKeyHex: "b".repeat(64),640      pairedAt: 1,641      trustSource: "direct" as const,642    };643    let paired: typeof device[] = [device];644    sendMessageMock.mockImplementation(async (msg: any) => {645      if (msg?.type === "LIST_PAIRED_DEVICES") {646        return { ok: true, data: { devices: [...paired] } };647      }648      if (msg?.type === "UNPAIR_DEVICE") {649        // Mirror the real handler: UNPAIR_DEVICE drops the row from the650        // registry. The next LIST_PAIRED_DEVICES must not see it.651        paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);652        return { ok: true };653      }654      if (msg?.type === "RENAME_PAIRED_DEVICE") {655        paired = paired.map((d) =>656          d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,657        );658        return { ok: true };659      }660      return { ok: true, data: null };661    });662663    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);664    for (let i = 0; i < 30; i++) await Promise.resolve();665    Array.from(container.querySelectorAll("button"))666      .find((b) => /sync/i.test(b.textContent ?? ""))667      ?.click();668    for (let i = 0; i < 30; i++) await Promise.resolve();669670    // The list itself: extracting the relay selector must not take the hub with it.671    expect(container.textContent).toContain("PAIRED DEVICES");672    expect(container.textContent).toContain("June's Phone");673    expect(container.textContent).toContain("Android");674    Array.from(container.querySelectorAll("button"))675      .find((b) => (b.textContent ?? "").trim() === "rename")!676      .click();677    const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;678    renameInput.value = "Travel phone";679    renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));680    Array.from(container.querySelectorAll("button"))681      .find((b) => (b.textContent ?? "").trim() === "save")!682      .click();683    for (let i = 0; i < 30; i++) await Promise.resolve();684    expect(sendMessageMock).toHaveBeenCalledWith({685      type: "RENAME_PAIRED_DEVICE",686      payload: { deviceId: "phone-1", name: "Travel phone" },687    });688    expect(container.textContent).toContain("Travel phone");689    const syncNowButton = container.querySelector<HTMLButtonElement>(690      'button[aria-label="Sync this vault now"]',691    )!;692    const labels = Array.from(container.querySelectorAll(".peach-settings-sub-label"));693    const addDeviceLabel = labels.find((label) => label.textContent?.includes("ADD A DEVICE"))!;694    const pairedDevicesLabel = labels.find((label) => label.textContent?.includes("PAIRED DEVICES"))!;695    expect(696      syncNowButton.compareDocumentPosition(addDeviceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,697    ).toBeTruthy();698    expect(699      syncNowButton.compareDocumentPosition(pairedDevicesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,700    ).toBeTruthy();701702    Array.from(container.querySelectorAll("button"))703      .find((b) => (b.textContent ?? "").trim() === "remove")!704      .click();705    for (let i = 0; i < 10; i++) await Promise.resolve();706    const confirm = Array.from(707      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),708    ).find((b) => (b.textContent ?? "").trim() === "remove");709    confirm!.click();710    for (let i = 0; i < 30; i++) await Promise.resolve();711712    expect(sendMessageMock).toHaveBeenCalledWith({713      type: "UNPAIR_DEVICE",714      payload: { deviceId: "phone-1" },715    });716    // The list must be refreshed and the device must be gone. The regression717    // we're guarding against: a Settings view that calls UNPAIR_DEVICE but718    // never refreshes LIST_PAIRED_DEVICES, so the row stays in the UI.719    expect(container.textContent).not.toContain("June's Phone");720    expect(container.textContent).not.toContain("PAIRED DEVICES");721  });722723  it("Settings keeps connection and recovery details collapsed until requested", async () => {724    sendMessageMock.mockImplementation(async (msg: any) => {725      if (msg?.type === "LIST_PAIRED_DEVICES") {726        return { ok: true, data: { devices: [] } };727      }728      return { ok: true, data: null };729    });730731    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);732    for (let i = 0; i < 30; i++) await Promise.resolve();733    Array.from(container.querySelectorAll("button"))734      .find((button) => /sync/i.test(button.textContent ?? ""))735      ?.click();736    for (let i = 0; i < 20; i++) await Promise.resolve();737738    expect(container.textContent).toContain("SYNC STATUS");739    expect(container.textContent).toContain("ADD A DEVICE");740    expect(container.textContent).not.toContain("Public relay");741    expect(container.textContent).not.toContain("Rotate sync identity");742743    const connection = Array.from(container.querySelectorAll("button"))744      .find((button) => button.textContent?.includes("CONNECTION"));745    connection?.click();746    await Promise.resolve();747    expect(container.textContent).toContain("Public relay");748749    const recovery = Array.from(container.querySelectorAll("button"))750      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));751    recovery?.click();752    await Promise.resolve();753    expect(container.textContent).toContain("Rotate sync identity");754  });755756  it("Settings requires current, new, and confirm passwords before enabling save", async () => {757    sendMessageMock.mockResolvedValue({ ok: true, data: null });758    const container = mount(759      <SettingsView760        entriesCount={1}761        breachMonitoring={true}762        lockOnClose={true}763        autoLockMinutes={5}764        trashRetentionDays={30}765        theme="dark"766        accentColor="peach"767        textSize="medium"768        reduceMotion={false}769        onBreachMonitoringChange={vi.fn()}770        onLockOnCloseChange={vi.fn()}771        onAutoLockMinutesChange={vi.fn()}772        onTrashRetentionChange={vi.fn()}773        onThemeChange={vi.fn()}774        onAccentChange={vi.fn()}775        onTextSizeChange={vi.fn()}776        onReduceMotionChange={vi.fn()}777        shortcuts={DEFAULT_BINDINGS}778        onShortcutsChange={vi.fn()}779        onBack={vi.fn()}780        onImport={vi.fn()}781        onExport={vi.fn()}782        onBreachCheckDone={vi.fn()}783      />784    );785    await flushView();786787    expect(container.querySelectorAll(".stk-card").length).toBe(7);788    // Settings now opens to card I (Peach Pro) — see koh save [f7a2710b3f67]789    // "open settings to card I (Peach Pro) instead of card II (Protection)".790    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Peach Pro");791792    const changeButton = Array.from(container.querySelectorAll("button")).find(793      (candidate) => candidate.textContent?.trim() === "change"794    ) as HTMLButtonElement;795    changeButton.click();796    await flushView();797798    const saveButton = Array.from(container.querySelectorAll("button")).find(799      (candidate) => candidate.textContent?.trim() === "save new password"800    ) as HTMLButtonElement;801    expect(saveButton.disabled).toBe(true);802803    const inputs = container.querySelectorAll('input[type="password"]');804    inputText(inputs[0] as HTMLInputElement, "current-pass");805    inputText(inputs[1] as HTMLInputElement, "new-pass-12345");806    await flushView();807    expect(saveButton.disabled).toBe(true);808809    inputText(inputs[2] as HTMLInputElement, "new-pass-12345");810    await flushView();811    expect(saveButton.disabled).toBe(false);812  });813814  it("Settings feedback highlights the selected category and hides the form after submit", async () => {815    sendMessageMock.mockResolvedValue({ ok: true, data: null });816    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({817      ok: true,818      json: async () => ({ success: true }),819    }));820    vi.stubGlobal("fetch", fetchMock);821822    const container = mount(823      <SettingsView824        entriesCount={1}825        breachMonitoring={true}826        lockOnClose={true}827        autoLockMinutes={5}828        trashRetentionDays={30}829        theme="dark"830        accentColor="peach"831        textSize="medium"832        reduceMotion={false}833        initialSection="feedback"834        onBreachMonitoringChange={vi.fn()}835        onLockOnCloseChange={vi.fn()}836        onAutoLockMinutesChange={vi.fn()}837        onTrashRetentionChange={vi.fn()}838        onThemeChange={vi.fn()}839        onAccentChange={vi.fn()}840        onTextSizeChange={vi.fn()}841        onReduceMotionChange={vi.fn()}842        shortcuts={DEFAULT_BINDINGS}843        onShortcutsChange={vi.fn()}844        onBack={vi.fn()}845        onImport={vi.fn()}846        onExport={vi.fn()}847        onBreachCheckDone={vi.fn()}848      />849    );850    await flushView();851852    const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;853    expect(feedbackCard.classList.contains("is-active")).toBe(true);854855    const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];856    const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;857    const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;858    expect(bugButton.getAttribute("aria-pressed")).toBe("true");859    expect(featureButton.getAttribute("aria-pressed")).toBe("false");860861    featureButton.click();862    await flushView();863864    expect(bugButton.getAttribute("aria-pressed")).toBe("false");865    expect(featureButton.getAttribute("aria-pressed")).toBe("true");866    expect(featureButton.classList.contains("is-active")).toBe(true);867868    inputText(869      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,870      '<script>alert("xss")</script> Please make this easier to see & safer.'871    );872    const sendButton = Array.from(container.querySelectorAll("button")).find(873      (candidate) => candidate.textContent?.trim() === "Send"874    ) as HTMLButtonElement;875    sendButton.click();876877    await vi.waitFor(() => {878      expect(container.textContent).toContain("Thank you");879    });880881    const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));882    expect(requestBody.subject).toContain("Feature request");883    expect(requestBody.message).toBe(884      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; safer."885    );886    expect(requestBody.message).not.toContain("<script>");887    expect(container.textContent).toContain("Someone will process it soon");888    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();889    expect(container.querySelector(".peach-feedback-submit")).toBeNull();890  });891892  it("Settings keeps license activation on the full-screen celebration path", async () => {893    const onLicenseChange = vi.fn(async () => undefined);894    sendMessageMock.mockImplementation(async (msg: unknown) => {895      const type = (msg as { type?: string }).type;896      if (type === "GET_LICENSE") return { ok: true, data: { valid: false, plan: "free" } };897      if (type === "ACTIVATE_LICENSE") {898        return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };899      }900      if (type === "GET_ENTRIES") return { ok: true, data: [] };901      return { ok: true, data: null };902    });903904    const container = mount(<SettingsView {...settingsProps({ isPro: false, onLicenseChange })} />);905    await flushView();906907    inputText(908      container.querySelector(".peach-pro-key-inline-input") as HTMLInputElement,909      "PEACH-TEST-KEY",910    );911    const apply = container.querySelector(".peach-pro-key-apply") as HTMLButtonElement;912    apply.click();913914    await vi.waitFor(() => expect(onLicenseChange).toHaveBeenCalledTimes(1));915    expect(container.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(false);916    expect(container.textContent).not.toContain("thank you ✦");917  });918919  it("Settings Pro star and peach clicks use the same in-card replay path", async () => {920    sendMessageMock.mockImplementation(async (msg: unknown) => {921      const type = (msg as { type?: string }).type;922      if (type === "GET_LICENSE") return { ok: true, data: { valid: true, plan: "pro", issued: "2026-05-21" } };923      if (type === "GET_LICENSE_KEY") return { ok: true, data: "PEACH-TEST-KEY" };924      if (type === "GET_ENTRIES") return { ok: true, data: [] };925      return { ok: true, data: null };926    });927928    const onLicenseChangeFromStar = vi.fn();929    const starContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromStar })} />);930    await flushView();931    (starContainer.querySelector(".stk-card-icon--btn") as HTMLButtonElement).click();932    expect(starContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);933    expect(onLicenseChangeFromStar).not.toHaveBeenCalled();934935    const onLicenseChangeFromPeach = vi.fn();936    const peachContainer = mount(<SettingsView {...settingsProps({ isPro: true, onLicenseChange: onLicenseChangeFromPeach })} />);937    await flushView();938    (peachContainer.querySelector(".peach-pro-active-mark-wrap") as HTMLButtonElement).click();939    expect(peachContainer.querySelector(".stk-deck")?.classList.contains("is-fanning")).toBe(true);940    expect(onLicenseChangeFromPeach).not.toHaveBeenCalled();941  });942943  it("Settings feedback sends even if Firefox reports no data-collection consent — a user-initiated send is its own consent", async () => {944    sendMessageMock.mockResolvedValue({ ok: true, data: null });945    const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({946      ok: true,947      json: async () => ({ success: true }),948    }));949    vi.stubGlobal("fetch", fetchMock);950    // Firefox-style permissions API reporting the optional consent as absent.951    // The old code gated send behind this and silently returned to idle, which952    // read as a dead Send button. The gate is gone: Send must always send.953    vi.stubGlobal("browser", {954      permissions: {955        getAll: vi.fn(async () => ({ data_collection: [] })),956        request: vi.fn(async () => false),957      },958    });959960    const container = mount(961      <SettingsView962        entriesCount={1}963        breachMonitoring={true}964        lockOnClose={true}965        autoLockMinutes={5}966        trashRetentionDays={30}967        theme="dark"968        accentColor="peach"969        textSize="medium"970        reduceMotion={false}971        initialSection="feedback"972        onBreachMonitoringChange={vi.fn()}973        onLockOnCloseChange={vi.fn()}974        onAutoLockMinutesChange={vi.fn()}975        onTrashRetentionChange={vi.fn()}976        onThemeChange={vi.fn()}977        onAccentChange={vi.fn()}978        onTextSizeChange={vi.fn()}979        onReduceMotionChange={vi.fn()}980        shortcuts={DEFAULT_BINDINGS}981        onShortcutsChange={vi.fn()}982        onBack={vi.fn()}983        onImport={vi.fn()}984        onExport={vi.fn()}985        onBreachCheckDone={vi.fn()}986      />987    );988    await flushView();989990    inputText(991      container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,992      "Please improve the feedback flow."993    );994    const sendButton = Array.from(container.querySelectorAll("button")).find(995      (candidate) => candidate.textContent?.trim() === "Send"996    ) as HTMLButtonElement;997    sendButton.click();998999    await vi.waitFor(() => {1000      expect(fetchMock).toHaveBeenCalledTimes(1);1001    });1002    await vi.waitFor(() => {1003      expect(container.textContent).toContain("Thank you");1004    });1005    expect(container.querySelector(".peach-feedback-textarea")).toBeNull();1006  });10071008  it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {1009    sendMessageMock.mockImplementation(async (msg: unknown) => {1010      const m = msg as { type: string };1011      if (m.type === "GET_RECOVERY_WORDS") {1012        return {1013          ok: true,1014          data: [1015            "abandon", "ability", "able", "about", "above", "absent",1016            "absorb", "abstract", "absurd", "abuse", "access", "accident",1017          ],1018        };1019      }1020      return { ok: true, data: null };1021    });1022    const onAutoLockMinutesChange = vi.fn();1023    const onThemeChange = vi.fn();1024    const container = mount(1025      <SettingsView1026        entriesCount={1}1027        breachMonitoring={true}1028        lockOnClose={true}1029        autoLockMinutes={5}1030        trashRetentionDays={30}1031        theme="dark"1032        accentColor="peach"1033        textSize="medium"1034        reduceMotion={false}1035        onBreachMonitoringChange={vi.fn()}1036        onLockOnCloseChange={vi.fn()}1037        onAutoLockMinutesChange={onAutoLockMinutesChange}1038        onTrashRetentionChange={vi.fn()}1039        onThemeChange={onThemeChange}1040        onAccentChange={vi.fn()}1041        onTextSizeChange={vi.fn()}1042        onReduceMotionChange={vi.fn()}1043        shortcuts={DEFAULT_BINDINGS}1044        onShortcutsChange={vi.fn()}1045        onBack={vi.fn()}1046        onImport={vi.fn()}1047        onExport={vi.fn()}1048        onBreachCheckDone={vi.fn()}1049      />1050    );1051    await flushView();1052    sendMessageMock.mockClear();10531054    // ── Auto-lock + theme changes (before password change) ─────────────1055    const selects = container.querySelectorAll("select");1056    changeSelect(selects[0] as HTMLSelectElement, "15");1057    expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);10581059    const appearanceCard = container.querySelector('[data-settings-card-id="appearance"]') as HTMLElement;1060    appearanceCard.click();1061    await flushView();1062    expect(container.querySelector(".stk-card.is-active")?.textContent).toContain("Appearance");1063    const themeButton = Array.from(container.querySelectorAll("button")).find(1064      (candidate) => candidate.textContent?.trim() === "Switch to light"1065    ) as HTMLButtonElement;1066    themeButton.click();1067    expect(onThemeChange).toHaveBeenCalledWith("light");10681069    // ── Password change → recovery words display ────────────────────────1070    const protectionCard = container.querySelector('[data-settings-card-id="protection"]') as HTMLElement;1071    protectionCard.click();1072    await flushView();10731074    const changeButton = Array.from(container.querySelectorAll("button")).find(1075      (candidate) => candidate.textContent?.trim() === "change"1076    ) as HTMLButtonElement;1077    changeButton.click();1078    await flushView();10791080    const inputs = container.querySelectorAll('input[type="password"]');1081    inputText(inputs[0] as HTMLInputElement, "current-pass");1082    inputText(inputs[1] as HTMLInputElement, "new-password-123");1083    inputText(inputs[2] as HTMLInputElement, "new-password-123");1084    await flushView();10851086    const saveButton = Array.from(container.querySelectorAll("button")).find(1087      (candidate) => candidate.textContent?.trim() === "save new password"1088    ) as HTMLButtonElement;1089    saveButton.click();1090    await flushView();10911092    expect(sendMessageMock).toHaveBeenCalledWith({1093      type: "CHANGE_PASSWORD",1094      payload: {1095        currentPassword: "current-pass",1096        newPassword: "new-password-123",1097      },1098    });10991100    // After a successful password change the recovery word display replaces settings.1101    expect(container.textContent).toContain("save your recovery words");1102    expect(sendMessageMock).toHaveBeenCalledWith({ type: "GET_RECOVERY_WORDS" });1103  });11041105  it("Entry copy actions send COPY and canary ping messages for password and username", async () => {1106    sendMessageMock.mockResolvedValue({ ok: true });1107    const container = mount(1108      <EntryView1109        entry={sampleEntry()}1110        onDelete={vi.fn()}1111        onFieldSaved={vi.fn(async () => undefined)}1112      />1113    );11141115    const passwordButton = container.querySelector('button[aria-label="Copy password"]') as HTMLButtonElement;1116    passwordButton.click();1117    await flushView();11181119    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1120      type: "COPY",1121      payload: { text: "secret123", label: "password", alreadyWritten: false },1122    });1123    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1124      type: "FIRE_CANARY_PING",1125      payload: {1126        webhookUrl: "https://example.com/hook",1127        entryTitle: "GitHub",1128        trigger: "password_copied",1129      },1130    });11311132    sendMessageMock.mockClear();1133    const usernameButton = container.querySelector('button[aria-label="Copy username"]') as HTMLButtonElement;1134    usernameButton.click();1135    await flushView();11361137    expect(sendMessageMock).toHaveBeenNthCalledWith(1, {1138      type: "COPY",1139      payload: { text: "june", label: "username", alreadyWritten: false },1140    });1141    expect(sendMessageMock).toHaveBeenNthCalledWith(2, {1142      type: "FIRE_CANARY_PING",1143      payload: {1144        webhookUrl: "https://example.com/hook",1145        entryTitle: "GitHub",1146        trigger: "username_copied",1147      },1148    });1149  });11501151  it("Entry shows Advanced for passkey-bearing credential entries", async () => {1152    const container = mount(1153      <EntryView1154        entry={sampleEntry({1155          type: "passkey",1156          username: undefined,1157          password: undefined,1158          totp: undefined,1159          canary: undefined,1160          passkey: {1161            rpId: "vercel.com",1162            rpName: "Vercel",1163            credentialId: "cred-1",1164            publicKeyCose: "cose",1165            userHandle: "handle",1166            userName: "june",1167            userDisplayName: "June",1168            algorithm: -7,1169            signCount: 0,1170            aaguid: "",1171            backupEligible: false,1172            backupState: false,1173            createdAt: 1,1174          },1175        })}1176        onDelete={vi.fn()}1177        onFieldSaved={vi.fn(async () => undefined)}1178      />1179    );11801181    const advanced = Array.from(container.querySelectorAll(".peach-entry-add-link")).find(1182      (button) => button.textContent?.includes("Advanced")1183    ) as HTMLButtonElement | undefined;1184    expect(advanced).toBeDefined();1185    expect(advanced?.textContent).toContain("+ Advanced");11861187    advanced!.click();1188    await flushView();11891190    expect(container.textContent).toContain("passkey · vercel.com");1191  });11921193  it("Entry refuses to send an empty password update and copies TOTP through COPY", async () => {1194    sendMessageMock.mockResolvedValue({ ok: true });1195    const onFieldSaved = vi.fn(async () => undefined);1196    const container = mount(1197      <EntryView1198        entry={sampleEntry()}1199        onDelete={vi.fn()}1200        onFieldSaved={onFieldSaved}1201      />1202    );12031204    const passwordValue = container.querySelector(".peach-password-value") as HTMLSpanElement;1205    passwordValue.click();1206    await flushView();12071208    const passwordInput = container.querySelector('input[placeholder="paste or type a password"]') as HTMLInputElement;1209    inputText(passwordInput, "");1210    await flushView();12111212    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1213    saveButton.click();1214    await flushView();12151216    expect(sendMessageMock).not.toHaveBeenCalledWith(1217      expect.objectContaining({ type: "UPDATE_ENTRY" })1218    );1219    expect(onFieldSaved).not.toHaveBeenCalled();12201221    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1222    totpMain.click();12231224    await vi.waitFor(() => {1225      expect(sendMessageMock).toHaveBeenLastCalledWith({1226        type: "COPY",1227        payload: { text: "123456", label: "totp", alreadyWritten: false },1228      });1229    }, { timeout: 500 });1230  });12311232  it("Entry closes an unchanged inline editor when focus leaves it", async () => {1233    sendMessageMock.mockResolvedValue({ ok: true });1234    const container = mount(1235      <EntryView1236        entry={sampleEntry()}1237        onDelete={vi.fn()}1238        onFieldSaved={vi.fn(async () => undefined)}1239      />1240    );12411242    const usernameValue = container.querySelector(".peach-field-value.peach-inline-clickable") as HTMLSpanElement;1243    usernameValue.click();1244    await flushView();12451246    const usernameInput = container.querySelector(".peach-inline-input[autocomplete=\"off\"]") as HTMLInputElement;1247    usernameInput.blur();1248    await flushView();12491250    expect(container.querySelector(".peach-inline-input[autocomplete=\"off\"]")).toBeNull();1251    expect(sendMessageMock).not.toHaveBeenCalledWith(1252      expect.objectContaining({ type: "UPDATE_ENTRY" })1253    );1254  });12551256  it("Entry keeps the full used-email list available while editing an email", async () => {1257    const container = mount(1258      <EntryView1259        entry={sampleEntry({ email: "[email protected]" })}1260        entries={[1261          sampleEntry({ email: "[email protected]" }),1262          sampleEntry({ id: "entry-2", email: "[email protected]" }),1263          sampleEntry({ id: "entry-3", username: "duckuccino" }),1264        ]}1265        onDelete={vi.fn()}1266        onFieldSaved={vi.fn(async () => undefined)}1267      />1268    );12691270    const emailValue = Array.from(container.querySelectorAll(".peach-field-card")).find(1271      (card) => card.querySelector(".peach-label")?.textContent === "email"1272    )?.querySelector(".peach-inline-clickable") as HTMLSpanElement;1273    emailValue.click();1274    await flushView();12751276    const suggestions = Array.from(container.querySelectorAll(".peach-identity-suggestion-value"))1277      .map((element) => element.textContent);1278    expect(suggestions).toEqual(["[email protected]", "[email protected]"]);1279  });12801281  it("Entry lets TOTP secrets be edited inline", async () => {1282    sendMessageMock.mockResolvedValue({ ok: true });1283    const onFieldSaved = vi.fn(async () => undefined);1284    const container = mount(1285      <EntryView1286        entry={sampleEntry()}1287        onDelete={vi.fn()}1288        onFieldSaved={onFieldSaved}1289      />1290    );12911292    const totpMain = container.querySelector(".peach-totp-main") as HTMLDivElement;1293    totpMain.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));1294    await flushView();12951296    const totpInput = container.querySelector('input[placeholder="otpauth://... or base32 secret"]') as HTMLInputElement;1297    inputText(totpInput, "otpauth://totp/Example?secret=NB2W45DFOIZA====&issuer=Peach");1298    await flushView();12991300    const saveButton = container.querySelector('button[aria-label="Save"]') as HTMLButtonElement;1301    saveButton.click();1302    await flushView();13031304    expect(sendMessageMock).toHaveBeenCalledWith({1305      type: "UPDATE_ENTRY",1306      payload: {1307        id: "entry-1",1308        patch: { totp: "NB2W45DFOIZA====" },1309      },1310    });1311    expect(onFieldSaved).toHaveBeenCalled();1312  });13131314  it("Vault seeds and focuses search when a search seed is provided", async () => {1315    const container = mount(1316      <VaultView1317        entries={[sampleEntry(), sampleEntry({ id: "entry-2", title: "GitLab" })]}1318        sortOrder="url-first"1319        searchSeed="g"1320        searchSeedNonce={1}1321        onSelectEntry={vi.fn()}1322        onSortOrderChange={vi.fn()}1323        onAdd={vi.fn()}1324        onClose={vi.fn()}1325      />1326    );13271328    await flushView();13291330    const search = container.querySelector(".peach-list-top .peach-search-input") as HTMLInputElement;1331    expect(search.value).toBe("g");1332    expect(document.activeElement).toBe(search);1333  });13341335  it("Import existing-vault add mode parses a file and batches entries into the vault", async () => {1336    parseImportSourceMock.mockResolvedValue(1337      sampleImportResult({1338        entries: [1339          {1340            type: "login",1341            title: "Imported site",1342            username: "imported-user",1343            password: "imported-pass",1344            url: "https://imported.test",1345          },1346        ],1347      })1348    );1349    sendMessageMock.mockImplementation(async (msg: unknown) => {1350      const type = (msg as { type?: string }).type;1351      if (type === "GET_ENTRIES") {1352        return {1353          ok: true,1354          data: [1355            sampleEntry({1356              id: "existing-1",1357              title: "Imported site",1358              username: "imported-user",1359              url: "https://imported.test",1360            }),1361          ],1362        };1363      }1364      if (type === "BATCH_ADD_ENTRIES") {1365        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1366      }1367      return { ok: true };1368    });1369    const onImport = vi.fn(async () => undefined);1370    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1371    await flushView();1372    sendMessageMock.mockClear();13731374    await selectImportFile(container);1375    findButton(container, "Import 1").click();1376    await flushView();13771378    expect(parseImportSourceMock).toHaveBeenCalledWith('{"export":"test"}', undefined);1379    expect(sendMessageMock).toHaveBeenCalledWith({1380      type: "BATCH_ADD_ENTRIES",1381      payload: {1382        entries: [1383          expect.objectContaining({1384            title: "Imported site",1385            username: "imported-user",1386            password: "imported-pass",1387            url: "https://imported.test",1388          }),1389        ],1390      },1391    });1392    expect(onImport).toHaveBeenCalled();1393    expect(container.textContent).toContain("1 entry appears to be duplicates");1394  });13951396  it("Import replace mode requires confirmation before clearing and replacing the vault", async () => {1397    parseImportSourceMock.mockResolvedValue(sampleImportResult());1398    sendMessageMock.mockImplementation(async (msg: unknown) => {1399      const type = (msg as { type?: string }).type;1400      if (type === "GET_ENTRIES") {1401        return { ok: true, data: [sampleEntry({ id: "existing-1" })] };1402      }1403      if (type === "CLEAR_VAULT_ENTRIES") {1404        return { ok: true };1405      }1406      if (type === "BATCH_ADD_ENTRIES") {1407        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1408      }1409      return { ok: true };1410    });1411    const onImport = vi.fn(async () => undefined);1412    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1413    await flushView();1414    sendMessageMock.mockClear();14151416    await selectImportFile(container);1417    (container.querySelector('input[value="replace"]') as HTMLInputElement).click();1418    await flushView();14191420    findButton(container, "Replace vault with 1").click();1421    await flushView();14221423    expect(sendMessageMock).not.toHaveBeenCalledWith(1424      expect.objectContaining({ type: "CLEAR_VAULT_ENTRIES" })1425    );1426    expect(container.textContent).toContain("Replace forever? This cannot be undone.");14271428    findButton(container, "Yes, replace forever").click();1429    await flushView();14301431    expect(sendMessageMock).toHaveBeenCalledWith({ type: "CLEAR_VAULT_ENTRIES" });1432    expect(sendMessageMock).toHaveBeenCalledWith({1433      type: "BATCH_ADD_ENTRIES",1434      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1435    });1436    expect(onImport).toHaveBeenCalled();1437  });14381439  it("Import onboarding mode waits for a matching master password before creating a vault", async () => {1440    parseImportSourceMock.mockResolvedValue(sampleImportResult());1441    sendMessageMock.mockImplementation(async (msg: unknown) => {1442      const type = (msg as { type?: string }).type;1443      if (type === "CREATE_VAULT") return { ok: true };1444      if (type === "BATCH_ADD_ENTRIES") {1445        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1446      }1447      return { ok: true };1448    });1449    const onImport = vi.fn(async () => undefined);1450    const container = mount(1451      <ImportView1452        mode="onboarding"1453        replacesExistingVault={true}1454        onBack={vi.fn()}1455        onImport={onImport}1456      />1457    );14581459    await selectImportFile(container);14601461    const createButton = findButton(container, "Create vault + import 1");1462    expect(createButton.disabled).toBe(true);1463    expect(container.textContent).toContain("Restoring now replaces the locked local vault");1464    expect(container.textContent).toContain("Use at least 8 characters");14651466    const masterInput = container.querySelector(1467      'input[placeholder="new master password"]'1468    ) as HTMLInputElement;1469    const confirmInput = container.querySelector(1470      'input[placeholder="confirm master password"]'1471    ) as HTMLInputElement;1472    const revealButton = container.querySelector(1473      'button[aria-label="Show passwords"]'1474    ) as HTMLButtonElement;1475    expect(revealButton).toBeTruthy();1476    revealButton.click();1477    await flushView();1478    expect(masterInput.type).toBe("text");1479    expect(confirmInput.type).toBe("text");14801481    inputText(masterInput, "fresh-master-password");1482    inputText(confirmInput, "fresh-master-password");1483    await flushView();14841485    expect(createButton.disabled).toBe(false);1486    createButton.click();1487    await flushView();14881489    expect(sendMessageMock).toHaveBeenCalledWith({1490      type: "CREATE_VAULT",1491      payload: { password: "fresh-master-password" },1492    });1493    expect(sendMessageMock).toHaveBeenCalledWith({1494      type: "BATCH_ADD_ENTRIES",1495      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1496    });1497    expect(onImport).toHaveBeenCalled();1498  });14991500  it("Import encrypted backups reparse with the backup password before import", async () => {1501    parseImportSourceMock1502      .mockResolvedValueOnce(1503        sampleImportResult({1504          format: "peach-json",1505          label: "Peach encrypted backup",1506          entries: [],1507          totalItems: 0,1508          requiresPassword: true,1509        })1510      )1511      .mockResolvedValueOnce(1512        sampleImportResult({1513          format: "peach-json",1514          label: "Peach encrypted backup",1515        })1516      );1517    sendMessageMock.mockImplementation(async (msg: unknown) => {1518      const type = (msg as { type?: string }).type;1519      if (type === "GET_ENTRIES") return { ok: true, data: [] };1520      if (type === "BATCH_ADD_ENTRIES") {1521        return { ok: true, data: [sampleEntry({ id: "imported-1" })] };1522      }1523      return { ok: true };1524    });1525    const onImport = vi.fn(async () => undefined);1526    const container = mount(<ImportView onBack={vi.fn()} onImport={onImport} />);1527    await flushView();1528    sendMessageMock.mockClear();15291530    await selectImportFile(container, '{"type":"peach-export"}');1531    const password = container.querySelector('input[placeholder="backup password"]') as HTMLInputElement;1532    inputText(password, "backup-passphrase");1533    pressEnter(password);1534    await flushView();1535    await flushView();15361537    expect(parseImportSourceMock).toHaveBeenNthCalledWith(1, '{"type":"peach-export"}', undefined);1538    expect(parseImportSourceMock).toHaveBeenNthCalledWith(2, '{"type":"peach-export"}', "backup-passphrase");15391540    findButton(container, "Import 1").click();1541    await flushView();15421543    expect(sendMessageMock).toHaveBeenCalledWith({1544      type: "BATCH_ADD_ENTRIES",1545      payload: { entries: [expect.objectContaining({ title: "Imported site" })] },1546    });1547    expect(onImport).toHaveBeenCalled();1548  });1549});