PairDevice.test.tsx

7.9 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("svg.peach-pair-qr")?.getAttribute("width")).toBe("224");89    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();90    expect(container.textContent).not.toContain("Show a code instead");91    expect(container.textContent).not.toContain("peach-pair-v2:");92  });9394  // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.95  it("names the relay in force without making it a decision", async () => {96    const { container } = mount();97    await vi.waitFor(() => {98      expect(container.textContent).toContain("This extension has the vault");99    });100101    const summary = container.querySelector(".peach-sync-mode-summary");102    expect(summary?.textContent).toContain("Using Public relay");103    // Quiet means quiet: no selector on screen until asked for.104    expect(container.querySelector(".peach-sync-mode")).toBeNull();105106    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();107    await vi.waitFor(() => {108      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);109    });110    // The expanded selector is taller than the popup, so the QR folds away.111    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();112  });113114  it("rebuilds the invitation when the relay changes, since the code carries it", async () => {115    const { container } = mount();116    await vi.waitFor(() => {117      expect(container.textContent).toContain("This extension has the vault");118    });119    const opened = () =>120      mockSendMessage.mock.calls.filter(121        ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",122      ).length;123    expect(opened()).toBe(1);124125    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();126    await vi.waitFor(() => {127      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);128    });129    const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(130      (b) => b.textContent?.includes("No sync"),131    )!;132    noSync.click();133134    await vi.waitFor(() => {135      expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });136      expect(opened()).toBe(2);137    });138  });139140  it("shows the SAS and waits for the popup's single confirmation", async () => {141    const { container } = mount();142    await vi.waitFor(() => {143      expect(container.textContent).toContain("This extension has the vault");144    });145146    pushFromBackground("ADOPTION_SOURCE_SAS", {147      id: "test-sas-id",148      sas: "48219371",149      receiverDeviceName: "June's phone",150    });151    expect(container.textContent).toContain("June's phone");152    expect(container.textContent).toContain("48219371");153    expect(container.textContent).toContain("Compare the 8-digit safety code");154    expect(container.textContent).toContain("complete copy of this vault");155    expect(container.textContent).not.toContain("Session ID");156    expect(container.querySelector("#adoption-consent-password")).toBeNull();157    // The pop-up must NOT auto-approve; it waits for the user to tap a button.158    expect(mockSendMessage).not.toHaveBeenCalledWith(159      expect.objectContaining({ type: "SOURCE_SAS_DECISION" }),160    );161    const matchButton = [...container.querySelectorAll<HTMLButtonElement>("button")]162      .find((b) => b.textContent?.trim() === "They match");163    expect(matchButton).toBeTruthy();164    const rejectButton = [...container.querySelectorAll<HTMLButtonElement>("button")]165      .find((b) => b.textContent?.trim() === "They don't match");166    expect(rejectButton).toBeTruthy();167168    matchButton!.click();169    await vi.waitFor(() => {170      expect(mockSendMessage).toHaveBeenCalledWith({171        type: "SOURCE_SAS_DECISION",172        payload: { id: "test-sas-id", ok: true },173      });174    });175176    // After the user answers, the background completes the rest of the protocol177    // and pushes the terminal result.178    pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" });179    expect(container.textContent).toContain("Device added");180    expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2);181  });182183  it("shows the Peach Pro upgrade path when neither pairing peer is Pro", async () => {184    const onLearnPro = vi.fn();185    const { container } = mount(onLearnPro);186    await vi.waitFor(() => {187      expect(container.textContent).toContain("This extension has the vault");188    });189190    pushFromBackground("ADOPTION_SOURCE_RESULT", {191      status: "error",192      error: "ADOPT_PRO_REQUIRED",193    });194195    expect(container.textContent).toContain("Pair freely");196    expect(container.textContent).toContain("at least one of these two devices");197    expect(container.textContent).toContain("$39 once");198    const openPro = [...container.querySelectorAll<HTMLButtonElement>("button")]199      .find((button) => button.textContent?.trim() === "Open Peach Pro");200    expect(openPro).toBeTruthy();201    openPro!.click();202    expect(onLearnPro).toHaveBeenCalledOnce();203  });204});