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>
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { closeSpawnModal, openSpawnModal } from "./spawnModal.js";
|
|
import type { Session } from "./types.js";
|
|
|
|
function stubFetch(): void {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
const body = url.includes("/api/info")
|
|
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
|
: url.includes("/api/projects")
|
|
? []
|
|
: url.includes("/api/names")
|
|
? { names: ["cello"] }
|
|
: undefined;
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Drain the microtask queue so the form's initial loads land. */
|
|
async function flush(): Promise<void> {
|
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
}
|
|
|
|
const modal = () => document.getElementById("modal-host");
|
|
|
|
describe("spawn modal", () => {
|
|
beforeEach(() => {
|
|
stubFetch();
|
|
document.body.replaceChildren();
|
|
});
|
|
|
|
afterEach(() => {
|
|
closeSpawnModal();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("mounts the shared spawn form inside a dialog", async () => {
|
|
openSpawnModal({ onSuccess: () => {} });
|
|
await flush();
|
|
|
|
const host = modal()!;
|
|
expect(host).toBeTruthy();
|
|
expect(host.querySelector('[role="dialog"]')).toBeTruthy();
|
|
expect(host.querySelector("form.spawn-form")).toBeTruthy();
|
|
expect(host.querySelector("#spawn-title")?.textContent).toBe("Spawn session");
|
|
// The modal adds the Cancel the shared form doesn't ship.
|
|
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
|
});
|
|
|
|
it("opens with the project filter focused, harness before project", async () => {
|
|
openSpawnModal({ onSuccess: () => {} });
|
|
await flush();
|
|
|
|
const form = modal()!.querySelector("form.spawn-form")!;
|
|
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
|
|
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
|
|
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
|
|
});
|
|
|
|
it("titles a seeded open as a clone", async () => {
|
|
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
|
|
await flush();
|
|
|
|
expect(modal()!.querySelector("#spawn-title")?.textContent).toBe("Spawn similar");
|
|
});
|
|
|
|
it("closes on Escape, on Cancel and on a backdrop click", async () => {
|
|
for (const dismiss of [
|
|
() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
|
|
() => (modal()!.querySelector(".form-actions .cancel") as HTMLButtonElement).click(),
|
|
() => modal()!.dispatchEvent(new MouseEvent("click", { bubbles: true })),
|
|
() => (modal()!.querySelector(".close") as HTMLButtonElement).click(),
|
|
]) {
|
|
openSpawnModal({ onSuccess: () => {} });
|
|
await flush();
|
|
dismiss();
|
|
expect(modal()).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("leaves no stray Escape handler behind after closing", async () => {
|
|
openSpawnModal({ onSuccess: () => {} });
|
|
await flush();
|
|
closeSpawnModal();
|
|
|
|
// A second Escape with no modal up must not throw on the torn-down form.
|
|
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
expect(modal()).toBeNull();
|
|
});
|
|
|
|
it("opening twice tears the first one down rather than stacking dialogs", async () => {
|
|
openSpawnModal({ onSuccess: () => {} });
|
|
await flush();
|
|
openSpawnModal({ onSuccess: () => {} });
|
|
await flush();
|
|
|
|
expect(document.querySelectorAll("#modal-host")).toHaveLength(1);
|
|
expect(document.querySelectorAll("form.spawn-form")).toHaveLength(1);
|
|
});
|
|
|
|
it("closes before handing the created session to its caller", async () => {
|
|
const seen: Array<{ session: Session; stillOpen: boolean }> = [];
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
if (url.includes("/api/sessions") && init?.method === "POST") {
|
|
return new Response(JSON.stringify({ id: "s1", cwd: "/x", command: "zsh" }), { status: 200 });
|
|
}
|
|
const body = url.includes("/api/info")
|
|
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
|
: url.includes("/api/projects")
|
|
? []
|
|
: url.includes("/api/names")
|
|
? { names: ["cello"] }
|
|
: undefined;
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
|
|
openSpawnModal({
|
|
// A plain, worktree-free spawn: the point here is where the created
|
|
// session is handed over, not what the form validates.
|
|
seed: { command: "zsh", cwd: "/x", cwdRawVisible: true, worktreeEnabled: false },
|
|
onSuccess: (session) => seen.push({ session, stillOpen: modal() != null }),
|
|
});
|
|
await flush();
|
|
(modal()!.querySelector("form.spawn-form") as HTMLFormElement).dispatchEvent(
|
|
new Event("submit", { bubbles: true, cancelable: true }),
|
|
);
|
|
await flush();
|
|
|
|
expect(seen).toHaveLength(1);
|
|
expect(seen[0].session.id).toBe("s1");
|
|
expect(seen[0].stillOpen).toBe(false);
|
|
});
|
|
});
|