04-desktop-pairing-ui.test.tsx

8.8 KB · raw

1// @vitest-environment jsdom2//3// DRAFT spec for DesktopPairingFlow (Change 4). Apply-ready — do NOT run from4// the drafts folder; on apply it lives next to the component as5// src/popup/components/DesktopPairingFlow.test.tsx, and the import paths below6// are written for THAT location (./DesktopPairingFlow, ../views/view-test-utils,7// ../../shared/* ).8//9// Mirrors the repo's existing component-test idiom (ChirpPairingFlow.test.tsx):10//   - @vitest-environment jsdom11//   - renderView / inputText from view-test-utils (NOT @solidjs/testing-library)12//   - raw DOM queries + .click()13//14// chrome messaging is mocked exactly the way the popup talks to it:15//   - shared/bridge `sendMessage` is mocked (popup -> bg)16//   - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can17//     fire DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes (bg -> popup)1819import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2021// --- mock the background bridge (popup -> bg) -------------------------------22const sendMessage = vi.fn(async (_msg: unknown) => ({ ok: true }) as { ok: boolean; error?: string });23vi.mock("../../shared/bridge", () => ({ sendMessage }));2425import { DesktopPairingFlow } from "./DesktopPairingFlow";26import type { PairedDevice } from "../../shared/sync-types";27import { renderView, inputText } from "../views/view-test-utils";2829// --- fake chrome.runtime push channel (bg -> popup) ------------------------30type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;31let listeners: RuntimeListener[] = [];3233function pushFromBackground(type: string, payload: unknown): void {34  // Solid effects/handlers run synchronously; the listener registered in35  // onMount updates the signals immediately when invoked.36  for (const l of [...listeners]) l({ type, payload });37}3839beforeEach(() => {40  listeners = [];41  sendMessage.mockClear();42  sendMessage.mockResolvedValue({ ok: true });43  (globalThis as unknown as { chrome: unknown }).chrome = {44    runtime: {45      onMessage: {46        addListener: (fn: RuntimeListener) => { listeners.push(fn); },47        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },48      },49    },50  };51});5253describe("DesktopPairingFlow", () => {54  const cleanups: Array<() => void> = [];55  afterEach(() => {56    while (cleanups.length) cleanups.pop()!();57  });5859  function mount(handlers: { onCancel?: () => void; onDone?: (d?: PairedDevice) => void } = {}) {60    const r = renderView(61      <DesktopPairingFlow62        onCancel={handlers.onCancel ?? (() => {})}63        onDone={handlers.onDone ?? (() => {})}64      />,65    );66    cleanups.push(r.dispose);67    return r;68  }6970  function codeInput(container: HTMLElement): HTMLInputElement {71    return container.querySelector("#desktop-pair-code") as HTMLInputElement;72  }73  function button(container: HTMLElement, label: string): HTMLButtonElement | undefined {74    return Array.from(container.querySelectorAll("button")).find((b) => b.textContent === label) as75      | HTMLButtonElement76      | undefined;77  }7879  it("renders a 6-digit numeric code input", () => {80    const { container } = mount();81    const input = codeInput(container);82    expect(input).toBeTruthy();83    expect(input.getAttribute("inputmode")).toBe("numeric");84    expect(input.getAttribute("maxlength")).toBe("6");85    // non-digits are stripped and the value is capped at 6 chars86    inputText(input, "12ab3456789");87    expect(input.value).toBe("123456");88  });8990  it("Connect sends PAIR_WITH_DESKTOP with the typed code", async () => {91    const { container } = mount();92    inputText(codeInput(container), "428913");93    const connect = button(container, "Connect")!;94    expect(connect.disabled).toBe(false);95    connect.click();96    await Promise.resolve();97    expect(sendMessage).toHaveBeenCalledWith({98      type: "PAIR_WITH_DESKTOP",99      payload: { code: "428913" },100    });101    // moves into the searching state102    expect(container.textContent).toContain("Searching for helper…");103  });104105  it("Connect stays disabled until 6 digits are entered", () => {106    const { container } = mount();107    inputText(codeInput(container), "123");108    expect(button(container, "Connect")!.disabled).toBe(true);109    inputText(codeInput(container), "123456");110    expect(button(container, "Connect")!.disabled).toBe(false);111  });112113  it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => {114    const { container } = mount();115    inputText(codeInput(container), "428913");116    button(container, "Connect")!.click();117    await Promise.resolve();118119    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });120121    expect(container.textContent).toContain("Confirm this code matches on your computer:");122    expect(container.textContent).toContain("895245");123    expect(button(container, "Confirm")).toBeTruthy();124    expect(button(container, "Cancel")).toBeTruthy();125  });126127  it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => {128    const { container } = mount();129    inputText(codeInput(container), "428913");130    button(container, "Connect")!.click();131    await Promise.resolve();132    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });133134    button(container, "Confirm")!.click();135    await Promise.resolve();136    expect(sendMessage).toHaveBeenCalledWith({137      type: "DESKTOP_PAIR_CONFIRM",138      payload: { confirmed: true },139    });140  });141142  it("Cancel on the SAS gate sends DESKTOP_PAIR_CONFIRM { confirmed: false } and shows mismatch copy", async () => {143    const { container } = mount();144    inputText(codeInput(container), "428913");145    button(container, "Connect")!.click();146    await Promise.resolve();147    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });148149    button(container, "Cancel")!.click();150    await Promise.resolve();151    expect(sendMessage).toHaveBeenCalledWith({152      type: "DESKTOP_PAIR_CONFIRM",153      payload: { confirmed: false },154    });155    expect(container.textContent).toContain("Codes don't match");156  });157158  it("shows the combined 'not found or expired' copy on invalid_code (socket close)", async () => {159    const { container } = mount();160    inputText(codeInput(container), "000000");161    button(container, "Connect")!.click();162    await Promise.resolve();163164    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth165    // §0.3) — the background collapses both to invalid_code via a socket close.166    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" });167168    expect(container.textContent).toContain("Code not found or expired");169    expect(button(container, "Try again")).toBeTruthy();170  });171172  it("shows the 'no computer found' copy on no_host_found", async () => {173    const { container } = mount();174    inputText(codeInput(container), "111111");175    button(container, "Connect")!.click();176    await Promise.resolve();177    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });178    expect(container.textContent).toContain("Couldn't find your computer");179  });180181  it("renders Paired! and the device name on a complete result", async () => {182    const onDone = vi.fn();183    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: Date.now() };184    const { container } = mount({ onDone });185    inputText(codeInput(container), "428913");186    button(container, "Connect")!.click();187    await Promise.resolve();188    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });189    button(container, "Confirm")!.click();190    await Promise.resolve();191192    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });193    expect(container.textContent).toContain("Paired!");194    expect(container.textContent).toContain("Connected to June's iMac");195  });196197  it("auto-dismisses after the paired state settles", async () => {198    vi.useFakeTimers();199    try {200      const onDone = vi.fn();201      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };202      const { container } = mount({ onDone });203      inputText(codeInput(container), "428913");204      button(container, "Connect")!.click();205      await Promise.resolve();206      pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });207      button(container, "Confirm")!.click();208      await Promise.resolve();209      pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });210      vi.advanceTimersByTime(2000);211      expect(onDone).toHaveBeenCalled();212    } finally {213      vi.useRealTimers();214    }215  });216217  it("surfaces a synchronous start failure as an error phase", async () => {218    sendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });219    const { container } = mount();220    inputText(codeInput(container), "428913");221    button(container, "Connect")!.click();222    await Promise.resolve();223    await Promise.resolve();224    expect(container.textContent).toContain("Unlock Peach first");225  });226});