// @vitest-environment jsdom // // Code pairing, exercised through the integrated PairDeviceView state machine. // Code pairing is a MODE inside ChirpSync (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 both code forms: the pairing code a phone displays // (pairing-code.ts) and the legacy 6-digit desktop-helper code. // // 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 }; 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 }; mockSendMessage.mockReset(); mockSendMessage.mockImplementation(async (msg: { type?: string }) => { if (msg?.type === "PAIR_WITH_CODE") return startResponse; return { 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("ChirpSync — code pairing", () => { const cleanups: Array<() => void> = []; afterEach(() => { while (cleanups.length) cleanups.pop()!(); }); // ChirpSync is pairing-only now; it opens straight into the desktop code 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 gotoDesktopEnter(_container: HTMLElement): void { /* no-op: mount() already opens the desktop code flow */ } it("opens straight into the desktop code 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(); gotoDesktopEnter(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-chirp-bird-desktop")).toBeTruthy(); }); it("uppercases and regroups a pairing code as it is typed", () => { const { container } = mount(); gotoDesktopEnter(container); const input = codeInput(container); inputText(input, "a74h48j3ki32oep4"); expect(input.value).toBe("A74H-48J3-KI32-OEP4"); expect(buttonByText(container, "Connect")!.disabled).toBe(false); }); it("leaves a short legacy code ungrouped and strips unusable characters", () => { const { container } = mount(); gotoDesktopEnter(container); const input = codeInput(container); inputText(input, "12_34/56"); expect(input.value).toBe("123456"); }); it("keeps Connect disabled for input that is neither code form", () => { const { container } = mount(); gotoDesktopEnter(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(); gotoDesktopEnter(container); inputText(codeInput(container), "428913"); const connect = buttonByText(container, "Connect")!; expect(connect.disabled).toBe(false); connect.click(); await Promise.resolve(); expect(mockSendMessage).toHaveBeenCalledWith({ type: "PAIR_WITH_CODE", payload: { code: "428913" }, }); expect(container.textContent).toContain("Connecting…"); }); it("Connect stays disabled until 6 digits are entered", () => { const { container } = mount(); gotoDesktopEnter(container); inputText(codeInput(container), "123"); expect(buttonByText(container, "Connect")!.disabled).toBe(true); inputText(codeInput(container), "123456"); expect(buttonByText(container, "Connect")!.disabled).toBe(false); }); it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => { const { container } = mount(); gotoDesktopEnter(container); inputText(codeInput(container), "428913"); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "895245" }); expect(container.textContent).toContain("Confirm this code matches on your phone:"); expect(container.textContent).toContain("895245"); expect(buttonByText(container, "Confirm")).toBeTruthy(); expect(buttonByText(container, "Cancel")).toBeTruthy(); }); it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => { const { container } = mount(); gotoDesktopEnter(container); inputText(codeInput(container), "428913"); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "895245" }); 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(); gotoDesktopEnter(container); inputText(codeInput(container), "428913"); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "895245" }); 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(); gotoDesktopEnter(container); inputText(codeInput(container), "000000"); buttonByText(container, "Connect")!.click(); await Promise.resolve(); // peach-sync cannot distinguish wrong vs expired on the wire (ground truth // §0.3) — 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(); gotoDesktopEnter(container); inputText(codeInput(container), "111111"); 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 iMac", deviceType: "desktop", pairedAt: 1700000000000 }; const { container } = mount(); gotoDesktopEnter(container); inputText(codeInput(container), "428913"); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "895245" }); 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 iMac"); }); it("auto-dismisses back to settings after the paired state settles", async () => { vi.useFakeTimers(); try { const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 }; const onBack = vi.fn(); const { container } = mount(onBack); gotoDesktopEnter(container); inputText(codeInput(container), "428913"); buttonByText(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("CODE_PAIR_SAS", { sas: "895245" }); 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(); gotoDesktopEnter(container); inputText(codeInput(container), "428913"); buttonByText(container, "Connect")!.click(); await Promise.resolve(); await Promise.resolve(); expect(container.textContent).toContain("Unlock Peach first"); }); });