PairDevice.test.tsx

7.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";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-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2021import { PairDeviceView } from "./PairDevice";22import { renderView, inputText } 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 === "ADOPTION_SOURCE_CONFIRM") {43      return Promise.resolve({ ok: true, data: { valid: true } });44    }45    if (msg?.type === "GET_SYNC_SETTINGS" || msg?.type === "SET_SYNC_SETTINGS") {46      return Promise.resolve({47        ok: true,48        data: { mode: "public", relayUrl: "wss://relay.peachpasswords.com" },49      });50    }51    return Promise.resolve({ ok: true });52  });53  (globalThis as unknown as { chrome: unknown }).chrome = {54    runtime: {55      onMessage: {56        addListener: (fn: RuntimeListener) => { listeners.push(fn); },57        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },58      },59    },60  };61});6263describe("PairDevice — relay adoption source", () => {64  const cleanups: Array<() => void> = [];65  afterEach(() => {66    while (cleanups.length) cleanups.pop()!();67  });6869  function mount() {70    const rendered = renderView(71      <PairDeviceView72        isPro={true}73        onBack={() => {}}74        onLearnPro={() => {}}75      />,76    );77    cleanups.push(rendered.dispose);78    return rendered;79  }8081  it("opens the relay room before rendering its QR, and offers no manual copy", async () => {82    const { container } = mount();83    await vi.waitFor(() => {84      expect(container.textContent).toContain("Scan this in Peach");85    });8687    expect(mockSendMessage).toHaveBeenCalledWith({88      type: "START_ADOPTION_SOURCE",89    });90    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();91    // The QR is the default — the text form is behind an explicit fallback.92    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();93  });9495  it("offers the same invitation as text when a camera won't cooperate", async () => {96    const { container } = mount();97    await vi.waitFor(() => {98      expect(container.textContent).toContain("Scan this in Peach");99    });100101    const toggle = () =>102      [...container.querySelectorAll<HTMLButtonElement>("button")].find((b) =>103        /Show a code instead|Show the QR instead/.test(b.textContent ?? ""),104      )!;105    toggle().click();106107    const text = container.querySelector<HTMLTextAreaElement>("#pairing-invitation-manual");108    expect(text?.value).toBe(SOURCE_INVITATION);109    // What the code contains is disclosed where the user can see the string.110    expect(container.textContent).toContain("single-use token");111    // One invitation, two renderings — never two live codes on screen.112    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();113114    toggle().click();115    expect(container.querySelector("#pairing-invitation-manual")).toBeNull();116    expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();117  });118119  // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step.120  it("names the relay in force without making it a decision", async () => {121    const { container } = mount();122    await vi.waitFor(() => {123      expect(container.textContent).toContain("Scan this in Peach");124    });125126    const summary = container.querySelector(".peach-sync-mode-summary");127    expect(summary?.textContent).toContain("Using Public relay");128    // Quiet means quiet: no selector on screen until asked for.129    expect(container.querySelector(".peach-sync-mode")).toBeNull();130131    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();132    await vi.waitFor(() => {133      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);134    });135    // The expanded selector is taller than the popup, so the QR folds away.136    expect(container.querySelector("svg.peach-pair-qr")).toBeNull();137  });138139  it("rebuilds the invitation when the relay changes, since the code carries it", async () => {140    const { container } = mount();141    await vi.waitFor(() => {142      expect(container.textContent).toContain("Scan this in Peach");143    });144    const opened = () =>145      mockSendMessage.mock.calls.filter(146        ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE",147      ).length;148    expect(opened()).toBe(1);149150    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();151    await vi.waitFor(() => {152      expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);153    });154    const noSync = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(155      (b) => b.textContent?.includes("No sync"),156    )!;157    noSync.click();158159    await vi.waitFor(() => {160      expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" });161      expect(opened()).toBe(2);162    });163  });164165  it("requires the matching SAS and master password before sharing", async () => {166    const { container } = mount();167    await vi.waitFor(() => {168      expect(container.textContent).toContain("Scan this in Peach");169    });170171    pushFromBackground("ADOPTION_SOURCE_SAS", {172      sas: "48219371",173      receiverDeviceName: "June's phone",174    });175    expect(container.textContent).toContain("June's phone");176    expect(container.textContent).toContain("48219371");177178    inputText(179      container.querySelector("#adoption-consent-password") as HTMLInputElement,180      "correct horse battery staple",181    );182    const approve = Array.from(container.querySelectorAll("button")).find(183      (button) => button.textContent?.includes("Share vault"),184    ) as HTMLButtonElement;185    approve.click();186    await vi.waitFor(() => {187      expect(mockSendMessage).toHaveBeenCalledWith({188        type: "ADOPTION_SOURCE_CONFIRM",189        payload: { password: "correct horse battery staple" },190      });191    });192    await vi.waitFor(() => {193      expect(container.textContent).toContain("Waiting for the new device");194    });195  });196});