PairDevice.test.tsx

10.9 KB · raw

1// @vitest-environment jsdom2//3// Code pairing, exercised through the integrated PairDeviceView state machine.4// Code pairing is a MODE inside PairDevice (birds always animate), not a separate5// component — so these specs walk enter → connecting → SAS → paired/error,6// asserting the popup↔background message contract at each step.7//8// The input accepts both code forms: the pairing code a phone displays9// (pairing-code.ts) and the legacy 6-digit desktop-helper code.10//11// chrome messaging is mocked the way the popup uses it:12//   - shared/bridge `sendMessage` is mocked (popup → bg)13//   - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can14//     fire CODE_PAIR_SAS / CODE_PAIR_RESULT pushes (bg → popup)1516import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";1718// --- mock the background bridge (popup → bg) -------------------------------19const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));20vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));2122// Mutated per-test to script the PAIR_WITH_CODE start response.23let startResponse: { ok: boolean; error?: string } = { ok: true };2425import { PairDeviceView } from "./PairDevice";26import type { PairedDevice } from "../../shared/sync-types";27import { renderView, inputText } from "./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  for (const l of [...listeners]) l({ type, payload });35}3637beforeEach(() => {38  listeners = [];39  startResponse = { ok: true };40  mockSendMessage.mockReset();41  mockSendMessage.mockImplementation(async (msg: { type?: string }) => {42    if (msg?.type === "PAIR_WITH_CODE") return startResponse;43    return { ok: true };44  });45  (globalThis as unknown as { chrome: unknown }).chrome = {46    runtime: {47      onMessage: {48        addListener: (fn: RuntimeListener) => { listeners.push(fn); },49        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },50      },51    },52  };53});5455describe("PairDevice — code pairing", () => {56  const cleanups: Array<() => void> = [];57  afterEach(() => {58    while (cleanups.length) cleanups.pop()!();59  });6061  // PairDevice is pairing-only; it opens straight into the code-entry flow62  // (the device list / method choice live on the Settings sync card).63  function mount(onBack: () => void = () => {}) {64    const r = renderView(65      <PairDeviceView isPro={true} initialPair="code" onBack={onBack} onLearnPro={() => {}} />,66    );67    cleanups.push(r.dispose);68    return r;69  }7071  function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {72    return Array.from(container.querySelectorAll("button")).find(73      (b) => (b.textContent ?? "").trim() === text,74    ) as HTMLButtonElement | undefined;75  }76  function codeInput(container: HTMLElement): HTMLInputElement {77    return container.querySelector("#pair-code") as HTMLInputElement;78  }7980  // initialPair="code" lands directly on the code-entry screen.81  function gotoCodeEntry(_container: HTMLElement): void {82    /* no-op: mount() already opens the code-entry flow */83  }8485  it("opens straight into the code-entry flow (no method-select screen)", () => {86    const { container } = mount();87    expect(container.textContent).not.toContain("How do you want to pair?");88    // The code input is present immediately, without any intermediate choice.89    expect(codeInput(container)).toBeTruthy();90    expect(buttonByText(container, "Connect")).toBeTruthy();91  });9293  it("renders a code input sized for a full pairing code, with birds present", () => {94    const { container } = mount();95    gotoCodeEntry(container);96    const input = codeInput(container);97    expect(input).toBeTruthy();98    // Letters are part of the code now, so the field must not be numeric-only.99    expect(input.getAttribute("inputmode")).toBeNull();100    expect(input.getAttribute("maxlength")).toBe("32");101    // birds always animate on the code screens (design correction)102    expect(container.querySelector(".peach-pair-bird-desktop")).toBeTruthy();103  });104105  it("uppercases and regroups a pairing code as it is typed", () => {106    const { container } = mount();107    gotoCodeEntry(container);108    const input = codeInput(container);109    inputText(input, "a74h48j3ki32oep4");110    expect(input.value).toBe("A74H-48J3-KI32-OEP4");111    expect(buttonByText(container, "Connect")!.disabled).toBe(false);112  });113114  it("leaves a short legacy code ungrouped and strips unusable characters", () => {115    const { container } = mount();116    gotoCodeEntry(container);117    const input = codeInput(container);118    inputText(input, "12_34/56");119    expect(input.value).toBe("123456");120  });121122  it("keeps Connect disabled for input that is neither code form", () => {123    const { container } = mount();124    gotoCodeEntry(container);125    // Long enough to be grouped, but too short to carry a token.126    inputText(codeInput(container), "A74H48J3");127    expect(buttonByText(container, "Connect")!.disabled).toBe(true);128  });129130  it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {131    const { container } = mount();132    gotoCodeEntry(container);133    inputText(codeInput(container), "428913");134    const connect = buttonByText(container, "Connect")!;135    expect(connect.disabled).toBe(false);136    connect.click();137    await Promise.resolve();138    expect(mockSendMessage).toHaveBeenCalledWith({139      type: "PAIR_WITH_CODE",140      payload: { code: "428913" },141    });142    expect(container.textContent).toContain("Connecting…");143  });144145  it("Connect stays disabled until 6 digits are entered", () => {146    const { container } = mount();147    gotoCodeEntry(container);148    inputText(codeInput(container), "123");149    expect(buttonByText(container, "Connect")!.disabled).toBe(true);150    inputText(codeInput(container), "123456");151    expect(buttonByText(container, "Connect")!.disabled).toBe(false);152  });153154  it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {155    const { container } = mount();156    gotoCodeEntry(container);157    inputText(codeInput(container), "428913");158    buttonByText(container, "Connect")!.click();159    await Promise.resolve();160161    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });162163    expect(container.textContent).toContain("Confirm this code matches on your phone:");164    expect(container.textContent).toContain("895245");165    expect(buttonByText(container, "Confirm")).toBeTruthy();166    expect(buttonByText(container, "Cancel")).toBeTruthy();167  });168169  it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {170    const { container } = mount();171    gotoCodeEntry(container);172    inputText(codeInput(container), "428913");173    buttonByText(container, "Connect")!.click();174    await Promise.resolve();175    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });176177    buttonByText(container, "Confirm")!.click();178    await Promise.resolve();179    expect(mockSendMessage).toHaveBeenCalledWith({180      type: "CODE_PAIR_CONFIRM",181      payload: { confirmed: true },182    });183  });184185  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {186    const { container } = mount();187    gotoCodeEntry(container);188    inputText(codeInput(container), "428913");189    buttonByText(container, "Connect")!.click();190    await Promise.resolve();191    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });192193    buttonByText(container, "Cancel")!.click();194    await Promise.resolve();195    expect(mockSendMessage).toHaveBeenCalledWith({196      type: "CODE_PAIR_CONFIRM",197      payload: { confirmed: false },198    });199    expect(container.textContent).toContain("Codes don't match");200  });201202  it("shows the combined 'not found or expired' copy on invalid_code", async () => {203    const { container } = mount();204    gotoCodeEntry(container);205    inputText(codeInput(container), "000000");206    buttonByText(container, "Connect")!.click();207    await Promise.resolve();208209    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth210    // §0.3) — the background collapses both to invalid_code via a socket close.211    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });212213    expect(container.textContent).toContain("Code not found or expired");214    expect(buttonByText(container, "Try again")).toBeTruthy();215  });216217  it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {218    const { container } = mount();219    gotoCodeEntry(container);220    inputText(codeInput(container), "111111");221    buttonByText(container, "Connect")!.click();222    await Promise.resolve();223    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });224    expect(container.textContent).toContain("Couldn't reach your phone");225  });226227  it("renders Paired! and the device name on a complete result", async () => {228    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: 1700000000000 };229    const { container } = mount();230    gotoCodeEntry(container);231    inputText(codeInput(container), "428913");232    buttonByText(container, "Connect")!.click();233    await Promise.resolve();234    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });235    buttonByText(container, "Confirm")!.click();236    await Promise.resolve();237238    pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });239    expect(container.textContent).toContain("Paired!");240    expect(container.textContent).toContain("Connected to June's iMac");241  });242243  it("auto-dismisses back to settings after the paired state settles", async () => {244    vi.useFakeTimers();245    try {246      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };247      const onBack = vi.fn();248      const { container } = mount(onBack);249      gotoCodeEntry(container);250      inputText(codeInput(container), "428913");251      buttonByText(container, "Connect")!.click();252      await Promise.resolve();253      pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });254      buttonByText(container, "Confirm")!.click();255      await Promise.resolve();256      pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });257      expect(container.textContent).toContain("Paired!");258      vi.advanceTimersByTime(2000);259      // returns to the Settings sync hub, which owns the device list now260      expect(onBack).toHaveBeenCalled();261    } finally {262      vi.useRealTimers();263    }264  });265266  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {267    startResponse = { ok: false, error: "vault_locked" };268    const { container } = mount();269    gotoCodeEntry(container);270    inputText(codeInput(container), "428913");271    buttonByText(container, "Connect")!.click();272    await Promise.resolve();273    await Promise.resolve();274    expect(container.textContent).toContain("Unlock Peach first");275  });276});