// @vitest-environment jsdom // // DRAFT spec for DesktopPairingFlow (Change 4). Apply-ready — do NOT run from // the drafts folder; on apply it lives next to the component as // src/popup/components/DesktopPairingFlow.test.tsx, and the import paths below // are written for THAT location (./DesktopPairingFlow, ../views/view-test-utils, // ../../shared/* ). // // Mirrors the repo's existing component-test idiom (ChirpPairingFlow.test.tsx): // - @vitest-environment jsdom // - renderView / inputText from view-test-utils (NOT @solidjs/testing-library) // - raw DOM queries + .click() // // chrome messaging is mocked exactly the way the popup talks to it: // - shared/bridge `sendMessage` is mocked (popup -> bg) // - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can // fire DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes (bg -> popup) import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // --- mock the background bridge (popup -> bg) ------------------------------- const sendMessage = vi.fn(async (_msg: unknown) => ({ ok: true }) as { ok: boolean; error?: string }); vi.mock("../../shared/bridge", () => ({ sendMessage })); import { DesktopPairingFlow } from "./DesktopPairingFlow"; import type { PairedDevice } from "../../shared/sync-types"; import { renderView, inputText } from "../views/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 { // Solid effects/handlers run synchronously; the listener registered in // onMount updates the signals immediately when invoked. for (const l of [...listeners]) l({ type, payload }); } beforeEach(() => { listeners = []; sendMessage.mockClear(); sendMessage.mockResolvedValue({ 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("DesktopPairingFlow", () => { const cleanups: Array<() => void> = []; afterEach(() => { while (cleanups.length) cleanups.pop()!(); }); function mount(handlers: { onCancel?: () => void; onDone?: (d?: PairedDevice) => void } = {}) { const r = renderView( {})} onDone={handlers.onDone ?? (() => {})} />, ); cleanups.push(r.dispose); return r; } function codeInput(container: HTMLElement): HTMLInputElement { return container.querySelector("#desktop-pair-code") as HTMLInputElement; } function button(container: HTMLElement, label: string): HTMLButtonElement | undefined { return Array.from(container.querySelectorAll("button")).find((b) => b.textContent === label) as | HTMLButtonElement | undefined; } it("renders a 6-digit numeric code input", () => { const { container } = mount(); const input = codeInput(container); expect(input).toBeTruthy(); expect(input.getAttribute("inputmode")).toBe("numeric"); expect(input.getAttribute("maxlength")).toBe("6"); // non-digits are stripped and the value is capped at 6 chars inputText(input, "12ab3456789"); expect(input.value).toBe("123456"); }); it("Connect sends PAIR_WITH_DESKTOP with the typed code", async () => { const { container } = mount(); inputText(codeInput(container), "428913"); const connect = button(container, "Connect")!; expect(connect.disabled).toBe(false); connect.click(); await Promise.resolve(); expect(sendMessage).toHaveBeenCalledWith({ type: "PAIR_WITH_DESKTOP", payload: { code: "428913" }, }); // moves into the searching state expect(container.textContent).toContain("Searching for helper…"); }); it("Connect stays disabled until 6 digits are entered", () => { const { container } = mount(); inputText(codeInput(container), "123"); expect(button(container, "Connect")!.disabled).toBe(true); inputText(codeInput(container), "123456"); expect(button(container, "Connect")!.disabled).toBe(false); }); it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => { const { container } = mount(); inputText(codeInput(container), "428913"); button(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" }); expect(container.textContent).toContain("Confirm this code matches on your computer:"); expect(container.textContent).toContain("895245"); expect(button(container, "Confirm")).toBeTruthy(); expect(button(container, "Cancel")).toBeTruthy(); }); it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => { const { container } = mount(); inputText(codeInput(container), "428913"); button(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" }); button(container, "Confirm")!.click(); await Promise.resolve(); expect(sendMessage).toHaveBeenCalledWith({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: true }, }); }); it("Cancel on the SAS gate sends DESKTOP_PAIR_CONFIRM { confirmed: false } and shows mismatch copy", async () => { const { container } = mount(); inputText(codeInput(container), "428913"); button(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" }); button(container, "Cancel")!.click(); await Promise.resolve(); expect(sendMessage).toHaveBeenCalledWith({ type: "DESKTOP_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 (socket close)", async () => { const { container } = mount(); inputText(codeInput(container), "000000"); button(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("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" }); expect(container.textContent).toContain("Code not found or expired"); expect(button(container, "Try again")).toBeTruthy(); }); it("shows the 'no computer found' copy on no_host_found", async () => { const { container } = mount(); inputText(codeInput(container), "111111"); button(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" }); expect(container.textContent).toContain("Couldn't find your computer"); }); it("renders Paired! and the device name on a complete result", async () => { const onDone = vi.fn(); const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: Date.now() }; const { container } = mount({ onDone }); inputText(codeInput(container), "428913"); button(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" }); button(container, "Confirm")!.click(); await Promise.resolve(); pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device }); expect(container.textContent).toContain("Paired!"); expect(container.textContent).toContain("Connected to June's iMac"); }); it("auto-dismisses after the paired state settles", async () => { vi.useFakeTimers(); try { const onDone = vi.fn(); const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 }; const { container } = mount({ onDone }); inputText(codeInput(container), "428913"); button(container, "Connect")!.click(); await Promise.resolve(); pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" }); button(container, "Confirm")!.click(); await Promise.resolve(); pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device }); vi.advanceTimersByTime(2000); expect(onDone).toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it("surfaces a synchronous start failure as an error phase", async () => { sendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" }); const { container } = mount(); inputText(codeInput(container), "428913"); button(container, "Connect")!.click(); await Promise.resolve(); await Promise.resolve(); expect(container.textContent).toContain("Unlock Peach first"); }); });