Move the harness/command choice above the project picker so the operator decides what runs before where. Replace the flat command tiles with larger centered cards that render each tool's icon from toolIcon.ts at 24x24. The Custom escape hatch gets a three-dot ellipsis icon and a dashed border to read as secondary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
280 lines
9.2 KiB
TypeScript
280 lines
9.2 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// Enter submits the draft form from any field, at any point (#97). The two
|
|
// exceptions: a picker button that isn't yet the active choice takes the
|
|
// Enter to *choose* it instead (the next Enter submits), and the project
|
|
// filter must resolve to the highlighted project rather than sending
|
|
// whatever cwd was already set.
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
|
import type { Project } from "./types.js";
|
|
|
|
const MAESTRO: Project = {
|
|
path: "/home/user/tea/maestro",
|
|
name: "maestro",
|
|
is_worktree: false,
|
|
last_activity: "2026-08-20T10:00:00Z",
|
|
};
|
|
|
|
const WT_A: Project = {
|
|
path: "/home/user/tea/maestro/worktrees/bulgarians",
|
|
name: "bulgarians",
|
|
is_worktree: true,
|
|
base_repo: MAESTRO.path,
|
|
last_activity: "2026-08-22T10:00:00Z",
|
|
};
|
|
|
|
const OTHER: Project = {
|
|
path: "/home/user/tea/rev",
|
|
name: "rev",
|
|
is_worktree: false,
|
|
last_activity: "2026-08-19T10:00:00Z",
|
|
};
|
|
|
|
function stubFetch(projects: Project[]): void {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
const body = url.includes("/api/projects")
|
|
? projects
|
|
: url.includes("/api/names")
|
|
? { names: ["amber-fox"] }
|
|
: url.includes("/api/branches")
|
|
? []
|
|
: url.includes("/api/dirs")
|
|
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
|
|
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe("spawnForm Enter-to-submit (#97)", () => {
|
|
let handle: SpawnFormHandle | null = null;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.destroy();
|
|
handle = null;
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
const drain = async () => {
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
};
|
|
|
|
async function mount(
|
|
projects: Project[],
|
|
seed?: Parameters<typeof createSpawnForm>[0]["seed"],
|
|
): Promise<HTMLFormElement> {
|
|
stubFetch(projects);
|
|
handle = createSpawnForm({ seed, onSuccess() {} });
|
|
document.body.appendChild(handle.el);
|
|
await drain();
|
|
return handle.el;
|
|
}
|
|
|
|
function submitSpy(): ReturnType<typeof vi.fn> {
|
|
return vi.fn();
|
|
}
|
|
|
|
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
|
|
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
|
|
el.dispatchEvent(evt);
|
|
return { defaultPrevented: evt.defaultPrevented };
|
|
}
|
|
|
|
const box = (form: HTMLFormElement, path: string) =>
|
|
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
|
|
|
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
|
|
const form = await mount([MAESTRO]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
|
expect(toggle.checked).toBe(true);
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
pressEnter(toggle);
|
|
expect(toggle.checked).toBe(false);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
|
|
pressEnter(toggle);
|
|
expect(toggle.checked).toBe(true);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe("submits immediately from ordinary fields", () => {
|
|
it("name field", async () => {
|
|
const form = await mount([MAESTRO]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("worktree branch field", async () => {
|
|
const form = await mount([MAESTRO]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("custom command field", async () => {
|
|
const form = await mount([MAESTRO]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
|
|
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
|
raw.value = "bash -l";
|
|
raw.dispatchEvent(new Event("input"));
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
pressEnter(raw);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("raw cwd field once the autocomplete dropdown is closed", async () => {
|
|
const form = await mount([MAESTRO]);
|
|
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
|
raw.value = "/opt/xyz-nothing-here";
|
|
raw.dispatchEvent(new Event("input"));
|
|
await vi.advanceTimersByTimeAsync(300);
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
pressEnter(raw);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
|
|
const form = await mount([MAESTRO]);
|
|
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
|
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
|
raw.focus();
|
|
raw.value = "/opt/";
|
|
raw.dispatchEvent(new Event("input", { bubbles: true }));
|
|
await vi.advanceTimersByTimeAsync(150);
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
const { defaultPrevented } = pressEnter(raw);
|
|
|
|
expect(defaultPrevented).toBe(true);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
expect(raw.value).toBe("/opt/nomatch-zzz/");
|
|
});
|
|
});
|
|
|
|
describe("two-step picker: first Enter chooses, second submits", () => {
|
|
it("project box", async () => {
|
|
const form = await mount([MAESTRO]);
|
|
const b = box(form, MAESTRO.path);
|
|
b.focus();
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
const first = pressEnter(b);
|
|
expect(first.defaultPrevented).toBe(true);
|
|
expect(b.classList.contains("is-active")).toBe(true);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
|
|
pressEnter(b);
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("worktree choice", async () => {
|
|
const form = await mount([MAESTRO, WT_A]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
|
|
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
pressEnter(choice);
|
|
expect(choice.classList.contains("is-active")).toBe(true);
|
|
expect(spy).not.toHaveBeenCalled();
|
|
|
|
pressEnter(choice);
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("project filter", () => {
|
|
it("submits with the highlighted (first matching) project, not the filter text", async () => {
|
|
const form = await mount([MAESTRO, OTHER]);
|
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
|
filter.value = "rev";
|
|
filter.dispatchEvent(new Event("input"));
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
pressEnter(filter);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
|
});
|
|
|
|
it("submits in the seeded project rather than the freshest one", async () => {
|
|
// What `n` then Enter must do: the modal opens parked on the project of
|
|
// the session you were last on (OTHER here), even though MAESTRO's
|
|
// worktree is the freshest row in the list.
|
|
const form = await mount([MAESTRO, WT_A, OTHER], {
|
|
cwd: OTHER.path,
|
|
cwdRawVisible: false,
|
|
});
|
|
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
pressEnter(form.querySelector<HTMLInputElement>(".project-filter")!);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
|
});
|
|
|
|
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
|
|
const form = await mount([MAESTRO, WT_A]);
|
|
box(form, MAESTRO.path).click();
|
|
await drain();
|
|
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
|
|
await drain();
|
|
|
|
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
|
filter.value = "maestro";
|
|
filter.dispatchEvent(new Event("input"));
|
|
const spy = submitSpy();
|
|
form.addEventListener("submit", spy);
|
|
|
|
pressEnter(filter);
|
|
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
|
|
"is-active",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
});
|