ChirpSync.desktop.test.tsx

10.3 KB · raw

1// @vitest-environment jsdom2//3// Desktop helper code pairing (Change 4), exercised through the integrated4// ChirpSyncView state machine. Per the brief's "UI Design Correction", desktop5// pairing is a MODE inside ChirpSync (birds always animate), not a separate6// component — so these specs navigate: rest → method-select → desktop enter →7// connecting → SAS → paired/error, asserting the popup↔background message8// contract at each step.9//10// chrome messaging is mocked the way the popup uses it:11//   - shared/bridge `sendMessage` is mocked (popup → bg)12//   - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can13//     fire DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes (bg → popup)1415import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";1617// --- mock the background bridge (popup → bg) -------------------------------18const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));19vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));2021// Mutated per-test to script the PAIR_WITH_DESKTOP start response.22let startResponse: { ok: boolean; error?: string } = { ok: true };2324import { ChirpSyncView } from "./ChirpSync";25import type { PairedDevice } from "../../shared/sync-types";26import { renderView, inputText } from "./view-test-utils";2728// --- fake chrome.runtime push channel (bg → popup) ------------------------29type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;30let listeners: RuntimeListener[] = [];3132function pushFromBackground(type: string, payload: unknown): void {33  for (const l of [...listeners]) l({ type, payload });34}3536beforeEach(() => {37  listeners = [];38  startResponse = { ok: true };39  mockSendMessage.mockReset();40  mockSendMessage.mockImplementation(async (msg: { type?: string }) => {41    if (msg?.type === "PAIR_WITH_DESKTOP") return startResponse;42    return { ok: true };43  });44  (globalThis as unknown as { chrome: unknown }).chrome = {45    runtime: {46      onMessage: {47        addListener: (fn: RuntimeListener) => { listeners.push(fn); },48        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },49      },50    },51  };52});5354describe("ChirpSync — desktop helper pairing", () => {55  const cleanups: Array<() => void> = [];56  afterEach(() => {57    while (cleanups.length) cleanups.pop()!();58  });5960  function mount() {61    const r = renderView(62      <ChirpSyncView isPro={true} onBack={() => {}} onLearnPro={() => {}} />,63    );64    cleanups.push(r.dispose);65    return r;66  }6768  function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {69    return Array.from(container.querySelectorAll("button")).find(70      (b) => (b.textContent ?? "").trim() === text,71    ) as HTMLButtonElement | undefined;72  }73  function buttonContaining(container: HTMLElement, text: string): HTMLButtonElement | undefined {74    return Array.from(container.querySelectorAll("button")).find(75      (b) => (b.textContent ?? "").includes(text),76    ) as HTMLButtonElement | undefined;77  }78  function codeInput(container: HTMLElement): HTMLInputElement {79    return container.querySelector("#desktop-pair-code") as HTMLInputElement;80  }8182  function gotoMethodSelect(container: HTMLElement): void {83    buttonByText(container, "Pair a device")!.click();84  }85  function gotoDesktopEnter(container: HTMLElement): void {86    gotoMethodSelect(container);87    buttonContaining(container, "Desktop helper")!.click();88  }8990  it("offers both pairing methods on the method-select screen", () => {91    const { container } = mount();92    gotoMethodSelect(container);93    expect(container.textContent).toContain("How do you want to pair?");94    expect(buttonContaining(container, "Same room")).toBeTruthy();95    expect(buttonContaining(container, "Desktop helper")).toBeTruthy();96  });9798  it("renders a 6-digit numeric code input with birds present", () => {99    const { container } = mount();100    gotoDesktopEnter(container);101    const input = codeInput(container);102    expect(input).toBeTruthy();103    expect(input.getAttribute("inputmode")).toBe("numeric");104    expect(input.getAttribute("maxlength")).toBe("6");105    // birds always animate on the desktop screens (design correction)106    expect(container.querySelector(".peach-chirp-bird-desktop")).toBeTruthy();107    // non-digits are stripped and the value is capped at 6 chars108    inputText(input, "12ab3456789");109    expect(input.value).toBe("123456");110  });111112  it("Connect sends PAIR_WITH_DESKTOP with the typed code, then searches", async () => {113    const { container } = mount();114    gotoDesktopEnter(container);115    inputText(codeInput(container), "428913");116    const connect = buttonByText(container, "Connect")!;117    expect(connect.disabled).toBe(false);118    connect.click();119    await Promise.resolve();120    expect(mockSendMessage).toHaveBeenCalledWith({121      type: "PAIR_WITH_DESKTOP",122      payload: { code: "428913" },123    });124    expect(container.textContent).toContain("Searching for helper…");125  });126127  it("Connect stays disabled until 6 digits are entered", () => {128    const { container } = mount();129    gotoDesktopEnter(container);130    inputText(codeInput(container), "123");131    expect(buttonByText(container, "Connect")!.disabled).toBe(true);132    inputText(codeInput(container), "123456");133    expect(buttonByText(container, "Connect")!.disabled).toBe(false);134  });135136  it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => {137    const { container } = mount();138    gotoDesktopEnter(container);139    inputText(codeInput(container), "428913");140    buttonByText(container, "Connect")!.click();141    await Promise.resolve();142143    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });144145    expect(container.textContent).toContain("Confirm this code matches on your computer:");146    expect(container.textContent).toContain("895245");147    expect(buttonByText(container, "Confirm")).toBeTruthy();148    expect(buttonByText(container, "Cancel")).toBeTruthy();149  });150151  it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => {152    const { container } = mount();153    gotoDesktopEnter(container);154    inputText(codeInput(container), "428913");155    buttonByText(container, "Connect")!.click();156    await Promise.resolve();157    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });158159    buttonByText(container, "Confirm")!.click();160    await Promise.resolve();161    expect(mockSendMessage).toHaveBeenCalledWith({162      type: "DESKTOP_PAIR_CONFIRM",163      payload: { confirmed: true },164    });165  });166167  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {168    const { container } = mount();169    gotoDesktopEnter(container);170    inputText(codeInput(container), "428913");171    buttonByText(container, "Connect")!.click();172    await Promise.resolve();173    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });174175    buttonByText(container, "Cancel")!.click();176    await Promise.resolve();177    expect(mockSendMessage).toHaveBeenCalledWith({178      type: "DESKTOP_PAIR_CONFIRM",179      payload: { confirmed: false },180    });181    expect(container.textContent).toContain("Codes don't match");182  });183184  it("shows the combined 'not found or expired' copy on invalid_code", async () => {185    const { container } = mount();186    gotoDesktopEnter(container);187    inputText(codeInput(container), "000000");188    buttonByText(container, "Connect")!.click();189    await Promise.resolve();190191    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth192    // §0.3) — the background collapses both to invalid_code via a socket close.193    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" });194195    expect(container.textContent).toContain("Code not found or expired");196    expect(buttonByText(container, "Try again")).toBeTruthy();197  });198199  it("shows the 'no computer found' copy on no_host_found", async () => {200    const { container } = mount();201    gotoDesktopEnter(container);202    inputText(codeInput(container), "111111");203    buttonByText(container, "Connect")!.click();204    await Promise.resolve();205    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });206    expect(container.textContent).toContain("Couldn't find your computer");207  });208209  it("renders Paired! and the device name on a complete result", async () => {210    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: 1700000000000 };211    const { container } = mount();212    gotoDesktopEnter(container);213    inputText(codeInput(container), "428913");214    buttonByText(container, "Connect")!.click();215    await Promise.resolve();216    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });217    buttonByText(container, "Confirm")!.click();218    await Promise.resolve();219220    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });221    expect(container.textContent).toContain("Paired!");222    expect(container.textContent).toContain("Connected to June's iMac");223  });224225  it("auto-dismisses to the device list after the paired state settles", async () => {226    vi.useFakeTimers();227    try {228      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };229      const { container } = mount();230      gotoDesktopEnter(container);231      inputText(codeInput(container), "428913");232      buttonByText(container, "Connect")!.click();233      await Promise.resolve();234      pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });235      buttonByText(container, "Confirm")!.click();236      await Promise.resolve();237      pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });238      expect(container.textContent).toContain("Paired!");239      vi.advanceTimersByTime(2000);240      // returns to the rest surface (the "Pair a device" hero is back)241      expect(container.textContent).not.toContain("Paired!");242      expect(buttonByText(container, "Pair a device")).toBeTruthy();243    } finally {244      vi.useRealTimers();245    }246  });247248  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {249    startResponse = { ok: false, error: "vault_locked" };250    const { container } = mount();251    gotoDesktopEnter(container);252    inputText(codeInput(container), "428913");253    buttonByText(container, "Connect")!.click();254    await Promise.resolve();255    await Promise.resolve();256    expect(container.textContent).toContain("Unlock Peach first");257  });258});