ChirpSync.desktop.test.tsx

10.2 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  // ChirpSync is pairing-only now; it opens straight into the desktop code flow61  // (the device list / method choice live on the Settings sync card).62  function mount(onBack: () => void = () => {}) {63    const r = renderView(64      <ChirpSyncView isPro={true} initialPair="desktop" onBack={onBack} onLearnPro={() => {}} />,65    );66    cleanups.push(r.dispose);67    return r;68  }6970  function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {71    return Array.from(container.querySelectorAll("button")).find(72      (b) => (b.textContent ?? "").trim() === text,73    ) as HTMLButtonElement | undefined;74  }75  function codeInput(container: HTMLElement): HTMLInputElement {76    return container.querySelector("#desktop-pair-code") as HTMLInputElement;77  }7879  // initialPair="desktop" lands directly on the code-entry screen.80  function gotoDesktopEnter(_container: HTMLElement): void {81    /* no-op: mount() already opens the desktop code flow */82  }8384  it("opens straight into the desktop code flow (no method-select screen)", () => {85    const { container } = mount();86    expect(container.textContent).not.toContain("How do you want to pair?");87    // The code input is present immediately, without any intermediate choice.88    expect(codeInput(container)).toBeTruthy();89    expect(buttonByText(container, "Connect")).toBeTruthy();90  });9192  it("renders a 6-digit numeric code input with birds present", () => {93    const { container } = mount();94    gotoDesktopEnter(container);95    const input = codeInput(container);96    expect(input).toBeTruthy();97    expect(input.getAttribute("inputmode")).toBe("numeric");98    expect(input.getAttribute("maxlength")).toBe("6");99    // birds always animate on the desktop screens (design correction)100    expect(container.querySelector(".peach-chirp-bird-desktop")).toBeTruthy();101    // non-digits are stripped and the value is capped at 6 chars102    inputText(input, "12ab3456789");103    expect(input.value).toBe("123456");104  });105106  it("Connect sends PAIR_WITH_DESKTOP with the typed code, then searches", async () => {107    const { container } = mount();108    gotoDesktopEnter(container);109    inputText(codeInput(container), "428913");110    const connect = buttonByText(container, "Connect")!;111    expect(connect.disabled).toBe(false);112    connect.click();113    await Promise.resolve();114    expect(mockSendMessage).toHaveBeenCalledWith({115      type: "PAIR_WITH_DESKTOP",116      payload: { code: "428913" },117    });118    expect(container.textContent).toContain("Searching for helper…");119  });120121  it("Connect stays disabled until 6 digits are entered", () => {122    const { container } = mount();123    gotoDesktopEnter(container);124    inputText(codeInput(container), "123");125    expect(buttonByText(container, "Connect")!.disabled).toBe(true);126    inputText(codeInput(container), "123456");127    expect(buttonByText(container, "Connect")!.disabled).toBe(false);128  });129130  it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => {131    const { container } = mount();132    gotoDesktopEnter(container);133    inputText(codeInput(container), "428913");134    buttonByText(container, "Connect")!.click();135    await Promise.resolve();136137    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });138139    expect(container.textContent).toContain("Confirm this code matches on your computer:");140    expect(container.textContent).toContain("895245");141    expect(buttonByText(container, "Confirm")).toBeTruthy();142    expect(buttonByText(container, "Cancel")).toBeTruthy();143  });144145  it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => {146    const { container } = mount();147    gotoDesktopEnter(container);148    inputText(codeInput(container), "428913");149    buttonByText(container, "Connect")!.click();150    await Promise.resolve();151    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });152153    buttonByText(container, "Confirm")!.click();154    await Promise.resolve();155    expect(mockSendMessage).toHaveBeenCalledWith({156      type: "DESKTOP_PAIR_CONFIRM",157      payload: { confirmed: true },158    });159  });160161  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", 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, "Cancel")!.click();170    await Promise.resolve();171    expect(mockSendMessage).toHaveBeenCalledWith({172      type: "DESKTOP_PAIR_CONFIRM",173      payload: { confirmed: false },174    });175    expect(container.textContent).toContain("Codes don't match");176  });177178  it("shows the combined 'not found or expired' copy on invalid_code", async () => {179    const { container } = mount();180    gotoDesktopEnter(container);181    inputText(codeInput(container), "000000");182    buttonByText(container, "Connect")!.click();183    await Promise.resolve();184185    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth186    // §0.3) — the background collapses both to invalid_code via a socket close.187    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" });188189    expect(container.textContent).toContain("Code not found or expired");190    expect(buttonByText(container, "Try again")).toBeTruthy();191  });192193  it("shows the 'no computer found' copy on no_host_found", async () => {194    const { container } = mount();195    gotoDesktopEnter(container);196    inputText(codeInput(container), "111111");197    buttonByText(container, "Connect")!.click();198    await Promise.resolve();199    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });200    expect(container.textContent).toContain("Couldn't find your computer");201  });202203  it("renders Paired! and the device name on a complete result", async () => {204    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: 1700000000000 };205    const { container } = mount();206    gotoDesktopEnter(container);207    inputText(codeInput(container), "428913");208    buttonByText(container, "Connect")!.click();209    await Promise.resolve();210    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });211    buttonByText(container, "Confirm")!.click();212    await Promise.resolve();213214    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });215    expect(container.textContent).toContain("Paired!");216    expect(container.textContent).toContain("Connected to June's iMac");217  });218219  it("auto-dismisses back to settings after the paired state settles", async () => {220    vi.useFakeTimers();221    try {222      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };223      const onBack = vi.fn();224      const { container } = mount(onBack);225      gotoDesktopEnter(container);226      inputText(codeInput(container), "428913");227      buttonByText(container, "Connect")!.click();228      await Promise.resolve();229      pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });230      buttonByText(container, "Confirm")!.click();231      await Promise.resolve();232      pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });233      expect(container.textContent).toContain("Paired!");234      vi.advanceTimersByTime(2000);235      // returns to the Settings sync hub, which owns the device list now236      expect(onBack).toHaveBeenCalled();237    } finally {238      vi.useRealTimers();239    }240  });241242  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {243    startResponse = { ok: false, error: "vault_locked" };244    const { container } = mount();245    gotoDesktopEnter(container);246    inputText(codeInput(container), "428913");247    buttonByText(container, "Connect")!.click();248    await Promise.resolve();249    await Promise.resolve();250    expect(container.textContent).toContain("Unlock Peach first");251  });252});