This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/spawnModal.test.ts
T
Miguel Palhas 247060de68 feat(maestro): open the spawn form on the project picker
The picker now leads the form in both clients and takes focus on open, so
Tab walks from it into the command tiles. Opening the form also preselects
the project of the session you were last on — a worktree session seeds its
base repository — so 'n' then Enter spawns another session there.
2026-09-02 15:56:35 +01:00

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, ahead of the command tiles", 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(["Project", "Command"]);
});
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);
});
});