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.textContent).not.toContain("Pair a device");95    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();96    expect(container.querySelector("svg.peach-pair-qr")?.getAttribute("width")).toBe("224");97    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();98    expect(container.textContent).not.toContain("Show a code instead");99    expect(container.textContent).not.toContain("peach-pair-v2:");100  });101102  // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.103  it("names the relay in force without making it a decision", async () => {104    const { container } = mount();105    await vi.waitFor(() => {106      expect(container.textContent).toContain("This extension has the vault");107    });108109    const summary = container.querySelector(".peach-sync-mode-summary");110    expect(summary?.textContent).toContain("Using Public relay");111    // Quiet means quiet: no selector on screen until asked for.112    expect(container.querySelector(".peach-sync-mode")).toBeNull();113114    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();115    await vi.waitFor(() => {116      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);117    });118    // The expanded selector is taller than the popup, so the QR folds away.119    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();120  });121122  it("rebuilds the invitation when the relay changes, since the code carries it", async () => {123    const { container } = mount();124    await vi.waitFor(() => {125      expect(container.textContent).toContain("This extension has the vault");126    });127    const opened = () =>128      mockSendMessage.mock.calls.filter(129        ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",130      ).length;131    expect(opened()).toBe(1);132133    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();134    await vi.waitFor(() => {135      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);136    });137    const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(138      (b) => b.textContent?.includes("No sync"),139    )!;140    noSync.click();141142    await vi.waitFor(() => {143      expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });144      expect(opened()).toBe(2);145    });146  });147148  it("shows the SAS and waits for the popup's single confirmation", async () => {149    const { container } = mount();150    await vi.waitFor(() => {151      expect(container.textContent).toContain("This extension has the vault");152    });153154    pushFromBackground("ADOPTION_SOURCE_SAS", {155      id: "test-sas-id",156      sas: "48219371",157      receiverDeviceName: "June's phone",158    });159    expect(container.textContent).toContain("June's phone");160    expect(container.textContent).toContain("48219371");161    expect(container.textContent).toContain("Compare the 8-digit safety code");162    expect(container.textContent).toContain("complete copy of this vault");163    expect(container.textContent).not.toContain("Session ID");164    expect(container.querySelector("#adoption-consent-password")).toBeNull();165    // The pop-up must NOT auto-approve; it waits for the user to tap a button.166    expect(mockSendMessage).not.toHaveBeenCalledWith(167      expect.objectContaining({ type: "SOURCE_SAS_DECISION" }),168    );169    const matchButton = [...container.querySelectorAll<HTMLButtonElement>("button")]170      .find((b) => b.textContent?.trim() === "They match");171    expect(matchButton).toBeTruthy();172    const rejectButton = [...container.querySelectorAll<HTMLButtonElement>("button")]173      .find((b) => b.textContent?.trim() === "They don't match");174    expect(rejectButton).toBeTruthy();175176    matchButton!.click();177    await vi.waitFor(() => {178      expect(mockSendMessage).toHaveBeenCalledWith({179        type: "SOURCE_SAS_DECISION",180        payload: { id: "test-sas-id", ok: true },181      });182    });183184    // After the user answers, the background completes the rest of the protocol185    // and pushes the terminal result.186    pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });187    expect(container.textContent).toContain("Device added");188    expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);189  });190191  it("shows the Peach Pro upgrade path when neither pairing peer is Pro", async () => {192    const onLearnPro = vi.fn();193    const { container } = mount(onLearnPro);194    await vi.waitFor(() => {195      expect(container.textContent).toContain("This extension has the vault");196    });197198    pushFromBackground("ADOPTION_SOURCE_RESULT", {199      status: "error",200      error: "ADOPT_PRO_REQUIRED",201    });202203    expect(container.textContent).toContain("Pair freely");204    expect(container.textContent).toContain("at least one of these two devices");205    expect(container.textContent).toContain("$39 once");206    const openPro = [...container.querySelectorAll<HTMLButtonElement>("button")]207      .find((button) => button.textContent?.trim() === "Open Peach Pro");208    expect(openPro).toBeTruthy();209    openPro!.click();210    expect(onLearnPro).toHaveBeenCalledOnce();211  });212});