PairDevice.test.tsx

8.3 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("reserves exactly the bird height without shrinking or adding dead space", () => {80    const css = readFileSync("src/popup/styles/popup.css", "utf8");81    expect(css).toMatch(/\.peach-pair-stage\s*\{[^}]*flex:\s*0 0 108px/s);82    expect(css).toMatch(/\.peach-pair-stage\s*\{[^}]*height:\s*108px/s);83  });8485  it("opens the relay room before rendering one source QR", async () => {86    const { container } = mount();87    await vi.waitFor(() => {88      expect(container.textContent).toContain("This extension has the vault");89    });9091    expect(mockSendMessage).toHaveBeenCalledWith({92      type: "START_ADOPTION_SOURCE",93    });94    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();95    expect(container.querySelector("svg.peach-pair-qr")?.getAttribute("width")).toBe("224");96    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();97    expect(container.textContent).not.toContain("Show a code instead");98    expect(container.textContent).not.toContain("peach-pair-v2:");99  });100101  // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.102  it("names the relay in force without making it a decision", async () => {103    const { container } = mount();104    await vi.waitFor(() => {105      expect(container.textContent).toContain("This extension has the vault");106    });107108    const summary = container.querySelector(".peach-sync-mode-summary");109    expect(summary?.textContent).toContain("Using Public relay");110    // Quiet means quiet: no selector on screen until asked for.111    expect(container.querySelector(".peach-sync-mode")).toBeNull();112113    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();114    await vi.waitFor(() => {115      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);116    });117    // The expanded selector is taller than the popup, so the QR folds away.118    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();119  });120121  it("rebuilds the invitation when the relay changes, since the code carries it", async () => {122    const { container } = mount();123    await vi.waitFor(() => {124      expect(container.textContent).toContain("This extension has the vault");125    });126    const opened = () =>127      mockSendMessage.mock.calls.filter(128        ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",129      ).length;130    expect(opened()).toBe(1);131132    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();133    await vi.waitFor(() => {134      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);135    });136    const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(137      (b) => b.textContent?.includes("No sync"),138    )!;139    noSync.click();140141    await vi.waitFor(() => {142      expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });143      expect(opened()).toBe(2);144    });145  });146147  it("shows the SAS and waits for the popup's single confirmation", async () => {148    const { container } = mount();149    await vi.waitFor(() => {150      expect(container.textContent).toContain("This extension has the vault");151    });152153    pushFromBackground("ADOPTION_SOURCE_SAS", {154      id: "test-sas-id",155      sas: "48219371",156      receiverDeviceName: "June's phone",157    });158    expect(container.textContent).toContain("June's phone");159    expect(container.textContent).toContain("48219371");160    expect(container.textContent).toContain("Compare the 8-digit safety code");161    expect(container.textContent).toContain("complete copy of this vault");162    expect(container.textContent).not.toContain("Session ID");163    expect(container.querySelector("#adoption-consent-password")).toBeNull();164    // The pop-up must NOT auto-approve; it waits for the user to tap a button.165    expect(mockSendMessage).not.toHaveBeenCalledWith(166      expect.objectContaining({ type: "SOURCE_SAS_DECISION" }),167    );168    const matchButton = [...container.querySelectorAll<HTMLButtonElement>("button")]169      .find((b) => b.textContent?.trim() === "They match");170    expect(matchButton).toBeTruthy();171    const rejectButton = [...container.querySelectorAll<HTMLButtonElement>("button")]172      .find((b) => b.textContent?.trim() === "They don't match");173    expect(rejectButton).toBeTruthy();174175    matchButton!.click();176    await vi.waitFor(() => {177      expect(mockSendMessage).toHaveBeenCalledWith({178        type: "SOURCE_SAS_DECISION",179        payload: { id: "test-sas-id", ok: true },180      });181    });182183    // After the user answers, the background completes the rest of the protocol184    // and pushes the terminal result.185    pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });186    expect(container.textContent).toContain("Device added");187    expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);188  });189190  it("shows the Peach Pro upgrade path when neither pairing peer is Pro", async () => {191    const onLearnPro = vi.fn();192    const { container } = mount(onLearnPro);193    await vi.waitFor(() => {194      expect(container.textContent).toContain("This extension has the vault");195    });196197    pushFromBackground("ADOPTION_SOURCE_RESULT", {198      status: "error",199      error: "ADOPT_PRO_REQUIRED",200    });201202    expect(container.textContent).toContain("Pair freely");203    expect(container.textContent).toContain("at least one of these two devices");204    expect(container.textContent).toContain("$39 once");205    const openPro = [...container.querySelectorAll<HTMLButtonElement>("button")]206      .find((button) => button.textContent?.trim() === "Open Peach Pro");207    expect(openPro).toBeTruthy();208    openPro!.click();209    expect(onLearnPro).toHaveBeenCalledOnce();210  });211});