// @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-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9"; import { PairDeviceView } from "./PairDevice"; import { renderView, inputText } 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 === "ADOPTION_SOURCE_CONFIRM") { return Promise.resolve({ ok: true, data: { valid: true } }); } 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() { const rendered = renderView( {}} onLearnPro={() => {}} />, ); cleanups.push(rendered.dispose); return rendered; } it("opens the relay room before rendering its QR, and offers no manual copy", async () => { const { container } = mount(); await vi.waitFor(() => { expect(container.textContent).toContain("Scan this in Peach"); }); expect(mockSendMessage).toHaveBeenCalledWith({ type: "START_ADOPTION_SOURCE", }); expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy(); // The invitation is carried by the QR alone — the manual textarea and its // Copy button duplicated the pairing-code path and were removed. expect(container.querySelector("#pairing-invitation-manual")).toBeNull(); expect(container.textContent).not.toContain("or copy the invitation"); expect(container.textContent).toContain("single-use token"); }); it("requires the matching SAS and master password before sharing", async () => { const { container } = mount(); await vi.waitFor(() => { expect(container.textContent).toContain("Scan this in Peach"); }); pushFromBackground("ADOPTION_SOURCE_SAS", { sas: "48219371", receiverDeviceName: "June's phone", }); expect(container.textContent).toContain("June's phone"); expect(container.textContent).toContain("48219371"); inputText( container.querySelector("#adoption-consent-password") as HTMLInputElement, "correct horse battery staple", ); const approve = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("Share vault"), ) as HTMLButtonElement; approve.click(); await vi.waitFor(() => { expect(mockSendMessage).toHaveBeenCalledWith({ type: "ADOPTION_SOURCE_CONFIRM", payload: { password: "correct horse battery staple" }, }); }); await vi.waitFor(() => { expect(container.textContent).toContain("Waiting for the new device"); }); }); });