166 lines
5.7 KiB
TypeScript
166 lines
5.7 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: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
|
|
: undefined;
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
}
|
|
|
|
function rowPaths(form: HTMLFormElement): string[] {
|
|
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
|
(r) => r.dataset.cwd ?? "",
|
|
);
|
|
}
|
|
|
|
describe("spawnForm project picker", () => {
|
|
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 row 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(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
|
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
|
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
|
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
|
});
|
|
|
|
it("nests worktrees under their base repository", async () => {
|
|
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
|
const wt = project({
|
|
name: "wt",
|
|
path: "/elsewhere/wt",
|
|
is_worktree: true,
|
|
base_repo: main.path,
|
|
});
|
|
const form = (await mount([wt, main])).el;
|
|
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
|
const [mainRow, wtRow] = [
|
|
...form.querySelectorAll(".project-row:not(.project-other)"),
|
|
];
|
|
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
|
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
|
});
|
|
|
|
it("filters rows as the operator types", async () => {
|
|
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
|
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
|
const form = (await mount([a, b])).el;
|
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
|
filter.value = "tea";
|
|
filter.dispatchEvent(new Event("input"));
|
|
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
|
filter.value = "zzz";
|
|
filter.dispatchEvent(new Event("input"));
|
|
expect(rowPaths(form)).toEqual([]);
|
|
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
|
});
|
|
|
|
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
|
const p = project({
|
|
name: "maestro",
|
|
path: "/home/user/maestro",
|
|
last_command: "claude",
|
|
});
|
|
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 row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
|
row.click();
|
|
|
|
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
|
const last = onChange.mock.lastCall![0];
|
|
expect(last.cwd).toBe("/home/user/maestro");
|
|
expect(last.command).toBe("claude");
|
|
expect(values).toBeTruthy();
|
|
// The raw input stays hidden — the picked row is the source of truth.
|
|
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
|
expect(row.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");
|
|
});
|
|
});
|