PairDevice.test.tsx

14.2 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 offers no manual copy", 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    // The invitation is carried by the QR alone — the manual textarea and its111    // Copy button duplicated the pairing-code path and were removed.112    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();113    expect(container.textContent).not.toContain("or copy the invitation");114    expect(container.textContent).toContain("single-use random token");115  });116117  it("requires the matching SAS and master password before sharing", async () => {118    const { container } = mount();119    await vi.waitFor(() => {120      expect(container.textContent).toContain("Scan this in Peach");121    });122123    pushFromBackground("ADOPTION_SOURCE_SAS", {124      sas: "48219371",125      receiverDeviceName: "June's phone",126    });127    expect(container.textContent).toContain("June's phone");128    expect(container.textContent).toContain("48219371");129130    inputText(131      container.querySelector("#adoption-consent-password") as HTMLInputElement,132      "correct horse battery staple",133    );134    const approve = Array.from(container.querySelectorAll("button")).find(135      (button) => button.textContent?.includes("Share vault"),136    ) as HTMLButtonElement;137    approve.click();138    await vi.waitFor(() => {139      expect(mockSendMessage).toHaveBeenCalledWith({140        type: "ADOPTION_SOURCE_CONFIRM",141        payload: { password: "correct horse battery staple" },142      });143    });144    await vi.waitFor(() => {145      expect(container.textContent).toContain("Waiting for the new device");146    });147  });148});149150describe("PairDevice — code pairing", () => {151  const VALID_CODE = "A74H-48J3-KI32-OEP4";152  const cleanups: Array<() => void> = [];153  afterEach(() => {154    while (cleanups.length) cleanups.pop()!();155  });156157  // PairDevice is pairing-only; it opens straight into the code-entry flow158  // (the device list / method choice live on the Settings sync card).159  function mount(onBack: () => void = () => {}) {160    const r = renderView(161      <PairDeviceView isPro={true} initialPair="code" onBack={onBack} onLearnPro={() => {}} />,162    );163    cleanups.push(r.dispose);164    return r;165  }166167  function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {168    return Array.from(container.querySelectorAll("button")).find(169      (b) => (b.textContent ?? "").trim() === text,170    ) as HTMLButtonElement | undefined;171  }172  function codeInput(container: HTMLElement): HTMLInputElement {173    return container.querySelector("#pair-code") as HTMLInputElement;174  }175176  // initialPair="code" lands directly on the code-entry screen.177  function gotoCodeEntry(_container: HTMLElement): void {178    /* no-op: mount() already opens the code-entry flow */179  }180181  it("opens straight into the code-entry flow (no method-select screen)", () => {182    const { container } = mount();183    expect(container.textContent).not.toContain("How do you want to pair?");184    // The code input is present immediately, without any intermediate choice.185    expect(codeInput(container)).toBeTruthy();186    expect(buttonByText(container, "Connect")).toBeTruthy();187  });188189  it("renders a code input sized for a full pairing code, with birds present", () => {190    const { container } = mount();191    gotoCodeEntry(container);192    const input = codeInput(container);193    expect(input).toBeTruthy();194    // Letters are part of the code now, so the field must not be numeric-only.195    expect(input.getAttribute("inputmode")).toBeNull();196    expect(input.getAttribute("maxlength")).toBe("32");197    // birds always animate on the code screens (design correction)198    expect(container.querySelector(".peach-pair-bird-desktop")).toBeTruthy();199  });200201  it("uppercases and regroups a pairing code as it is typed", () => {202    const { container } = mount();203    gotoCodeEntry(container);204    const input = codeInput(container);205    inputText(input, "a74h48j3ki32oep4");206    expect(input.value).toBe("A74H-48J3-KI32-OEP4");207    expect(buttonByText(container, "Connect")!.disabled).toBe(false);208  });209210  it("groups partial input and strips unusable characters", () => {211    const { container } = mount();212    gotoCodeEntry(container);213    const input = codeInput(container);214    inputText(input, "12_34/56");215    expect(input.value).toBe("1234-56");216    expect(buttonByText(container, "Connect")!.disabled).toBe(true);217  });218219  it("keeps Connect disabled for input that is neither code form", () => {220    const { container } = mount();221    gotoCodeEntry(container);222    // Long enough to be grouped, but too short to carry a token.223    inputText(codeInput(container), "A74H48J3");224    expect(buttonByText(container, "Connect")!.disabled).toBe(true);225  });226227  it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {228    const { container } = mount();229    gotoCodeEntry(container);230    inputText(codeInput(container), VALID_CODE);231    const connect = buttonByText(container, "Connect")!;232    expect(connect.disabled).toBe(false);233    connect.click();234    await Promise.resolve();235    expect(mockSendMessage).toHaveBeenCalledWith({236      type: "PAIR_WITH_CODE",237      payload: { code: VALID_CODE },238    });239    expect(container.textContent).toContain("Connecting…");240  });241242  it("Connect stays disabled until the full pairing code is entered", () => {243    const { container } = mount();244    gotoCodeEntry(container);245    inputText(codeInput(container), "A74H48J3");246    expect(buttonByText(container, "Connect")!.disabled).toBe(true);247    inputText(codeInput(container), VALID_CODE);248    expect(buttonByText(container, "Connect")!.disabled).toBe(false);249  });250251  it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {252    const { container } = mount();253    gotoCodeEntry(container);254    inputText(codeInput(container), VALID_CODE);255    buttonByText(container, "Connect")!.click();256    await Promise.resolve();257258    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });259260    expect(container.textContent).toContain("Confirm this code matches on your phone:");261    expect(container.textContent).toContain("89524517");262    expect(buttonByText(container, "Confirm")).toBeTruthy();263    expect(buttonByText(container, "Cancel")).toBeTruthy();264  });265266  it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {267    const { container } = mount();268    gotoCodeEntry(container);269    inputText(codeInput(container), VALID_CODE);270    buttonByText(container, "Connect")!.click();271    await Promise.resolve();272    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });273274    buttonByText(container, "Confirm")!.click();275    await Promise.resolve();276    expect(mockSendMessage).toHaveBeenCalledWith({277      type: "CODE_PAIR_CONFIRM",278      payload: { confirmed: true },279    });280  });281282  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {283    const { container } = mount();284    gotoCodeEntry(container);285    inputText(codeInput(container), VALID_CODE);286    buttonByText(container, "Connect")!.click();287    await Promise.resolve();288    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });289290    buttonByText(container, "Cancel")!.click();291    await Promise.resolve();292    expect(mockSendMessage).toHaveBeenCalledWith({293      type: "CODE_PAIR_CONFIRM",294      payload: { confirmed: false },295    });296    expect(container.textContent).toContain("Codes don't match");297  });298299  it("shows the combined 'not found or expired' copy on invalid_code", async () => {300    const { container } = mount();301    gotoCodeEntry(container);302    inputText(codeInput(container), VALID_CODE);303    buttonByText(container, "Connect")!.click();304    await Promise.resolve();305306    // Wrong vs expired is indistinguishable on the wire; the background307    // collapses both to invalid_code via a socket close.308    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });309310    expect(container.textContent).toContain("Code not found or expired");311    expect(buttonByText(container, "Try again")).toBeTruthy();312  });313314  it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {315    const { container } = mount();316    gotoCodeEntry(container);317    inputText(codeInput(container), VALID_CODE);318    buttonByText(container, "Connect")!.click();319    await Promise.resolve();320    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });321    expect(container.textContent).toContain("Couldn't reach your phone");322  });323324  it("renders Paired! and the device name on a complete result", async () => {325    const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 };326    const { container } = mount();327    gotoCodeEntry(container);328    inputText(codeInput(container), VALID_CODE);329    buttonByText(container, "Connect")!.click();330    await Promise.resolve();331    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });332    buttonByText(container, "Confirm")!.click();333    await Promise.resolve();334335    pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });336    expect(container.textContent).toContain("Paired!");337    expect(container.textContent).toContain("Connected to June's Pixel");338  });339340  it("auto-dismisses back to settings after the paired state settles", async () => {341    vi.useFakeTimers();342    try {343      const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 };344      const onBack = vi.fn();345      const { container } = mount(onBack);346      gotoCodeEntry(container);347      inputText(codeInput(container), VALID_CODE);348      buttonByText(container, "Connect")!.click();349      await Promise.resolve();350      pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });351      buttonByText(container, "Confirm")!.click();352      await Promise.resolve();353      pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });354      expect(container.textContent).toContain("Paired!");355      vi.advanceTimersByTime(2000);356      // returns to the Settings sync hub, which owns the device list now357      expect(onBack).toHaveBeenCalled();358    } finally {359      vi.useRealTimers();360    }361  });362363  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {364    startResponse = { ok: false, error: "vault_locked" };365    const { container } = mount();366    gotoCodeEntry(container);367    inputText(codeInput(container), VALID_CODE);368    buttonByText(container, "Connect")!.click();369    await Promise.resolve();370    await Promise.resolve();371    expect(container.textContent).toContain("Unlock Peach first");372  });373});