PairDevice.test.tsx

14.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 };23let lanOnly = true;24const SOURCE_INVITATION =25  "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2627import { PairDeviceView } from "./PairDevice";28import type { PairedDevice } from "../../shared/sync-types";29import { renderView, inputText } from "./view-test-utils";3031// --- fake chrome.runtime push channel (bg → popup) ------------------------32type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;33let listeners: RuntimeListener[] = [];3435function pushFromBackground(type: string, payload: unknown): void {36  for (const l of [...listeners]) l({ type, payload });37}3839beforeEach(() => {40  listeners = [];41  startResponse = { ok: true };42  lanOnly = true;43  mockSendMessage.mockReset();44  mockSendMessage.mockImplementation((msg: { type?: string }) => {45    if (msg?.type === "GET_RELAY_SETTINGS") {46      // Resolve synchronously so the existing code-pairing state-machine tests47      // enter the explicitly configured LAN-only path before their first DOM48      // assertion.49      return {50        then(resolve: (value: unknown) => unknown) {51          return Promise.resolve(resolve({52            ok: true,53            data: { lanOnly },54          }));55        },56      };57    }58    if (msg?.type === "START_ADOPTION_SOURCE") {59      return Promise.resolve({60        ok: true,61        data: { invitation: SOURCE_INVITATION },62      });63    }64    if (msg?.type === "ADOPTION_SOURCE_CONFIRM") {65      return Promise.resolve({ ok: true, data: { valid: true } });66    }67    if (msg?.type === "PAIR_WITH_CODE") return Promise.resolve(startResponse);68    return Promise.resolve({ ok: true });69  });70  (globalThis as unknown as { chrome: unknown }).chrome = {71    runtime: {72      onMessage: {73        addListener: (fn: RuntimeListener) => { listeners.push(fn); },74        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },75      },76    },77  };78});7980describe("PairDevice — relay adoption source", () => {81  const cleanups: Array<() => void> = [];82  afterEach(() => {83    while (cleanups.length) cleanups.pop()!();84  });8586  function mount() {87    lanOnly = false;88    const rendered = renderView(89      <PairDeviceView90        isPro={true}91        initialPair="code"92        onBack={() => {}}93        onLearnPro={() => {}}94      />,95    );96    cleanups.push(rendered.dispose);97    return rendered;98  }99100  it("opens the relay room before rendering its QR and manual fallback", async () => {101    const { container } = mount();102    await vi.waitFor(() => {103      expect(container.textContent).toContain("Scan this in Peach");104    });105106    expect(mockSendMessage).toHaveBeenCalledWith({107      type: "START_ADOPTION_SOURCE",108    });109    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();110    expect(111      (container.querySelector("#pairing-invitation-manual") as HTMLTextAreaElement).value,112    ).toBe(SOURCE_INVITATION);113    expect(container.textContent).toContain("single-use random token");114  });115116  it("requires the matching SAS and master password before sharing", async () => {117    const { container } = mount();118    await vi.waitFor(() => {119      expect(container.textContent).toContain("Scan this in Peach");120    });121122    pushFromBackground("ADOPTION_SOURCE_SAS", {123      sas: "482193",124      receiverDeviceName: "June's phone",125    });126    expect(container.textContent).toContain("June's phone");127    expect(container.textContent).toContain("4 8 2 1 9 3");128129    inputText(130      container.querySelector("#adoption-consent-password") as HTMLInputElement,131      "correct horse battery staple",132    );133    const approve = Array.from(container.querySelectorAll("button")).find(134      (button) => button.textContent?.includes("Share vault"),135    ) as HTMLButtonElement;136    approve.click();137    await vi.waitFor(() => {138      expect(mockSendMessage).toHaveBeenCalledWith({139        type: "ADOPTION_SOURCE_CONFIRM",140        payload: { password: "correct horse battery staple" },141      });142    });143    await vi.waitFor(() => {144      expect(container.textContent).toContain("Waiting for the new device");145    });146  });147});148149describe("PairDevice — code pairing", () => {150  const VALID_CODE = "A74H-48J3-KI32-OEP4";151  const cleanups: Array<() => void> = [];152  afterEach(() => {153    while (cleanups.length) cleanups.pop()!();154  });155156  // PairDevice is pairing-only; it opens straight into the code-entry flow157  // (the device list / method choice live on the Settings sync card).158  function mount(onBack: () => void = () => {}) {159    const r = renderView(160      <PairDeviceView isPro={true} initialPair="code" onBack={onBack} onLearnPro={() => {}} />,161    );162    cleanups.push(r.dispose);163    return r;164  }165166  function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {167    return Array.from(container.querySelectorAll("button")).find(168      (b) => (b.textContent ?? "").trim() === text,169    ) as HTMLButtonElement | undefined;170  }171  function codeInput(container: HTMLElement): HTMLInputElement {172    return container.querySelector("#pair-code") as HTMLInputElement;173  }174175  // initialPair="code" lands directly on the code-entry screen.176  function gotoCodeEntry(_container: HTMLElement): void {177    /* no-op: mount() already opens the code-entry flow */178  }179180  it("opens straight into the code-entry flow (no method-select screen)", () => {181    const { container } = mount();182    expect(container.textContent).not.toContain("How do you want to pair?");183    // The code input is present immediately, without any intermediate choice.184    expect(codeInput(container)).toBeTruthy();185    expect(buttonByText(container, "Connect")).toBeTruthy();186  });187188  it("renders a code input sized for a full pairing code, with birds present", () => {189    const { container } = mount();190    gotoCodeEntry(container);191    const input = codeInput(container);192    expect(input).toBeTruthy();193    // Letters are part of the code now, so the field must not be numeric-only.194    expect(input.getAttribute("inputmode")).toBeNull();195    expect(input.getAttribute("maxlength")).toBe("32");196    // birds always animate on the code screens (design correction)197    expect(container.querySelector(".peach-pair-bird-desktop")).toBeTruthy();198  });199200  it("uppercases and regroups a pairing code as it is typed", () => {201    const { container } = mount();202    gotoCodeEntry(container);203    const input = codeInput(container);204    inputText(input, "a74h48j3ki32oep4");205    expect(input.value).toBe("A74H-48J3-KI32-OEP4");206    expect(buttonByText(container, "Connect")!.disabled).toBe(false);207  });208209  it("groups partial input and strips unusable characters", () => {210    const { container } = mount();211    gotoCodeEntry(container);212    const input = codeInput(container);213    inputText(input, "12_34/56");214    expect(input.value).toBe("1234-56");215    expect(buttonByText(container, "Connect")!.disabled).toBe(true);216  });217218  it("keeps Connect disabled for input that is neither code form", () => {219    const { container } = mount();220    gotoCodeEntry(container);221    // Long enough to be grouped, but too short to carry a token.222    inputText(codeInput(container), "A74H48J3");223    expect(buttonByText(container, "Connect")!.disabled).toBe(true);224  });225226  it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {227    const { container } = mount();228    gotoCodeEntry(container);229    inputText(codeInput(container), VALID_CODE);230    const connect = buttonByText(container, "Connect")!;231    expect(connect.disabled).toBe(false);232    connect.click();233    await Promise.resolve();234    expect(mockSendMessage).toHaveBeenCalledWith({235      type: "PAIR_WITH_CODE",236      payload: { code: VALID_CODE },237    });238    expect(container.textContent).toContain("Connecting…");239  });240241  it("Connect stays disabled until the full pairing code is entered", () => {242    const { container } = mount();243    gotoCodeEntry(container);244    inputText(codeInput(container), "A74H48J3");245    expect(buttonByText(container, "Connect")!.disabled).toBe(true);246    inputText(codeInput(container), VALID_CODE);247    expect(buttonByText(container, "Connect")!.disabled).toBe(false);248  });249250  it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {251    const { container } = mount();252    gotoCodeEntry(container);253    inputText(codeInput(container), VALID_CODE);254    buttonByText(container, "Connect")!.click();255    await Promise.resolve();256257    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });258259    expect(container.textContent).toContain("Confirm this code matches on your phone:");260    expect(container.textContent).toContain("895245");261    expect(buttonByText(container, "Confirm")).toBeTruthy();262    expect(buttonByText(container, "Cancel")).toBeTruthy();263  });264265  it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {266    const { container } = mount();267    gotoCodeEntry(container);268    inputText(codeInput(container), VALID_CODE);269    buttonByText(container, "Connect")!.click();270    await Promise.resolve();271    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });272273    buttonByText(container, "Confirm")!.click();274    await Promise.resolve();275    expect(mockSendMessage).toHaveBeenCalledWith({276      type: "CODE_PAIR_CONFIRM",277      payload: { confirmed: true },278    });279  });280281  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {282    const { container } = mount();283    gotoCodeEntry(container);284    inputText(codeInput(container), VALID_CODE);285    buttonByText(container, "Connect")!.click();286    await Promise.resolve();287    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });288289    buttonByText(container, "Cancel")!.click();290    await Promise.resolve();291    expect(mockSendMessage).toHaveBeenCalledWith({292      type: "CODE_PAIR_CONFIRM",293      payload: { confirmed: false },294    });295    expect(container.textContent).toContain("Codes don't match");296  });297298  it("shows the combined 'not found or expired' copy on invalid_code", async () => {299    const { container } = mount();300    gotoCodeEntry(container);301    inputText(codeInput(container), VALID_CODE);302    buttonByText(container, "Connect")!.click();303    await Promise.resolve();304305    // Wrong vs expired is indistinguishable on the wire; the background306    // collapses both to invalid_code via a socket close.307    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });308309    expect(container.textContent).toContain("Code not found or expired");310    expect(buttonByText(container, "Try again")).toBeTruthy();311  });312313  it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {314    const { container } = mount();315    gotoCodeEntry(container);316    inputText(codeInput(container), VALID_CODE);317    buttonByText(container, "Connect")!.click();318    await Promise.resolve();319    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });320    expect(container.textContent).toContain("Couldn't reach your phone");321  });322323  it("renders Paired! and the device name on a complete result", async () => {324    const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 };325    const { container } = mount();326    gotoCodeEntry(container);327    inputText(codeInput(container), VALID_CODE);328    buttonByText(container, "Connect")!.click();329    await Promise.resolve();330    pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });331    buttonByText(container, "Confirm")!.click();332    await Promise.resolve();333334    pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });335    expect(container.textContent).toContain("Paired!");336    expect(container.textContent).toContain("Connected to June's Pixel");337  });338339  it("auto-dismisses back to settings after the paired state settles", async () => {340    vi.useFakeTimers();341    try {342      const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 };343      const onBack = vi.fn();344      const { container } = mount(onBack);345      gotoCodeEntry(container);346      inputText(codeInput(container), VALID_CODE);347      buttonByText(container, "Connect")!.click();348      await Promise.resolve();349      pushFromBackground("CODE_PAIR_SAS", { sas: "895245" });350      buttonByText(container, "Confirm")!.click();351      await Promise.resolve();352      pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });353      expect(container.textContent).toContain("Paired!");354      vi.advanceTimersByTime(2000);355      // returns to the Settings sync hub, which owns the device list now356      expect(onBack).toHaveBeenCalled();357    } finally {358      vi.useRealTimers();359    }360  });361362  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {363    startResponse = { ok: false, error: "vault_locked" };364    const { container } = mount();365    gotoCodeEntry(container);366    inputText(codeInput(container), VALID_CODE);367    buttonByText(container, "Connect")!.click();368    await Promise.resolve();369    await Promise.resolve();370    expect(container.textContent).toContain("Unlock Peach first");371  });372});