Matches the TUI's WorktreeToggle, whose Enter also toggles rather than submitting — otherwise the two clients would disagree on this one field despite #97 requiring the same rule everywhere else.
259 lines
8.4 KiB
TypeScript
259 lines
8.4 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[]): Promise<HTMLFormElement> {
|
|
stubFetch(projects);
|
|
handle = createSpawnForm({ 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 .tile[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("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);
|
|
});
|
|
});
|
|
});
|