Rewrites the 27 DOM-behavior test files (dialogs, sidebar, spawn form, command bar, structured pane, catalog, multiview, theme, etc.) to use screen/within/fireEvent/waitFor instead of manual querySelector and dispatchEvent. Pure-logic test files (matcher, tree, activity, paths, reconnect, keys, webgl, paneTitle, commands, daemonLink, events, layout, persistence, previewSchedule, projectList, spawnSimilar, structuredDiff, toolIcon, fastForward, spawnForm) are untouched, and preview.test.ts needed no changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import "@testing-library/jest-dom/vitest";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { fireEvent, within } from "@testing-library/react";
|
|
|
|
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
|
|
|
/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called —
|
|
* everything else (names, projects, gitroot) answers immediately with an
|
|
* empty/default body. Lets a test pick a preset (or type a custom command)
|
|
* mid-flight, then observe what the late `/api/info` resolution does to it. */
|
|
function stubDeferredInfo(): { resolveInfo: () => void } {
|
|
let releaseInfo: () => void = () => {};
|
|
const infoGate = new Promise<void>((resolve) => {
|
|
releaseInfo = resolve;
|
|
});
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.includes("/api/info")) {
|
|
await infoGate;
|
|
return new Response(
|
|
JSON.stringify({
|
|
hostname: "h",
|
|
version: "0",
|
|
home_dir: "/home/user",
|
|
presets: [
|
|
{ id: "claude", label: "Claude", command: "claude" },
|
|
{ id: "fish", label: "Fish", command: "fish" },
|
|
{ id: "custom", label: "Custom…", command: "" },
|
|
],
|
|
}),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
if (url.includes("/api/names")) {
|
|
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
|
}
|
|
return new Response(JSON.stringify([]), { status: 200 });
|
|
}),
|
|
);
|
|
return { resolveInfo: releaseInfo };
|
|
}
|
|
|
|
describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
|
let handle: SpawnFormHandle | null = null;
|
|
|
|
afterEach(() => {
|
|
handle?.destroy();
|
|
handle = null;
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
async function drain(): Promise<void> {
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
}
|
|
|
|
// Tiles fold their icon's accessible name into the button's computed name,
|
|
// so lookups go by the stable `data-id` rather than by accessible role/name.
|
|
function tileById(form: HTMLFormElement, id: string): HTMLButtonElement {
|
|
return [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
|
(t) => t.dataset.id === id,
|
|
)!;
|
|
}
|
|
|
|
it("keeps an operator-picked tile after /api/info resolves late", async () => {
|
|
const { resolveInfo } = stubDeferredInfo();
|
|
let values: SpawnFormValues | undefined;
|
|
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
|
const form = handle.el;
|
|
document.body.appendChild(form);
|
|
await drain();
|
|
|
|
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
|
const zshTile = tileById(form, "zsh");
|
|
fireEvent.click(zshTile);
|
|
expect(zshTile).toHaveClass("is-active");
|
|
|
|
// /api/info lands late with a preset list that doesn't even include "zsh".
|
|
resolveInfo();
|
|
await drain();
|
|
|
|
// The operator's pick must not be discarded back to the new list's first
|
|
// entry — `onChange` only starts firing once the initial load settles
|
|
// (`ready`), which happens right here, so this is the first place the
|
|
// published command is observable.
|
|
expect(values?.command).toBe("zsh");
|
|
});
|
|
|
|
it("keeps an operator-typed custom command after /api/info resolves late", async () => {
|
|
const { resolveInfo } = stubDeferredInfo();
|
|
let values: SpawnFormValues | undefined;
|
|
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
|
const form = handle.el;
|
|
document.body.appendChild(form);
|
|
await drain();
|
|
|
|
fireEvent.click(tileById(form, "custom"));
|
|
const cmdRaw = within(form).getByPlaceholderText("bash -l") as HTMLInputElement;
|
|
fireEvent.input(cmdRaw, { target: { value: "htop" } });
|
|
|
|
resolveInfo();
|
|
await drain();
|
|
|
|
expect(values?.command).toBe("htop");
|
|
expect(cmdRaw.value).toBe("htop");
|
|
});
|
|
|
|
it("still applies the real presets' first entry when the operator never touched the command", async () => {
|
|
const { resolveInfo } = stubDeferredInfo();
|
|
let values: SpawnFormValues | undefined;
|
|
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
|
const form = handle.el;
|
|
document.body.appendChild(form);
|
|
await drain();
|
|
|
|
resolveInfo();
|
|
await drain();
|
|
|
|
// Untouched: the refined preset list is still free to settle the command.
|
|
expect(values?.command).toBe("claude");
|
|
expect(tileById(form, "fish")).toBeDefined();
|
|
});
|
|
});
|