// @vitest-environment jsdom // // Code pairing, exercised through the integrated PairDeviceView state machine. // Code pairing is a MODE inside PairDevice (birds always animate), not a separate // component — so these specs walk enter → connecting → SAS → paired/error, // asserting the popup↔background message contract at each step. // // The input accepts the address-bearing pairing code a phone displays. // // 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 CODE_PAIR_SAS / CODE_PAIR_RESULT 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 })); // Mutated per-test to script the PAIR_WITH_CODE start response. let startResponse: { ok: boolean; error?: string } = { ok: true }; let lanOnly = true; const SOURCE_INVITATION = "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9"; import { PairDeviceView } from "./PairDevice"; import type { PairedDevice } from "../../shared/sync-types"; 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 = []; startResponse = { ok: true }; lanOnly = true; mockSendMessage.mockReset(); mockSendMessage.mockImplementation((msg: { type?: string }) => { if (msg?.type === "GET_RELAY_SETTINGS") { // Resolve synchronously so the existing code-pairing state-machine tests // enter the explicitly configured LAN-only path before their first DOM // assertion. return { then(resolve: (value: unknown) => unknown) { return Promise.resolve(resolve({ ok: true, data: { lanOnly }, })); }, }; } 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 } }); } if (msg?.type === "PAIR_WITH_CODE") return Promise.resolve(startResponse); 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() { lanOnly = false; 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 random 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"); }); }); }); describe("PairDevice — code pairing", () => { const VALID_CODE = "A74H-48J3-KI32-OEP4"; const cleanups: Array<() => void> = []; afterEach(() => { while (cleanups.length) cleanups.pop()!(); }); // PairDevice is pairing-only; it opens straight into the code-entry flow // (the device list / method choice live on the Settings sync card). function mount(onBack: () => void = () => {}) { const r = renderView( {}} />, ); cleanups.push(r.dispose); return r; } function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined { return Array.from(container.querySelectorAll("button")).find( (b) => (b.textContent ?? "").trim() === text, ) as HTMLButtonElement | undefined; } function codeInput(container: HTMLElement): HTMLInputElement { return container.querySelector("#pair-code") as HTMLInputElement; } // initialPair="code" lands directly on the code-entry screen. function gotoCodeEntry(_container: HTMLElement): void { /* no-op: mount() already opens the code-entry flow */ } it("opens straight into the code-entry flow (no method-select screen)", () => { const { container } = mount(); expect(container.textContent).not.toContain("How do you want to pair?"); // The code input is present immediately, without any intermediate choice. expect(codeInput(container)).toBeTruthy(); expect(buttonByText(container, "Connect")).toBeTruthy(); }); it("renders a code input sized for a full pairing code, with birds present", () => { const { container } = mount(); gotoCodeEntry(container); const input = codeInput(container); expect(input).toBeTruthy(); // Letters are part of the code now, so the field must not be numeric-only. expect(input.getAttribute("inputmode")).toBeNull(); expect(input.getAttribute("maxlength")).toBe("32"); // birds always animate on the code screens (design correction) expect(container.querySelector(".peach-pair-bird-desktop")).toBeTruthy(); }); it("uppercases and regroups a pairing code as it is typed", () => { const { container } = mount(); gotoCodeEntry(container); const input = codeInput(container); inputText(input, "a74h48j3ki32oep4"); expect(input.value).toBe("A74H-48J3-KI32-OEP4"); expect(buttonByText(container, "Connect")!.disabled).toBe(false); }); it("groups partial input and strips unusable characters", () => { const { container } = mount(); gotoCodeEntry(container); const input = codeInput(container); inputText(input, "12_34/56"); expect(input.value).toBe("1234-56"); expect(buttonByText(container, "Connect")!.disabled).toBe(true); }); it("keeps Connect disabled for input that is neither code form", () => { const { container } = mount(); gotoCodeEntry(container); // Long enough to be grouped, but too short to carry a token. inputText(codeInput(container), "A74H48J3"); expect(buttonByText(container, "Connect")!.disabled).toBe(true); }); it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => { const { container } = mount(); gotoCodeEntry(container); inputText(codeInput(container), VALID_CODE); const connect = buttonByText(container, "Connect")!; expect(connect.disabled).toBe(false); connect.click(); await Promise.resolve(); expect(mockSendMessage).toHaveBeenCalledWith({ type: "PAIR_WITH_CODE", payload: { code: VALID_CODE }, }); expect(container.textContent).toContain("Connecting…"); }); it("Connect stays disabled until the full pairing code is entered", () => { const { container } = mount(); gotoCodeEntry(container); inputText(codeInput(container), "A74H48J3"); expect(buttonByText(container, "Connect")!.disabled).toBe(true); inputText(codeInput(container), VALID_CODE); expect(buttonByText(container, "Connect")!.disabled).toBe(false); }); it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => { const { container } = mount(); gotoCodeEntry(container); inputText(codeInput(container), VALID_CODE); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" }); expect(container.textContent).toContain("Confirm this code matches on your phone:"); expect(container.textContent).toContain("89524517"); expect(buttonByText(container, "Confirm")).toBeTruthy(); expect(buttonByText(container, "Cancel")).toBeTruthy(); }); it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => { const { container } = mount(); gotoCodeEntry(container); inputText(codeInput(container), VALID_CODE); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" }); buttonByText(container, "Confirm")!.click(); await Promise.resolve(); expect(mockSendMessage).toHaveBeenCalledWith({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: true }, }); }); it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => { const { container } = mount(); gotoCodeEntry(container); inputText(codeInput(container), VALID_CODE); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" }); buttonByText(container, "Cancel")!.click(); await Promise.resolve(); expect(mockSendMessage).toHaveBeenCalledWith({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false }, }); expect(container.textContent).toContain("Codes don't match"); }); it("shows the combined 'not found or expired' copy on invalid_code", async () => { const { container } = mount(); gotoCodeEntry(container); inputText(codeInput(container), VALID_CODE); buttonByText(container, "Connect")!.click(); await Promise.resolve(); // Wrong vs expired is indistinguishable on the wire; the background // collapses both to invalid_code via a socket close. pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" }); expect(container.textContent).toContain("Code not found or expired"); expect(buttonByText(container, "Try again")).toBeTruthy(); }); it("shows the 'couldn't reach your phone' copy on no_host_found", async () => { const { container } = mount(); gotoCodeEntry(container); inputText(codeInput(container), VALID_CODE); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" }); expect(container.textContent).toContain("Couldn't reach your phone"); }); it("renders Paired! and the device name on a complete result", async () => { const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 }; const { container } = mount(); gotoCodeEntry(container); inputText(codeInput(container), VALID_CODE); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" }); buttonByText(container, "Confirm")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device }); expect(container.textContent).toContain("Paired!"); expect(container.textContent).toContain("Connected to June's Pixel"); }); it("auto-dismisses back to settings after the paired state settles", async () => { vi.useFakeTimers(); try { const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 }; const onBack = vi.fn(); const { container } = mount(onBack); gotoCodeEntry(container); inputText(codeInput(container), VALID_CODE); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" }); buttonByText(container, "Confirm")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device }); expect(container.textContent).toContain("Paired!"); vi.advanceTimersByTime(2000); // returns to the Settings sync hub, which owns the device list now expect(onBack).toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it("surfaces a synchronous start failure (vault_locked) as an error", async () => { startResponse = { ok: false, error: "vault_locked" }; const { container } = mount(); gotoCodeEntry(container); inputText(codeInput(container), VALID_CODE); buttonByText(container, "Connect")!.click(); await Promise.resolve(); await Promise.resolve(); expect(container.textContent).toContain("Unlock Peach first"); }); });