PairDevice.test.tsx

11.0 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 the address-bearing pairing code a phone displays.9//10// chrome messaging is mocked the way the popup uses it:11//   - shared/bridge `sendMessage` is mocked (popup → bg)12//   - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can13//     fire CODE_PAIR_SAS / CODE_PAIR_RESULT pushes (bg → popup)1415import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";1617// --- mock the background bridge (popup → bg) -------------------------------18const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));19vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));2021// Mutated per-test to script the PAIR_WITH_CODE start response.22let startResponse: { ok: boolean; error?: string } = { ok: true };2324import { PairDeviceView } from "./PairDevice";25import type { PairedDevice } from "../../shared/sync-types";26import { renderView, inputText } from "./view-test-utils";2728// --- fake chrome.runtime push channel (bg → popup) ------------------------29type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;30let listeners: RuntimeListener[] = [];3132function pushFromBackground(type: string, payload: unknown): void {33  for (const l of [...listeners]) l({ type, payload });34}3536beforeEach(() => {37  listeners = [];38  startResponse = { ok: true };39  mockSendMessage.mockReset();40  mockSendMessage.mockImplementation(async (msg: { type?: string }) => {41    if (msg?.type === "PAIR_WITH_CODE") return startResponse;42    return { ok: true };43  });44  (globalThis as unknown as { chrome: unknown }).chrome = {45    runtime: {46      onMessage: {47        addListener: (fn: RuntimeListener) => { listeners.push(fn); },48        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },49      },50    },51  };52});5354describe("PairDevice — code pairing", () => {55  const VALID_CODE = "A74H-48J3-KI32-OEP4";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("groups partial input 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("1234-56");120    expect(buttonByText(container, "Connect")!.disabled).toBe(true);121  });122123  it("keeps Connect disabled for input that is neither code form", () => {124    const { container } = mount();125    gotoCodeEntry(container);126    // Long enough to be grouped, but too short to carry a token.127    inputText(codeInput(container), "A74H48J3");128    expect(buttonByText(container, "Connect")!.disabled).toBe(true);129  });130131  it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {132    const { container } = mount();133    gotoCodeEntry(container);134    inputText(codeInput(container), VALID_CODE);135    const connect = buttonByText(container, "Connect")!;136    expect(connect.disabled).toBe(false);137    connect.click();138    await Promise.resolve();139    expect(mockSendMessage).toHaveBeenCalledWith({140      type: "PAIR_WITH_CODE",141      payload: { code: VALID_CODE },142    });143    expect(container.textContent).toContain("Connecting…");144  });145146  it("Connect stays disabled until the full pairing code is entered", () => {147    const { container } = mount();148    gotoCodeEntry(container);149    inputText(codeInput(container), "A74H48J3");150    expect(buttonByText(container, "Connect")!.disabled).toBe(true);151    inputText(codeInput(container), VALID_CODE);152    expect(buttonByText(container, "Connect")!.disabled).toBe(false);153  });154155  it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {156    const { container } = mount();157    gotoCodeEntry(container);158    inputText(codeInput(container), VALID_CODE);159    buttonByText(container, "Connect")!.click();160    await Promise.resolve();161162    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });163164    expect(container.textContent).toContain("Confirm this code matches on your phone:");165    expect(container.textContent).toContain("895245");166    expect(buttonByText(container, "Confirm")).toBeTruthy();167    expect(buttonByText(container, "Cancel")).toBeTruthy();168  });169170  it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {171    const { container } = mount();172    gotoCodeEntry(container);173    inputText(codeInput(container), VALID_CODE);174    buttonByText(container, "Connect")!.click();175    await Promise.resolve();176    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });177178    buttonByText(container, "Confirm")!.click();179    await Promise.resolve();180    expect(mockSendMessage).toHaveBeenCalledWith({181      type: "CODE_PAIR_CONFIRM",182      payload: { confirmed: true },183    });184  });185186  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {187    const { container } = mount();188    gotoCodeEntry(container);189    inputText(codeInput(container), VALID_CODE);190    buttonByText(container, "Connect")!.click();191    await Promise.resolve();192    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });193194    buttonByText(container, "Cancel")!.click();195    await Promise.resolve();196    expect(mockSendMessage).toHaveBeenCalledWith({197      type: "CODE_PAIR_CONFIRM",198      payload: { confirmed: false },199    });200    expect(container.textContent).toContain("Codes don't match");201  });202203  it("shows the combined 'not found or expired' copy on invalid_code", async () => {204    const { container } = mount();205    gotoCodeEntry(container);206    inputText(codeInput(container), VALID_CODE);207    buttonByText(container, "Connect")!.click();208    await Promise.resolve();209210    // Wrong vs expired is indistinguishable on the wire; the background211    // collapses both to invalid_code via a socket close.212    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });213214    expect(container.textContent).toContain("Code not found or expired");215    expect(buttonByText(container, "Try again")).toBeTruthy();216  });217218  it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {219    const { container } = mount();220    gotoCodeEntry(container);221    inputText(codeInput(container), VALID_CODE);222    buttonByText(container, "Connect")!.click();223    await Promise.resolve();224    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });225    expect(container.textContent).toContain("Couldn't reach your phone");226  });227228  it("renders Paired! and the device name on a complete result", async () => {229    const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 };230    const { container } = mount();231    gotoCodeEntry(container);232    inputText(codeInput(container), VALID_CODE);233    buttonByText(container, "Connect")!.click();234    await Promise.resolve();235    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });236    buttonByText(container, "Confirm")!.click();237    await Promise.resolve();238239    pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });240    expect(container.textContent).toContain("Paired!");241    expect(container.textContent).toContain("Connected to June's Pixel");242  });243244  it("auto-dismisses back to settings after the paired state settles", async () => {245    vi.useFakeTimers();246    try {247      const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 };248      const onBack = vi.fn();249      const { container } = mount(onBack);250      gotoCodeEntry(container);251      inputText(codeInput(container), VALID_CODE);252      buttonByText(container, "Connect")!.click();253      await Promise.resolve();254      pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });255      buttonByText(container, "Confirm")!.click();256      await Promise.resolve();257      pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });258      expect(container.textContent).toContain("Paired!");259      vi.advanceTimersByTime(2000);260      // returns to the Settings sync hub, which owns the device list now261      expect(onBack).toHaveBeenCalled();262    } finally {263      vi.useRealTimers();264    }265  });266267  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {268    startResponse = { ok: false, error: "vault_locked" };269    const { container } = mount();270    gotoCodeEntry(container);271    inputText(codeInput(container), VALID_CODE);272    buttonByText(container, "Connect")!.click();273    await Promise.resolve();274    await Promise.resolve();275    expect(container.textContent).toContain("Unlock Peach first");276  });277});