Move the harness/command choice above the project picker so the operator decides what runs before where. Replace the flat command tiles with larger centered cards that render each tool's icon from toolIcon.ts at 24x24. The Custom escape hatch gets a three-dot ellipsis icon and a dashed border to read as secondary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
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();
|
|
}
|
|
|
|
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 = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
|
(t) => t.dataset.id === "zsh",
|
|
)!;
|
|
zshTile.click();
|
|
expect(zshTile.classList.contains("is-active")).toBe(true);
|
|
|
|
// /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();
|
|
|
|
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
|
(t) => t.dataset.id === "custom",
|
|
)!;
|
|
customTile.click();
|
|
const cmdRaw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
|
cmdRaw.value = "htop";
|
|
cmdRaw.dispatchEvent(new Event("input"));
|
|
|
|
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");
|
|
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
|
(t) => t.dataset.id === "fish",
|
|
);
|
|
expect(fishTile).toBeDefined();
|
|
});
|
|
});
|