ChirpSync.desktop.test.tsx

10.9 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 as icon cards on the method-select screen", () => {91    const { container } = mount();92    gotoMethodSelect(container);93    expect(container.textContent).toContain("How do you want to pair?");94    // The redundant body copy was removed — the cards explain themselves.95    expect(container.textContent).not.toContain("In the same room? Use a chirp");9697    const sameRoom = buttonContaining(container, "Same room")!;98    const desktop = buttonContaining(container, "Desktop helper")!;99    expect(sameRoom).toBeTruthy();100    expect(desktop).toBeTruthy();101    // Each card carries its own description and a leading icon.102    expect(sameRoom.textContent).toContain("Chirp — plays an inaudible sound");103    expect(desktop.textContent).toContain("Enter the code shown by peach-sync");104    expect(sameRoom.querySelector(".peach-pair-method-icon")).toBeTruthy();105    expect(desktop.querySelector(".peach-pair-method-icon")).toBeTruthy();106  });107108  it("renders a 6-digit numeric code input with birds present", () => {109    const { container } = mount();110    gotoDesktopEnter(container);111    const input = codeInput(container);112    expect(input).toBeTruthy();113    expect(input.getAttribute("inputmode")).toBe("numeric");114    expect(input.getAttribute("maxlength")).toBe("6");115    // birds always animate on the desktop screens (design correction)116    expect(container.querySelector(".peach-chirp-bird-desktop")).toBeTruthy();117    // non-digits are stripped and the value is capped at 6 chars118    inputText(input, "12ab3456789");119    expect(input.value).toBe("123456");120  });121122  it("Connect sends PAIR_WITH_DESKTOP with the typed code, then searches", async () => {123    const { container } = mount();124    gotoDesktopEnter(container);125    inputText(codeInput(container), "428913");126    const connect = buttonByText(container, "Connect")!;127    expect(connect.disabled).toBe(false);128    connect.click();129    await Promise.resolve();130    expect(mockSendMessage).toHaveBeenCalledWith({131      type: "PAIR_WITH_DESKTOP",132      payload: { code: "428913" },133    });134    expect(container.textContent).toContain("Searching for helper…");135  });136137  it("Connect stays disabled until 6 digits are entered", () => {138    const { container } = mount();139    gotoDesktopEnter(container);140    inputText(codeInput(container), "123");141    expect(buttonByText(container, "Connect")!.disabled).toBe(true);142    inputText(codeInput(container), "123456");143    expect(buttonByText(container, "Connect")!.disabled).toBe(false);144  });145146  it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => {147    const { container } = mount();148    gotoDesktopEnter(container);149    inputText(codeInput(container), "428913");150    buttonByText(container, "Connect")!.click();151    await Promise.resolve();152153    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });154155    expect(container.textContent).toContain("Confirm this code matches on your computer:");156    expect(container.textContent).toContain("895245");157    expect(buttonByText(container, "Confirm")).toBeTruthy();158    expect(buttonByText(container, "Cancel")).toBeTruthy();159  });160161  it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => {162    const { container } = mount();163    gotoDesktopEnter(container);164    inputText(codeInput(container), "428913");165    buttonByText(container, "Connect")!.click();166    await Promise.resolve();167    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });168169    buttonByText(container, "Confirm")!.click();170    await Promise.resolve();171    expect(mockSendMessage).toHaveBeenCalledWith({172      type: "DESKTOP_PAIR_CONFIRM",173      payload: { confirmed: true },174    });175  });176177  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {178    const { container } = mount();179    gotoDesktopEnter(container);180    inputText(codeInput(container), "428913");181    buttonByText(container, "Connect")!.click();182    await Promise.resolve();183    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });184185    buttonByText(container, "Cancel")!.click();186    await Promise.resolve();187    expect(mockSendMessage).toHaveBeenCalledWith({188      type: "DESKTOP_PAIR_CONFIRM",189      payload: { confirmed: false },190    });191    expect(container.textContent).toContain("Codes don't match");192  });193194  it("shows the combined 'not found or expired' copy on invalid_code", async () => {195    const { container } = mount();196    gotoDesktopEnter(container);197    inputText(codeInput(container), "000000");198    buttonByText(container, "Connect")!.click();199    await Promise.resolve();200201    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth202    // §0.3) — the background collapses both to invalid_code via a socket close.203    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" });204205    expect(container.textContent).toContain("Code not found or expired");206    expect(buttonByText(container, "Try again")).toBeTruthy();207  });208209  it("shows the 'no computer found' copy on no_host_found", async () => {210    const { container } = mount();211    gotoDesktopEnter(container);212    inputText(codeInput(container), "111111");213    buttonByText(container, "Connect")!.click();214    await Promise.resolve();215    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });216    expect(container.textContent).toContain("Couldn't find your computer");217  });218219  it("renders Paired! and the device name on a complete result", async () => {220    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: 1700000000000 };221    const { container } = mount();222    gotoDesktopEnter(container);223    inputText(codeInput(container), "428913");224    buttonByText(container, "Connect")!.click();225    await Promise.resolve();226    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });227    buttonByText(container, "Confirm")!.click();228    await Promise.resolve();229230    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });231    expect(container.textContent).toContain("Paired!");232    expect(container.textContent).toContain("Connected to June's iMac");233  });234235  it("auto-dismisses to the device list after the paired state settles", async () => {236    vi.useFakeTimers();237    try {238      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };239      const { container } = mount();240      gotoDesktopEnter(container);241      inputText(codeInput(container), "428913");242      buttonByText(container, "Connect")!.click();243      await Promise.resolve();244      pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });245      buttonByText(container, "Confirm")!.click();246      await Promise.resolve();247      pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });248      expect(container.textContent).toContain("Paired!");249      vi.advanceTimersByTime(2000);250      // returns to the rest surface (the "Pair a device" hero is back)251      expect(container.textContent).not.toContain("Paired!");252      expect(buttonByText(container, "Pair a device")).toBeTruthy();253    } finally {254      vi.useRealTimers();255    }256  });257258  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {259    startResponse = { ok: false, error: "vault_locked" };260    const { container } = mount();261    gotoDesktopEnter(container);262    inputText(codeInput(container), "428913");263    buttonByText(container, "Connect")!.click();264    await Promise.resolve();265    await Promise.resolve();266    expect(container.textContent).toContain("Unlock Peach first");267  });268});