PairDevice.test.tsx

7.7 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-v2: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(onLearnPro: () => void = () => {}) {67    const rendered = renderView(68      <PairDeviceView69        isPro={true}70        onBack={() => {}}71        onLearnPro={onLearnPro}72      />,73    );74    cleanups.push(rendered.dispose);75    return rendered;76  }7778  it("opens the relay room before rendering one source QR", async () => {79    const { container } = mount();80    await vi.waitFor(() => {81      expect(container.textContent).toContain("This extension has the vault");82    });8384    expect(mockSendMessage).toHaveBeenCalledWith({85      type: "START_ADOPTION_SOURCE",86    });87    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();88    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();89    expect(container.textContent).not.toContain("Show a code instead");90    expect(container.textContent).not.toContain("peach-pair-v2:");91  });9293  // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.94  it("names the relay in force without making it a decision", async () => {95    const { container } = mount();96    await vi.waitFor(() => {97      expect(container.textContent).toContain("This extension has the vault");98    });99100    const summary = container.querySelector(".peach-sync-mode-summary");101    expect(summary?.textContent).toContain("Using Public relay");102    // Quiet means quiet: no selector on screen until asked for.103    expect(container.querySelector(".peach-sync-mode")).toBeNull();104105    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();106    await vi.waitFor(() => {107      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);108    });109    // The expanded selector is taller than the popup, so the QR folds away.110    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();111  });112113  it("rebuilds the invitation when the relay changes, since the code carries it", async () => {114    const { container } = mount();115    await vi.waitFor(() => {116      expect(container.textContent).toContain("This extension has the vault");117    });118    const opened = () =>119      mockSendMessage.mock.calls.filter(120        ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",121      ).length;122    expect(opened()).toBe(1);123124    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();125    await vi.waitFor(() => {126      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);127    });128    const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(129      (b) => b.textContent?.includes("No sync"),130    )!;131    noSync.click();132133    await vi.waitFor(() => {134      expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });135      expect(opened()).toBe(2);136    });137  });138139  it("shows the SAS and waits for the popup's single confirmation", async () => {140    const { container } = mount();141    await vi.waitFor(() => {142      expect(container.textContent).toContain("This extension has the vault");143    });144145    pushFromBackground("ADOPTION_SOURCE_SAS", {146      id: "test-sas-id",147      sas: "48219371",148      receiverDeviceName: "June's phone",149    });150    expect(container.textContent).toContain("June's phone");151    expect(container.textContent).toContain("48219371");152    expect(container.textContent).toContain("Compare the 8-digit safety code");153    expect(container.querySelector("#adoption-consent-password")).toBeNull();154    // The pop-up must NOT auto-approve; it waits for the user to tap a button.155    expect(mockSendMessage).not.toHaveBeenCalledWith(156      expect.objectContaining({ type: "SOURCE_SAS_DECISION" }),157    );158    const matchButton = [...container.querySelectorAll<HTMLButtonElement>("button")]159      .find((b) => b.textContent?.trim() === "They match");160    expect(matchButton).toBeTruthy();161    const rejectButton = [...container.querySelectorAll<HTMLButtonElement>("button")]162      .find((b) => b.textContent?.trim() === "They don't match");163    expect(rejectButton).toBeTruthy();164165    matchButton!.click();166    await vi.waitFor(() => {167      expect(mockSendMessage).toHaveBeenCalledWith({168        type: "SOURCE_SAS_DECISION",169        payload: { id: "test-sas-id", ok: true },170      });171    });172173    // After the user answers, the background completes the rest of the protocol174    // and pushes the terminal result.175    pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });176    expect(container.textContent).toContain("Device added");177    expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);178  });179180  it("shows the Peach Pro upgrade path when neither pairing peer is Pro", async () => {181    const onLearnPro = vi.fn();182    const { container } = mount(onLearnPro);183    await vi.waitFor(() => {184      expect(container.textContent).toContain("This extension has the vault");185    });186187    pushFromBackground("ADOPTION_SOURCE_RESULT", {188      status: "error",189      error: "ADOPT_PRO_REQUIRED",190    });191192    expect(container.textContent).toContain("Pair freely");193    expect(container.textContent).toContain("at least one of these two devices");194    expect(container.textContent).toContain("$39 once");195    const openPro = [...container.querySelectorAll<HTMLButtonElement>("button")]196      .find((button) => button.textContent?.trim() === "Open Peach Pro");197    expect(openPro).toBeTruthy();198    openPro!.click();199    expect(onLearnPro).toHaveBeenCalledOnce();200  });201});