The picker now leads the form in both clients and takes focus on open, so Tab walks from it into the command tiles. Opening the form also preselects the project of the session you were last on — a worktree session seeds its base repository — so 'n' then Enter spawns another session there.
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 .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("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);
|
|
});
|
|
});
|
|
});
|