The name field starts empty and the reroll is gone; the worktree branch keeps a default by borrowing one handle from /api/names. A card whose name the daemon generated renders dimmed until a first prompt settles it. Refs #131
194 lines
7.2 KiB
TypeScript
194 lines
7.2 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
|
import type { Project } from "./types.js";
|
|
|
|
const NOW = "2026-08-23T12:00:00Z";
|
|
|
|
function project(overrides: Partial<Project>): Project {
|
|
return {
|
|
path: `/home/user/${overrides.name ?? "proj"}`,
|
|
name: "proj",
|
|
is_worktree: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Three fetches gate the form's initial load: /api/info, /api/projects, and
|
|
* the /api/names batch (#53). */
|
|
function stubFetch(info: unknown, projects: Project[]): void {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
const body = url.includes("/api/info")
|
|
? info
|
|
: url.includes("/api/projects")
|
|
? projects
|
|
: url.includes("/api/names")
|
|
? { names: ["cello", "fugue", "lyre"] }
|
|
: undefined;
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
}
|
|
|
|
function boxPaths(form: HTMLFormElement): string[] {
|
|
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
|
|
(b) => b.dataset.cwd ?? "",
|
|
);
|
|
}
|
|
|
|
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
|
|
let handle: SpawnFormHandle | null = null;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ now: Date.parse(NOW) });
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.destroy();
|
|
handle = null;
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
async function mount(projects: Project[], seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
|
|
stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects);
|
|
handle = createSpawnForm({
|
|
seed,
|
|
onSuccess() {},
|
|
});
|
|
document.body.appendChild(handle.el);
|
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
return handle;
|
|
}
|
|
|
|
it("renders one box per project, fresh before stale", async () => {
|
|
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
|
const stale = project({
|
|
name: "stale",
|
|
path: "/home/user/stale",
|
|
last_activity: "2026-01-01T00:00:00Z",
|
|
});
|
|
const form = (await mount([stale, fresh])).el;
|
|
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
|
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
|
|
expect(boxes[1].classList.contains("is-stale")).toBe(true);
|
|
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
|
});
|
|
|
|
it("shows the name as the primary line and its parent directory below", async () => {
|
|
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
|
const form = (await mount([p])).el;
|
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
|
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
|
// Only the stem: repeating the last segment would just echo the name. Not
|
|
// `~/t`, since the stem is what distinguishes two projects sharing a
|
|
// basename (#95).
|
|
expect(box.querySelector(".project-path")!.textContent).toBe("~/tea");
|
|
});
|
|
|
|
it("keeps the last command out of the box", async () => {
|
|
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
|
|
const form = (await mount([p])).el;
|
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
|
expect(box.textContent).not.toContain("claude");
|
|
});
|
|
|
|
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
|
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
|
const wt = project({
|
|
name: "wt",
|
|
path: "/home/user/maestro/worktrees/wt",
|
|
is_worktree: true,
|
|
base_repo: main.path,
|
|
});
|
|
const form = (await mount([wt, main])).el;
|
|
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
|
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
|
|
});
|
|
|
|
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
|
|
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
|
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
|
const wt = project({
|
|
name: "bulgarians",
|
|
path: "/home/user/maestro/worktrees/bulgarians",
|
|
is_worktree: true,
|
|
base_repo: a.path,
|
|
});
|
|
const form = (await mount([a, b, wt])).el;
|
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
|
filter.value = "tea";
|
|
filter.dispatchEvent(new Event("input"));
|
|
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
|
|
// A query that only matches a worktree still surfaces its repository box.
|
|
filter.value = "bulgarians";
|
|
filter.dispatchEvent(new Event("input"));
|
|
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
|
filter.value = "zzz";
|
|
filter.dispatchEvent(new Event("input"));
|
|
expect(boxPaths(form)).toEqual([]);
|
|
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
|
});
|
|
|
|
it("picking a project selects its cwd and leaves the command alone", async () => {
|
|
const p = project({
|
|
name: "maestro",
|
|
path: "/home/user/maestro",
|
|
last_command: "aider",
|
|
});
|
|
const onChange = vi.fn();
|
|
stubFetch(
|
|
{
|
|
hostname: "h",
|
|
version: "0",
|
|
home_dir: "/home/user",
|
|
presets: [
|
|
{ id: "claude", label: "Claude", command: "claude" },
|
|
{ id: "custom", label: "Custom…", command: "" },
|
|
],
|
|
},
|
|
[p],
|
|
);
|
|
handle = createSpawnForm({ onSuccess() {}, onChange });
|
|
const form = handle.el;
|
|
document.body.appendChild(form);
|
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
onChange.mockClear();
|
|
|
|
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
|
box.click();
|
|
|
|
const name = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
|
const last = onChange.mock.lastCall![0];
|
|
expect(last.cwd).toBe("/home/user/maestro");
|
|
// The directory and the command are independent choices: the preset stays
|
|
// where it was, whatever the project was last run with.
|
|
expect(last.command).toBe("claude");
|
|
// Picking a project is not naming a session: the field stays empty and
|
|
// the daemon names it (#131).
|
|
expect(name).toBe("");
|
|
// The raw input stays hidden — the picked box is the source of truth.
|
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
|
expect(box.classList.contains("is-active")).toBe(true);
|
|
});
|
|
|
|
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
|
const p = project({ name: "maestro", path: "/home/user/maestro" });
|
|
const form = (await mount([p])).el;
|
|
const other = form.querySelector<HTMLButtonElement>(".project-other")!;
|
|
other.click();
|
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
|
});
|
|
|
|
it("falls back to the raw input when no projects exist", async () => {
|
|
const form = (await mount([])).el;
|
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false);
|
|
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
|
|
expect(raw.value).toBe("/home/user");
|
|
});
|
|
});
|