PairDevice.test.tsx

7.0 KB · raw

1// @vitest-environment jsdom2//3// PairDeviceView is relay-only: the extension always DISPLAYS an invitation for4// another device to scan. LAN pairing codes went with LAN sync5// (docs/sync-lan-retirement.md), so there is no code-entry mode to test.6//7// chrome messaging is mocked the way the popup uses it:8//   - shared/bridge `sendMessage` is mocked (popup → bg)9//   - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can10//     fire ADOPTION_SOURCE_* pushes (bg → popup)1112import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";1314// --- mock the background bridge (popup → bg) -------------------------------15const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));16vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));1718const SOURCE_INVITATION =19  "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2021import { PairDeviceView } from "./PairDevice";22import { renderView } from "./view-test-utils";2324// --- fake chrome.runtime push channel (bg → popup) ------------------------25type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;26let listeners: RuntimeListener[] = [];2728function pushFromBackground(type: string, payload: unknown): void {29  for (const l of [...listeners]) l({ type, payload });30}3132beforeEach(() => {33  listeners = [];34  mockSendMessage.mockReset();35  mockSendMessage.mockImplementation((msg: { type?: string }) => {36    if (msg?.type === "START_ADOPTION_SOURCE") {37      return Promise.resolve({38        ok: true,39        data: { invitation: SOURCE_INVITATION },40      });41    }42    if (msg?.type === "GET_SYNC_SETTINGS" || msg?.type === "SET_SYNC_SETTINGS") {43      return Promise.resolve({44        ok: true,45        data: { mode: "public", relayUrl: "wss://relay.peachpasswords.com" },46      });47    }48    return Promise.resolve({ ok: true });49  });50  (globalThis as unknown as { chrome: unknown }).chrome = {51    runtime: {52      onMessage: {53        addListener: (fn: RuntimeListener) => { listeners.push(fn); },54        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },55      },56    },57  };58});5960describe("PairDevice — relay adoption source", () => {61  const cleanups: Array<() => void> = [];62  afterEach(() => {63    while (cleanups.length) cleanups.pop()!();64  });6566  function mount() {67    const rendered = renderView(68      <PairDeviceView69        isPro={true}70        onBack={() => {}}71        onLearnPro={() => {}}72      />,73    );74    cleanups.push(rendered.dispose);75    return rendered;76  }7778  it("opens the relay room before rendering its QR, and offers no manual copy", async () => {79    const { container } = mount();80    await vi.waitFor(() => {81      expect(container.textContent).toContain("Scan this in Peach");82    });8384    expect(mockSendMessage).toHaveBeenCalledWith({85      type: "START_ADOPTION_SOURCE",86    });87    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();88    // The QR is the default — the text form is behind an explicit fallback.89    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();90  });9192  it("offers the same invitation as text when a camera won't cooperate", async () => {93    const { container } = mount();94    await vi.waitFor(() => {95      expect(container.textContent).toContain("Scan this in Peach");96    });9798    const toggle = () =>99      [...container.querySelectorAll<HTMLButtonElement>("button")].find((b) =>100        /Show a code instead|Show the QR instead/.test(b.textContent ?? ""),101      )!;102    toggle().click();103104    const text = container.querySelector<HTMLTextAreaElement>("#pairing-invitation-manual");105    expect(text?.value).toBe(SOURCE_INVITATION);106    // What the code contains is disclosed where the user can see the string.107    expect(container.textContent).toContain("single-use token");108    // One invitation, two renderings — never two live codes on screen.109    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();110111    toggle().click();112    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();113    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();114  });115116  // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.117  it("names the relay in force without making it a decision", async () => {118    const { container } = mount();119    await vi.waitFor(() => {120      expect(container.textContent).toContain("Scan this in Peach");121    });122123    const summary = container.querySelector(".peach-sync-mode-summary");124    expect(summary?.textContent).toContain("Using Public relay");125    // Quiet means quiet: no selector on screen until asked for.126    expect(container.querySelector(".peach-sync-mode")).toBeNull();127128    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();129    await vi.waitFor(() => {130      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);131    });132    // The expanded selector is taller than the popup, so the QR folds away.133    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();134  });135136  it("rebuilds the invitation when the relay changes, since the code carries it", async () => {137    const { container } = mount();138    await vi.waitFor(() => {139      expect(container.textContent).toContain("Scan this in Peach");140    });141    const opened = () =>142      mockSendMessage.mock.calls.filter(143        ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",144      ).length;145    expect(opened()).toBe(1);146147    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();148    await vi.waitFor(() => {149      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);150    });151    const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(152      (b) => b.textContent?.includes("No sync"),153    )!;154    noSync.click();155156    await vi.waitFor(() => {157      expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });158      expect(opened()).toBe(2);159    });160  });161162  it("shows the SAS and waits for the phone's single confirmation", async () => {163    const { container } = mount();164    await vi.waitFor(() => {165      expect(container.textContent).toContain("Scan this in Peach");166    });167168    pushFromBackground("ADOPTION_SOURCE_SAS", {169      sas: "48219371",170      receiverDeviceName: "June's phone",171    });172    expect(container.textContent).toContain("June's phone");173    expect(container.textContent).toContain("48219371");174    expect(container.textContent).toContain("tap “They match” there");175    expect(container.querySelector("#adoption-consent-password")).toBeNull();176    expect(mockSendMessage).not.toHaveBeenCalledWith(177      expect.objectContaining({ type: "ADOPTION_SOURCE_CONFIRM" }),178    );179180    pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });181    expect(container.textContent).toContain("Device added");182    expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);183  });184});