ChirpSync.desktop.test.tsx

10.7 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 gotoDesktopEnter(container: HTMLElement): void {83    // No method-select screen — the rest view links straight into code entry.84    buttonContaining(container, "Enter a code")!.click();85  }8687  it("offers chirp and code as direct options on the rest screen", () => {88    const { container } = mount();89    // The intermediate "how do you want to pair?" screen is gone.90    expect(container.textContent).not.toContain("How do you want to pair?");9192    const sameRoom = buttonContaining(container, "Same room")!;93    const code = buttonContaining(container, "Enter a code")!;94    expect(sameRoom).toBeTruthy();95    expect(code).toBeTruthy();96    // Each card carries its own description and a leading icon.97    expect(code.textContent).toContain("Type the code from your desktop sync helper");98    expect(sameRoom.querySelector(".peach-pair-method-icon")).toBeTruthy();99    expect(code.querySelector(".peach-pair-method-icon")).toBeTruthy();100  });101102  it("renders a 6-digit numeric code input with birds present", () => {103    const { container } = mount();104    gotoDesktopEnter(container);105    const input = codeInput(container);106    expect(input).toBeTruthy();107    expect(input.getAttribute("inputmode")).toBe("numeric");108    expect(input.getAttribute("maxlength")).toBe("6");109    // birds always animate on the desktop screens (design correction)110    expect(container.querySelector(".peach-chirp-bird-desktop")).toBeTruthy();111    // non-digits are stripped and the value is capped at 6 chars112    inputText(input, "12ab3456789");113    expect(input.value).toBe("123456");114  });115116  it("Connect sends PAIR_WITH_DESKTOP with the typed code, then searches", async () => {117    const { container } = mount();118    gotoDesktopEnter(container);119    inputText(codeInput(container), "428913");120    const connect = buttonByText(container, "Connect")!;121    expect(connect.disabled).toBe(false);122    connect.click();123    await Promise.resolve();124    expect(mockSendMessage).toHaveBeenCalledWith({125      type: "PAIR_WITH_DESKTOP",126      payload: { code: "428913" },127    });128    expect(container.textContent).toContain("Searching for helper…");129  });130131  it("Connect stays disabled until 6 digits are entered", () => {132    const { container } = mount();133    gotoDesktopEnter(container);134    inputText(codeInput(container), "123");135    expect(buttonByText(container, "Connect")!.disabled).toBe(true);136    inputText(codeInput(container), "123456");137    expect(buttonByText(container, "Connect")!.disabled).toBe(false);138  });139140  it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => {141    const { container } = mount();142    gotoDesktopEnter(container);143    inputText(codeInput(container), "428913");144    buttonByText(container, "Connect")!.click();145    await Promise.resolve();146147    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });148149    expect(container.textContent).toContain("Confirm this code matches on your computer:");150    expect(container.textContent).toContain("895245");151    expect(buttonByText(container, "Confirm")).toBeTruthy();152    expect(buttonByText(container, "Cancel")).toBeTruthy();153  });154155  it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => {156    const { container } = mount();157    gotoDesktopEnter(container);158    inputText(codeInput(container), "428913");159    buttonByText(container, "Connect")!.click();160    await Promise.resolve();161    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });162163    buttonByText(container, "Confirm")!.click();164    await Promise.resolve();165    expect(mockSendMessage).toHaveBeenCalledWith({166      type: "DESKTOP_PAIR_CONFIRM",167      payload: { confirmed: true },168    });169  });170171  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {172    const { container } = mount();173    gotoDesktopEnter(container);174    inputText(codeInput(container), "428913");175    buttonByText(container, "Connect")!.click();176    await Promise.resolve();177    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });178179    buttonByText(container, "Cancel")!.click();180    await Promise.resolve();181    expect(mockSendMessage).toHaveBeenCalledWith({182      type: "DESKTOP_PAIR_CONFIRM",183      payload: { confirmed: false },184    });185    expect(container.textContent).toContain("Codes don't match");186  });187188  it("shows the combined 'not found or expired' copy on invalid_code", async () => {189    const { container } = mount();190    gotoDesktopEnter(container);191    inputText(codeInput(container), "000000");192    buttonByText(container, "Connect")!.click();193    await Promise.resolve();194195    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth196    // §0.3) — the background collapses both to invalid_code via a socket close.197    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" });198199    expect(container.textContent).toContain("Code not found or expired");200    expect(buttonByText(container, "Try again")).toBeTruthy();201  });202203  it("shows the 'no computer found' copy on no_host_found", async () => {204    const { container } = mount();205    gotoDesktopEnter(container);206    inputText(codeInput(container), "111111");207    buttonByText(container, "Connect")!.click();208    await Promise.resolve();209    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });210    expect(container.textContent).toContain("Couldn't find your computer");211  });212213  it("renders Paired! and the device name on a complete result", async () => {214    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: 1700000000000 };215    const { container } = mount();216    gotoDesktopEnter(container);217    inputText(codeInput(container), "428913");218    buttonByText(container, "Connect")!.click();219    await Promise.resolve();220    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });221    buttonByText(container, "Confirm")!.click();222    await Promise.resolve();223224    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });225    expect(container.textContent).toContain("Paired!");226    expect(container.textContent).toContain("Connected to June's iMac");227  });228229  it("auto-dismisses to the device list after the paired state settles", async () => {230    vi.useFakeTimers();231    try {232      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };233      const { container } = mount();234      gotoDesktopEnter(container);235      inputText(codeInput(container), "428913");236      buttonByText(container, "Connect")!.click();237      await Promise.resolve();238      pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });239      buttonByText(container, "Confirm")!.click();240      await Promise.resolve();241      pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });242      expect(container.textContent).toContain("Paired!");243      vi.advanceTimersByTime(2000);244      // returns to the rest surface (the two direct pairing options are back)245      expect(container.textContent).not.toContain("Paired!");246      expect(buttonContaining(container, "Same room")).toBeTruthy();247      expect(buttonContaining(container, "Enter a code")).toBeTruthy();248    } finally {249      vi.useRealTimers();250    }251  });252253  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {254    startResponse = { ok: false, error: "vault_locked" };255    const { container } = mount();256    gotoDesktopEnter(container);257    inputText(codeInput(container), "428913");258    buttonByText(container, "Connect")!.click();259    await Promise.resolve();260    await Promise.resolve();261    expect(container.textContent).toContain("Unlock Peach first");262  });263});