- toggle defaults on for any target the project scan reports (repo or worktree); only the free-text escape hatch pays for /api/gitroot - branch mode control switches between creating a new branch (named after the session name, as before) and picking an existing one from GET /api/branches; branches held by another worktree stay visible but disabled, labelled with that worktree's path - emits create_branch: false on the wire for existing-mode picks - branch-list load follows the form's ready/destroyed discipline so a late response cannot corrupt a rebuilt draft
173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
|
|
|
const PROJECTS = [
|
|
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
|
|
];
|
|
|
|
const BRANCHES = [
|
|
{ name: "main", is_head: true },
|
|
{ name: "spawn-v2", is_head: false, worktree: "/home/user/wt-spawn" },
|
|
{ name: "free-branch", is_head: false },
|
|
];
|
|
|
|
/** Stub the fetches the form makes. `gateBranches` (when given) holds the
|
|
* /api/branches response until released, so tests can destroy the form while
|
|
* its branch list is still in flight. */
|
|
function stubFetch(opts?: {
|
|
gateBranches?: (release: () => void) => void;
|
|
}): string[] {
|
|
const urls: string[] = [];
|
|
const gated = new Promise<void>((resolve) => {
|
|
if (opts?.gateBranches) opts.gateBranches(resolve);
|
|
else resolve();
|
|
});
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
urls.push(url);
|
|
if (url.includes("/api/names")) {
|
|
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
|
}
|
|
if (url.includes("/api/branches")) {
|
|
await gated;
|
|
return new Response(JSON.stringify(BRANCHES), { status: 200 });
|
|
}
|
|
if (url.includes("/api/projects")) {
|
|
return new Response(JSON.stringify(PROJECTS), { status: 200 });
|
|
}
|
|
return new Response(
|
|
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
|
{ status: 200 },
|
|
);
|
|
}),
|
|
);
|
|
return urls;
|
|
}
|
|
|
|
describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
|
let handle: SpawnFormHandle | null = null;
|
|
let urls: string[];
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.destroy();
|
|
handle = null;
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
async function mount(
|
|
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
|
|
onChange?: (values: SpawnFormValues) => void,
|
|
) {
|
|
urls = stubFetch();
|
|
handle = createSpawnForm({ seed, onSuccess() {}, onChange });
|
|
document.body.appendChild(handle.el);
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
return handle.el;
|
|
}
|
|
|
|
const drain = async () => {
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
};
|
|
|
|
function pickProject(form: HTMLFormElement): void {
|
|
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
|
|
form
|
|
.querySelectorAll<HTMLButtonElement>(".project-row")
|
|
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
|
|
}
|
|
|
|
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
|
|
const form = await mount();
|
|
expect(form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
|
true,
|
|
);
|
|
|
|
pickProject(form);
|
|
await drain();
|
|
|
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
|
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(
|
|
"/home/user/maestro",
|
|
);
|
|
});
|
|
|
|
it("still probes the gitroot for a free-text directory", async () => {
|
|
const form = await mount();
|
|
|
|
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
|
|
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
|
raw.value = "/opt/somewhere";
|
|
raw.dispatchEvent(new Event("input"));
|
|
await drain();
|
|
|
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
|
|
});
|
|
|
|
it("populates existing branches, disabling ones held by another worktree", async () => {
|
|
const form = await mount();
|
|
pickProject(form);
|
|
|
|
const radio = form.querySelector<HTMLInputElement>(
|
|
'input[name=worktree_branch_mode][value="existing"]',
|
|
)!;
|
|
radio.click();
|
|
await drain();
|
|
|
|
const select = form.querySelector<HTMLSelectElement>(".branch-select")!;
|
|
expect(urls.some((u) => u.includes("/api/branches?path="))).toBe(true);
|
|
const options = [...select.options];
|
|
expect(options.map((o) => o.value)).toEqual(["main", "spawn-v2", "free-branch"]);
|
|
const held = options.find((o) => o.value === "spawn-v2")!;
|
|
expect(held.disabled).toBe(true);
|
|
expect(held.textContent).toContain("/home/user/wt-spawn");
|
|
expect(options.find((o) => o.value === "free-branch")!.disabled).toBe(false);
|
|
});
|
|
|
|
it("emits the branch mode as draft state when it changes", async () => {
|
|
const changes: SpawnFormValues[] = [];
|
|
const form = await mount(undefined, (v) => changes.push(v));
|
|
await drain();
|
|
|
|
const radio = form.querySelector<HTMLInputElement>(
|
|
'input[name=worktree_branch_mode][value="existing"]',
|
|
)!;
|
|
radio.click();
|
|
await drain();
|
|
|
|
const modes = changes.map((c) => (c as { worktreeBranchMode?: string }).worktreeBranchMode);
|
|
expect(modes).toContain("new");
|
|
expect(modes.at(-1)).toBe("existing");
|
|
});
|
|
|
|
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
|
const gate: { release?: () => void } = {};
|
|
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
|
handle = createSpawnForm({ onSuccess() {} });
|
|
document.body.appendChild(handle.el);
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
|
|
pickProject(handle.el);
|
|
handle.el
|
|
.querySelector<HTMLInputElement>('input[name=worktree_branch_mode][value="existing"]')!
|
|
.click();
|
|
|
|
handle.destroy();
|
|
gate.release?.();
|
|
await drain();
|
|
|
|
expect(
|
|
handle.el.querySelector<HTMLSelectElement>(".branch-select")!.options.length,
|
|
).toBe(0);
|
|
});
|
|
});
|