// @vitest-environment jsdom // // PairDeviceView is relay-only: the extension always DISPLAYS an invitation for // another device to scan. LAN pairing codes went with LAN sync // (docs/sync-lan-retirement.md), so there is no code-entry mode to test. // // chrome messaging is mocked the way the popup uses it: // - shared/bridge `sendMessage` is mocked (popup → bg) // - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can // fire ADOPTION_SOURCE_* pushes (bg → popup) import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --- mock the background bridge (popup → bg) ------------------------------- const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() })); vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage })); const SOURCE_INVITATION = "peach-pair-v2:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9"; import { PairDeviceView } from "./PairDevice"; import { renderView } from "./view-test-utils"; // --- fake chrome.runtime push channel (bg → popup) ------------------------ type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void; let listeners: RuntimeListener[] = []; function pushFromBackground(type: string, payload: unknown): void { for (const l of [...listeners]) l({ type, payload }); } beforeEach(() => { listeners = []; mockSendMessage.mockReset(); mockSendMessage.mockImplementation((msg: { type?: string }) => { if (msg?.type === "START_ADOPTION_SOURCE") { return Promise.resolve({ ok: true, data: { invitation: SOURCE_INVITATION }, }); } if (msg?.type === "GET_SYNC_SETTINGS" || msg?.type === "SET_SYNC_SETTINGS") { return Promise.resolve({ ok: true, data: { mode: "public", relayUrl: "wss://relay.peachpasswords.com" }, }); } return Promise.resolve({ ok: true }); }); (globalThis as unknown as { chrome: unknown }).chrome = { runtime: { onMessage: { addListener: (fn: RuntimeListener) => { listeners.push(fn); }, removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); }, }, }, }; }); describe("PairDevice — relay adoption source", () => { const cleanups: Array<() => void> = []; afterEach(() => { while (cleanups.length) cleanups.pop()!(); }); function mount(onLearnPro: () => void = () => {}) { const rendered = renderView( {}} onLearnPro={onLearnPro} />, ); cleanups.push(rendered.dispose); return rendered; } it("opens the relay room before rendering one source QR", async () => { const { container } = mount(); await vi.waitFor(() => { expect(container.textContent).toContain("This extension has the vault"); }); expect(mockSendMessage).toHaveBeenCalledWith({ type: "START_ADOPTION_SOURCE", }); expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy(); expect(container.querySelector("#pairing-invitation-manual")).toBeNull(); expect(container.textContent).not.toContain("Show a code instead"); expect(container.textContent).not.toContain("peach-pair-v2:"); }); // Moment 1 (docs/sync-relay-surfacing.md): present, but never a step. it("names the relay in force without making it a decision", async () => { const { container } = mount(); await vi.waitFor(() => { expect(container.textContent).toContain("This extension has the vault"); }); const summary = container.querySelector(".peach-sync-mode-summary"); expect(summary?.textContent).toContain("Using Public relay"); // Quiet means quiet: no selector on screen until asked for. expect(container.querySelector(".peach-sync-mode")).toBeNull(); container.querySelector(".peach-sync-mode-change")!.click(); await vi.waitFor(() => { expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3); }); // The expanded selector is taller than the popup, so the QR folds away. expect(container.querySelector("svg.peach-pair-qr")).toBeNull(); }); it("rebuilds the invitation when the relay changes, since the code carries it", async () => { const { container } = mount(); await vi.waitFor(() => { expect(container.textContent).toContain("This extension has the vault"); }); const opened = () => mockSendMessage.mock.calls.filter( ([m]) => (m as { type?: string })?.type === "START_ADOPTION_SOURCE", ).length; expect(opened()).toBe(1); container.querySelector(".peach-sync-mode-change")!.click(); await vi.waitFor(() => { expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3); }); const noSync = [...container.querySelectorAll("button.peach-sync-mode")].find( (b) => b.textContent?.includes("No sync"), )!; noSync.click(); await vi.waitFor(() => { expect(mockSendMessage).toHaveBeenCalledWith({ type: "CANCEL_ADOPTION_SOURCE" }); expect(opened()).toBe(2); }); }); it("shows the SAS and waits for the popup's single confirmation", async () => { const { container } = mount(); await vi.waitFor(() => { expect(container.textContent).toContain("This extension has the vault"); }); pushFromBackground("ADOPTION_SOURCE_SAS", { id: "test-sas-id", sas: "48219371", receiverDeviceName: "June's phone", }); expect(container.textContent).toContain("June's phone"); expect(container.textContent).toContain("48219371"); expect(container.textContent).toContain("Compare the 8-digit safety code"); expect(container.querySelector("#adoption-consent-password")).toBeNull(); // The pop-up must NOT auto-approve; it waits for the user to tap a button. expect(mockSendMessage).not.toHaveBeenCalledWith( expect.objectContaining({ type: "SOURCE_SAS_DECISION" }), ); const matchButton = [...container.querySelectorAll("button")] .find((b) => b.textContent?.trim() === "They match"); expect(matchButton).toBeTruthy(); const rejectButton = [...container.querySelectorAll("button")] .find((b) => b.textContent?.trim() === "They don't match"); expect(rejectButton).toBeTruthy(); matchButton!.click(); await vi.waitFor(() => { expect(mockSendMessage).toHaveBeenCalledWith({ type: "SOURCE_SAS_DECISION", payload: { id: "test-sas-id", ok: true }, }); }); // After the user answers, the background completes the rest of the protocol // and pushes the terminal result. pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "complete" }); expect(container.textContent).toContain("Device added"); expect(container.querySelectorAll(".peach-pair-bird-pose-happy")).toHaveLength(2); }); it("shows the Peach Pro upgrade path when neither pairing peer is Pro", async () => { const onLearnPro = vi.fn(); const { container } = mount(onLearnPro); await vi.waitFor(() => { expect(container.textContent).toContain("This extension has the vault"); }); pushFromBackground("ADOPTION_SOURCE_RESULT", { status: "error", error: "ADOPT_PRO_REQUIRED", }); expect(container.textContent).toContain("Pair freely"); expect(container.textContent).toContain("at least one of these two devices"); expect(container.textContent).toContain("$39 once"); const openPro = [...container.querySelectorAll("button")] .find((button) => button.textContent?.trim() === "Open Peach Pro"); expect(openPro).toBeTruthy(); openPro!.click(); expect(onLearnPro).toHaveBeenCalledOnce(); }); });