PairDevice.test.tsx

8.2 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";13import { readFileSync } from "node:fs";1415// --- mock the background bridge (popup → bg) -------------------------------16const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));17vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));1819const SOURCE_INVITATION =20  "peach-pair-v2:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2122import { PairDeviceView } from "./PairDevice";23import { renderView } from "./view-test-utils";2425// --- fake chrome.runtime push channel (bg → popup) ------------------------26type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;27let listeners: RuntimeListener[] = [];2829function pushFromBackground(type: string, payload: unknown): void {30  for (const l of [...listeners]) l({ type, payload });31}3233beforeEach(() => {34  listeners = [];35  mockSendMessage.mockReset();36  mockSendMessage.mockImplementation((msg: { type?: string }) => {37    if (msg?.type === "START_ADOPTION_SOURCE") {38      return Promise.resolve({39        ok: true,40        data: { invitation: SOURCE_INVITATION },41      });42    }43    if (msg?.type === "GET_SYNC_SETTINGS" || msg?.type === "SET_SYNC_SETTINGS") {44      return Promise.resolve({45        ok: true,46        data: { mode: "public", relayUrl: "wss://relay.peachpasswords.com" },47      });48    }49    return Promise.resolve({ ok: true });50  });51  (globalThis as unknown as { chrome: unknown }).chrome = {52    runtime: {53      onMessage: {54        addListener: (fn: RuntimeListener) => { listeners.push(fn); },55        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },56      },57    },58  };59});6061describe("PairDevice — relay adoption source", () => {62  const cleanups: Array<() => void> = [];63  afterEach(() => {64    while (cleanups.length) cleanups.pop()!();65  });6667  function mount(onLearnPro: () => void = () => {}) {68    const rendered = renderView(69      <PairDeviceView70        isPro={true}71        onBack={() => {}}72        onLearnPro={onLearnPro}73      />,74    );75    cleanups.push(rendered.dispose);76    return rendered;77  }7879  it("keeps the bird stage from shrinking into the dialog header", () => {80    const css = readFileSync("src/popup/styles/popup.css", "utf8");81    expect(css).toMatch(/\.peach-pair-stage\s*\{[^}]*flex-shrink:\s*0/s);82  });8384  it("opens the relay room before rendering one source QR", async () => {85    const { container } = mount();86    await vi.waitFor(() => {87      expect(container.textContent).toContain("This extension has the vault");88    });8990    expect(mockSendMessage).toHaveBeenCalledWith({91      type: "START_ADOPTION_SOURCE",92    });93    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();94    expect(container.querySelector("svg.peach-pair-qr")?.getAttribute("width")).toBe("224");95    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();96    expect(container.textContent).not.toContain("Show a code instead");97    expect(container.textContent).not.toContain("peach-pair-v2:");98  });99100  // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.101  it("names the relay in force without making it a decision", async () => {102    const { container } = mount();103    await vi.waitFor(() => {104      expect(container.textContent).toContain("This extension has the vault");105    });106107    const summary = container.querySelector(".peach-sync-mode-summary");108    expect(summary?.textContent).toContain("Using Public relay");109    // Quiet means quiet: no selector on screen until asked for.110    expect(container.querySelector(".peach-sync-mode")).toBeNull();111112    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();113    await vi.waitFor(() => {114      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);115    });116    // The expanded selector is taller than the popup, so the QR folds away.117    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();118  });119120  it("rebuilds the invitation when the relay changes, since the code carries it", async () => {121    const { container } = mount();122    await vi.waitFor(() => {123      expect(container.textContent).toContain("This extension has the vault");124    });125    const opened = () =>126      mockSendMessage.mock.calls.filter(127        ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",128      ).length;129    expect(opened()).toBe(1);130131    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();132    await vi.waitFor(() => {133      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);134    });135    const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(136      (b) => b.textContent?.includes("No sync"),137    )!;138    noSync.click();139140    await vi.waitFor(() => {141      expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });142      expect(opened()).toBe(2);143    });144  });145146  it("shows the SAS and waits for the popup's single confirmation", async () => {147    const { container } = mount();148    await vi.waitFor(() => {149      expect(container.textContent).toContain("This extension has the vault");150    });151152    pushFromBackground("ADOPTION_SOURCE_SAS", {153      id: "test-sas-id",154      sas: "48219371",155      receiverDeviceName: "June's phone",156    });157    expect(container.textContent).toContain("June's phone");158    expect(container.textContent).toContain("48219371");159    expect(container.textContent).toContain("Compare the 8-digit safety code");160    expect(container.textContent).toContain("complete copy of this vault");161    expect(container.textContent).not.toContain("Session ID");162    expect(container.querySelector("#adoption-consent-password")).toBeNull();163    // The pop-up must NOT auto-approve; it waits for the user to tap a button.164    expect(mockSendMessage).not.toHaveBeenCalledWith(165      expect.objectContaining({ type: "SOURCE_SAS_DECISION" }),166    );167    const matchButton = [...container.querySelectorAll<HTMLButtonElement>("button")]168      .find((b) => b.textContent?.trim() === "They match");169    expect(matchButton).toBeTruthy();170    const rejectButton = [...container.querySelectorAll<HTMLButtonElement>("button")]171      .find((b) => b.textContent?.trim() === "They don't match");172    expect(rejectButton).toBeTruthy();173174    matchButton!.click();175    await vi.waitFor(() => {176      expect(mockSendMessage).toHaveBeenCalledWith({177        type: "SOURCE_SAS_DECISION",178        payload: { id: "test-sas-id", ok: true },179      });180    });181182    // After the user answers, the background completes the rest of the protocol183    // and pushes the terminal result.184    pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });185    expect(container.textContent).toContain("Device added");186    expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);187  });188189  it("shows the Peach Pro upgrade path when neither pairing peer is Pro", async () => {190    const onLearnPro = vi.fn();191    const { container } = mount(onLearnPro);192    await vi.waitFor(() => {193      expect(container.textContent).toContain("This extension has the vault");194    });195196    pushFromBackground("ADOPTION_SOURCE_RESULT", {197      status: "error",198      error: "ADOPT_PRO_REQUIRED",199    });200201    expect(container.textContent).toContain("Pair freely");202    expect(container.textContent).toContain("at least one of these two devices");203    expect(container.textContent).toContain("$39 once");204    const openPro = [...container.querySelectorAll<HTMLButtonElement>("button")]205      .find((button) => button.textContent?.trim() === "Open Peach Pro");206    expect(openPro).toBeTruthy();207    openPro!.click();208    expect(onLearnPro).toHaveBeenCalledOnce();209  });210});