The new-branch name input and the existing-branch picker both had a literal <label>Branch</label>, on top of "New branch"/"Existing branch" mode radios — reading the form gave no clue what the field wanted. Kept the mode radios (they name the choice, not the field) and gave each field its own label: "New branch name" for the text input, "Pick a branch" for the select.
272 lines
9.4 KiB
TypeScript
272 lines
9.4 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;
|
|
projects?: unknown[];
|
|
}): 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(opts?.projects ?? 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-other")!.click();
|
|
form
|
|
.querySelectorAll<HTMLButtonElement>(".project-box")
|
|
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.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("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
|
|
urls = stubFetch({
|
|
projects: [
|
|
{
|
|
path: "/home/user/wt-91",
|
|
name: "wt-91",
|
|
is_worktree: true,
|
|
base_repo: "/home/user/maestro",
|
|
last_activity: "2026-08-20T10:00:00Z",
|
|
},
|
|
],
|
|
});
|
|
handle = createSpawnForm({ onSuccess() {} });
|
|
document.body.appendChild(handle.el);
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
|
|
handle.el
|
|
.querySelectorAll<HTMLButtonElement>(".project-box")
|
|
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
|
await drain();
|
|
|
|
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
|
false,
|
|
);
|
|
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
|
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
|
});
|
|
|
|
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 — debounced, so the quiet period must elapse first (#74).
|
|
form.querySelector<HTMLElement>(".project-other")!.click();
|
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
|
raw.value = "/opt/somewhere";
|
|
raw.dispatchEvent(new Event("input"));
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
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("labels the new-branch input and existing-branch picker distinctly (#94)", async () => {
|
|
const form = await mount();
|
|
pickProject(form);
|
|
await drain();
|
|
|
|
const newLabel = form.querySelector<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
|
|
const existLabel = form.querySelector<HTMLElement>(".wt-exist-wrap")!.textContent!.trim();
|
|
|
|
expect(newLabel).not.toBe("Branch");
|
|
expect(existLabel).not.toBe("Branch");
|
|
expect(newLabel).not.toBe(existLabel);
|
|
expect(newLabel).toMatch(/name/i);
|
|
expect(existLabel).toMatch(/pick|choose|select/i);
|
|
});
|
|
|
|
it("collapses the advanced (worktree) section by default (#93)", async () => {
|
|
const form = await mount();
|
|
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
|
|
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
|
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
|
|
});
|
|
|
|
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
|
|
const form = await mount();
|
|
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
|
|
});
|
|
|
|
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
|
|
urls = stubFetch({
|
|
projects: [
|
|
{
|
|
path: "/home/user/wt-91",
|
|
name: "wt-91",
|
|
is_worktree: true,
|
|
base_repo: "/home/user/maestro",
|
|
last_activity: "2026-08-20T10:00:00Z",
|
|
},
|
|
],
|
|
});
|
|
handle = createSpawnForm({ onSuccess() {} });
|
|
document.body.appendChild(handle.el);
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
|
|
handle.el
|
|
.querySelectorAll<HTMLButtonElement>(".project-box")
|
|
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
|
await drain();
|
|
|
|
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
|
|
// Still collapsed — picking a project must not force it open.
|
|
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
|
"false",
|
|
);
|
|
});
|
|
|
|
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
|
|
const form = await mount();
|
|
const before = [...form.querySelectorAll(".field")];
|
|
|
|
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
|
|
|
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
|
"true",
|
|
);
|
|
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
|
|
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|