Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Sonnet 5 c9562ab5eb Closes #252: port vanilla-DOM tests to React Testing Library
Rewrites the 27 DOM-behavior test files (dialogs, sidebar, spawn form,
command bar, structured pane, catalog, multiview, theme, etc.) to use
screen/within/fireEvent/waitFor instead of manual querySelector and
dispatchEvent. Pure-logic test files (matcher, tree, activity, paths,
reconnect, keys, webgl, paneTitle, commands, daemonLink, events, layout,
persistence, previewSchedule, projectList, spawnSimilar, structuredDiff,
toolIcon, fastForward, spawnForm) are untouched, and preview.test.ts
needed no changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 02:06:31 +01:00
Miguel Palhas 8304ef9a49 Closes #251: port preview mode to React 2026-09-09 01:39:37 +01:00
Miguel Palhas cdba6d07e3 Closes #250: port dialogs, context menu, help overlay, mobile drawer 2026-09-09 01:39:37 +01:00
Miguel Palhas e0edbc09d2 feat(maestro): port kill dialog, help overlay, context menu, drawer to React
Closes #250: kill confirmation/force-kill (dialogs/KillDialog), the ?
shortcuts overlay (dialogs/HelpOverlay), the general-purpose right-click
menu (common/ContextMenu, closes on scroll/resize unlike the sidebar's
own), the mobile drawer with swipe support (layout/Drawer), and the
visual-viewport hook (hooks/useViewport). Each is a standalone, tested
port of its vanilla-DOM source; wiring into the shell is a later issue.
2026-09-09 01:33:56 +01:00
39 changed files with 1757 additions and 846 deletions
+17 -17
View File
@@ -3,6 +3,8 @@
// The sidebar's half of the command bar's action page (#151): the bar picks
// kill or rename and raises the flow the card already has, so what the page
// runs is what the ✕ button and the `r` key run.
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { KILL_SESSION_EVENT, RENAME_SESSION_EVENT, renderSidebar } from "./catalog.js";
@@ -18,7 +20,7 @@ beforeAll(() => {
let mounted: { el: HTMLElement; dispose: () => void } | null = null;
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
screen.queryByRole("dialog")?.remove();
mounted?.dispose();
mounted = null;
document.body.replaceChildren();
@@ -75,14 +77,12 @@ describe("kill from the action page", () => {
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
expect(document.querySelector(".kill-dialog")?.getAttribute("aria-label")).toBe(
"Kill amber-falcon",
);
expect(screen.getByRole("dialog", { name: "Kill amber-falcon" })).toBeTruthy();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
await flush();
expect(calls).toContain(`DELETE /api/sessions/${s.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("raises the existing confirm-and-blockers dialog on a guarded refusal", async () => {
@@ -107,11 +107,10 @@ describe("kill from the action page", () => {
mount(s);
raise(KILL_SESSION_EVENT, s);
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill amber-falcon");
expect(dialog?.textContent).toContain("1 untracked file");
const dialog = screen.getByRole("dialog", { name: "Kill amber-falcon" });
expect(dialog.textContent).toContain("1 untracked file");
});
});
@@ -120,11 +119,12 @@ describe("rename from the action page", () => {
window.fetch = (() => Promise.reject(new Error("offline"))) as typeof fetch;
const s = session();
const el = mount(s);
const folder = el.querySelector<HTMLElement>(".tree-folder-header");
folder?.click();
expect(el.querySelector(`[data-session-id="${s.id}"]`)).toBeNull();
fireEvent.click(within(el).getByRole("button", { name: /maestro/ }));
expect(within(el).queryByRole("link", { name: /amber-falcon/ })).toBeNull();
raise(RENAME_SESSION_EVENT, s);
expect(el.querySelector<HTMLInputElement>(".leaf-rename")?.value).toBe("amber-falcon");
expect(within(el).getByRole("textbox", { name: "Rename amber-falcon" })).toHaveValue(
"amber-falcon",
);
});
it("raises the card's own prompt, pre-filled with the session's name", () => {
@@ -132,8 +132,8 @@ describe("rename from the action page", () => {
const s = session();
const el = mount(s);
raise(RENAME_SESSION_EVENT, s);
const input = el.querySelector<HTMLInputElement>(".leaf-rename");
expect(input?.value).toBe("amber-falcon");
expect(input?.getAttribute("aria-label")).toBe("Rename amber-falcon");
expect(within(el).getByRole("textbox", { name: "Rename amber-falcon" })).toHaveValue(
"amber-falcon",
);
});
});
+148 -128
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
@@ -17,7 +19,7 @@ beforeAll(() => {
});
afterEach(() => {
document.querySelector(".kill-dialog")?.remove();
document.body.replaceChildren();
});
function session(overrides: Partial<Session>): Session {
@@ -46,13 +48,20 @@ function storeWith(sessions: Session[]): CatalogStore {
} as unknown as CatalogStore;
}
/** The session's card, scoped by id: rows carry no other stable, queryable
* handle (names/aria-labels routinely collide across the fixtures below), so
* this stays a raw DOM lookup. Every assertion made *within* the row it
* returns goes through `within(row)`. */
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
}
/** Let the kill chain's awaits (fetch, its json body) settle. */
async function flush(): Promise<void> {
for (let i = 0; i < 8; i++) await Promise.resolve();
/** Mount a sidebar and attach it to the document, the way the shell does —
* screen/within queries and `:focus` both need a connected tree. */
function mount(store: CatalogStore): HTMLElement {
const el = renderSidebar(store).el;
document.body.appendChild(el);
return el;
}
describe("blocked-work indicator (#83)", () => {
@@ -66,13 +75,12 @@ describe("blocked-work indicator (#83)", () => {
deletions: 18,
},
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toBe("+124-18↑2");
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
const badge = within(row).getByTitle(/Kill will be refused unless forced/);
expect(badge.textContent).toBe("+124-18↑2");
// The spelled-out breakdown survives in the tooltip.
expect(badge?.getAttribute("title")).toContain(
"3 uncommitted files, 2 unpushed commits",
);
expect(badge.getAttribute("title")).toContain("3 uncommitted files, 2 unpushed commits");
});
it("falls back to the file count when tracked changes carry no lines", () => {
@@ -80,9 +88,10 @@ describe("blocked-work indicator (#83)", () => {
id: "aaaaaaaa-0000-0000-0000-00000000001a",
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toBe("~2?1");
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
const badge = within(row).getByTitle(/Kill will be refused unless forced/);
expect(badge.textContent).toBe("~2?1");
});
it("does not mark clear or unprobed sessions", () => {
@@ -93,9 +102,13 @@ describe("blocked-work indicator (#83)", () => {
const unprobed = session({
id: "aaaaaaaa-0000-0000-0000-000000000003",
});
const el = renderSidebar(storeWith([clear, unprobed])).el;
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
const el = mount(storeWith([clear, unprobed]));
expect(
within(rowFor(el, clear.id)!).queryByTitle(/Kill will be refused/),
).toBeNull();
expect(
within(rowFor(el, unprobed.id)!).queryByTitle(/Kill will be refused/),
).toBeNull();
});
it("confirms before an unguarded kill (#160)", async () => {
@@ -105,16 +118,16 @@ describe("blocked-work indicator (#83)", () => {
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.getAttribute("aria-label")).toBe("Kill claude");
const el = mount(storeWith([plain]));
const row = rowFor(el, plain.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
const dialog = screen.getByRole("dialog", { name: "Kill claude" });
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(document.querySelector(".kill-dialog")).toBeNull();
fireEvent.click(within(dialog).getByRole("button", { name: "Kill" }));
await waitFor(() => {
expect(calls).toContain(`DELETE /api/sessions/${plain.id}`);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("kills nothing when the confirmation is cancelled (#160)", async () => {
@@ -124,12 +137,11 @@ describe("blocked-work indicator (#83)", () => {
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000007" });
const el = renderSidebar(storeWith([plain])).el;
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
await flush();
expect(document.querySelector(".kill-dialog")).toBeNull();
const el = mount(storeWith([plain]));
const row = rowFor(el, plain.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(calls.filter((c) => c.startsWith("DELETE"))).toHaveLength(0);
});
@@ -139,12 +151,12 @@ describe("blocked-work indicator (#83)", () => {
id: "aaaaaaaa-0000-0000-0000-000000000008",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
expect(dialog?.textContent).toContain("refused unless it is forced");
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
const dialog = screen.getByRole("dialog", { name: "Kill claude" });
expect(dialog.textContent).toContain("1 untracked file");
expect(dialog.textContent).toContain("refused unless it is forced");
});
it("offers the override when the daemon refuses a guarded kill", async () => {
@@ -170,17 +182,20 @@ describe("blocked-work indicator (#83)", () => {
command: "bash",
blocked_work: { untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
const dialog = document.querySelector(".kill-dialog");
expect(dialog?.textContent).toContain("1 untracked file");
dialog?.querySelector<HTMLButtonElement>(".kill-dialog-force")?.click();
await flush();
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(document.querySelector(".kill-dialog")).toBeNull();
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill bash" }));
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
// The guarded refusal replaces the confirm dialog with the force prompt;
// wait for the swap rather than the (name-identical) dialog role alone.
const forceBtn = await screen.findByRole("button", { name: "Force kill" });
const dialog = screen.getByRole("dialog");
expect(dialog.textContent).toContain("1 untracked file");
fireEvent.click(forceBtn);
await waitFor(() => {
expect(calls).toContain(`DELETE /api/sessions/${blocked.id}?force=true`);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("closes the override prompt without killing on cancel", async () => {
@@ -200,13 +215,15 @@ describe("blocked-work indicator (#83)", () => {
id: "aaaaaaaa-0000-0000-0000-000000000006",
blocked_work: { unpushed_commits: 2 },
});
const el = renderSidebar(storeWith([blocked])).el;
rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
await flush();
document.querySelector<HTMLButtonElement>(".kill-dialog-cancel")?.click();
expect(document.querySelector(".kill-dialog")).toBeNull();
const el = mount(storeWith([blocked]));
const row = rowFor(el, blocked.id)!;
fireEvent.click(within(row).getByRole("button", { name: "Kill claude" }));
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
// Same reasoning as above: wait for the force prompt's own button before
// trusting "the" Cancel button is the one that belongs to it.
await screen.findByRole("button", { name: "Force kill" });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).toBeNull();
expect(calls.filter((c) => c.includes("force=true"))).toHaveLength(0);
});
});
@@ -222,10 +239,11 @@ describe("agent-reported detail (#112)", () => {
activity_since: ago(90_000),
activity_detail: "cargo build failed",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
const order = [...line2!.children].map((c) => c.className);
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line2 = row.querySelector<HTMLElement>(".card-line2")!;
expect(within(line2).getByText("cargo build failed")).toHaveClass("activity-detail");
const order = [...line2.children].map((c) => c.className);
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
expect(order.indexOf("activity-detail")).toBeLessThan(
order.findIndex((c) => c.startsWith("card-flag")),
@@ -238,8 +256,11 @@ describe("agent-reported detail (#112)", () => {
activity: "Active",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
// No text is rendered for a report that isn't in force, so there is
// nothing an accessible query can key off — this stays a raw DOM check.
expect(row.querySelector(".activity-detail")).toBeNull();
});
});
@@ -248,12 +269,13 @@ describe("session cards (#107)", () => {
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
const el = renderSidebar(storeWith([s])).el;
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line1 = row.querySelector<HTMLElement>(".card-line1")!;
expect(line1).not.toBeNull();
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
expect(within(line1).getByText("claude")).toHaveClass("leaf-name");
expect(line1.querySelector(".leaf-actions")).toBe(line1.lastElementChild);
});
it("always renders line 2 for a freshly spawned session", () => {
@@ -262,13 +284,13 @@ describe("session cards (#107)", () => {
activity: "Active",
activity_since: ago(5_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line2 = row.querySelector<HTMLElement>(".card-line2");
expect(line2).not.toBeNull();
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
expect(within(row).getByRole("img", { name: "Active" })).toHaveClass("activity-rail");
expect(line2!.querySelector(".activity-duration")).toBeNull();
expect(within(row).getByTitle("claude")).toHaveClass("leaf-cmd");
});
it("shows dot + floored duration once the state has been held past 30s", () => {
@@ -277,12 +299,12 @@ describe("session cards (#107)", () => {
activity: "Active",
activity_since: ago(4 * 60_000 + 30_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".card-line2");
expect(line2?.dataset.activity).toBe("active");
expect(row?.querySelector(".activity-rail")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const line2 = row.querySelector<HTMLElement>(".card-line2")!;
expect(line2.dataset.activity).toBe("active");
expect(within(row).getByRole("img", { name: "Active" })).toHaveClass("activity-rail");
expect(within(row).getByText("4m")).toHaveClass("activity-duration");
});
it("dims a name the daemon generated, until it settles (#131)", () => {
@@ -304,13 +326,12 @@ describe("session cards (#107)", () => {
metadata: { name: "older-session" },
});
const el = renderSidebar(storeWith([generated, haiku, chosen, legacy])).el;
const provisional = (s: Session) =>
rowFor(el, s.id)?.querySelector(".leaf-name")?.classList.contains("is-provisional");
expect(provisional(generated)).toBe(true);
expect(provisional(haiku)).toBe(false);
expect(provisional(chosen)).toBe(false);
expect(provisional(legacy)).toBe(false);
const el = mount(storeWith([generated, haiku, chosen, legacy]));
const nameEl = (s: Session, text: string) => within(rowFor(el, s.id)!).getByText(text);
expect(nameEl(generated, "amber-falcon")).toHaveClass("is-provisional");
expect(nameEl(haiku, "sidebar-rail")).not.toHaveClass("is-provisional");
expect(nameEl(chosen, "my-session")).not.toHaveClass("is-provisional");
expect(nameEl(legacy, "older-session")).not.toHaveClass("is-provisional");
});
it("renders the command as its tool icon when the tool is known", () => {
@@ -319,12 +340,13 @@ describe("session cards (#107)", () => {
command: "claude",
metadata: { name: "claude" },
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.getAttribute("title")).toBe("claude");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const cmd = within(row).getByTitle("claude");
expect(cmd).toHaveClass("leaf-cmd");
expect(cmd.querySelector<HTMLElement>(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
// The icon said it all: no text left over.
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("drops the command's arguments, keeping them in the tooltip", () => {
@@ -332,11 +354,12 @@ describe("session cards (#107)", () => {
id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --dangerously-skip-permissions",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
expect(cmd?.getAttribute("title")).toBe("claude --dangerously-skip-permissions");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const cmd = within(row).getByTitle("claude --dangerously-skip-permissions");
expect(cmd).toHaveClass("leaf-cmd");
expect(cmd.querySelector<HTMLElement>(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("keeps an unknown command as plain text", () => {
@@ -344,11 +367,11 @@ describe("session cards (#107)", () => {
id: "aaaaaaaa-0000-0000-0000-00000000001d",
command: "hx src/main.rs",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull();
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx");
expect(cmd?.getAttribute("title")).toBe("hx src/main.rs");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const cmd = within(row).getByTitle("hx src/main.rs");
expect(cmd.querySelector(".tool-icon")).toBeNull();
expect(within(cmd).getByText("hx")).toHaveClass("leaf-cmd-rest");
});
it("maps each activity state to its contract slug", () => {
@@ -362,16 +385,14 @@ describe("session cards (#107)", () => {
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
const el = renderSidebar(storeWith([idle, wait, error])).el;
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"idle",
);
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"error",
);
const el = mount(storeWith([idle, wait, error]));
// `data-activity` is a styling hook with no accessible surface of its
// own (the rail's role/name cover the state word, not the slug), so this
// stays a plain attribute assertion.
const activityOf = (s: Session) => rowFor(el, s.id)!.querySelector(".card-line2");
expect(activityOf(idle)).toHaveAttribute("data-activity", "idle");
expect(activityOf(wait)).toHaveAttribute("data-activity", "awaiting-input");
expect(activityOf(error)).toHaveAttribute("data-activity", "error");
});
it("treats an unknown activity variant as idle instead of failing to render", () => {
@@ -380,10 +401,10 @@ describe("session cards (#107)", () => {
activity: "Teleported" as Session["activity"],
activity_since: ago(120_000),
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
expect(line2?.getAttribute("data-activity")).toBe("idle");
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
expect(row.querySelector(".card-line2")).toHaveAttribute("data-activity", "idle");
expect(within(row).getByText("2m")).toHaveClass("activity-duration");
});
it("moves blocked work onto line 2 as diff notation", () => {
@@ -396,12 +417,12 @@ describe("session cards (#107)", () => {
deletions: 18,
},
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("+124");
expect(line2?.textContent).toContain("-18");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
expect(within(row).getByText("+124")).toBeTruthy();
expect(within(row).getByText("-18")).toBeTruthy();
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
expect(row.querySelector(".leaf-badge")).toBeNull();
});
it("shows exited on line 2 for dead sessions", () => {
@@ -409,9 +430,9 @@ describe("session cards (#107)", () => {
id: "aaaaaaaa-0000-0000-0000-000000000018",
status: "Dead",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("exited");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
expect(within(row).getByText("exited")).toBeTruthy();
});
it("carries the state word only in tooltips and aria labels, never as text", () => {
@@ -422,19 +443,18 @@ describe("session cards (#107)", () => {
activity: "AwaitingInput",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
const row = rowFor(el, s.id) as HTMLElement;
const rail = row.querySelector<HTMLElement>(".activity-rail");
expect(rail?.getAttribute("title")).toContain("Awaiting input");
expect(rail?.getAttribute("aria-label")).toContain("Awaiting input");
const el = mount(storeWith([s]));
const row = rowFor(el, s.id)!;
const rail = within(row).getByRole("img", { name: "Awaiting input" });
expect(rail).toHaveAttribute("title", "Awaiting input");
expect(row.textContent).not.toMatch(/awaiting.?input/i);
});
it("keeps the attach navigation and depth indentation", () => {
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
const el = renderSidebar(storeWith([s])).el;
const el = mount(storeWith([s]));
const row = rowFor(el, s.id) as HTMLAnchorElement;
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
expect(row).toHaveAttribute("href", "#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
});
});
+5 -4
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { within } from "@testing-library/react";
import { renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
@@ -61,9 +62,9 @@ describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
// the row's transitions, which is the flicker this avoids.
expect(rowOf(el)).toBe(before);
// …and the card still tells the truth about the session.
expect(before.querySelector(".activity-rail")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
expect(
within(before).getByRole("img", { name: "Awaiting input" }).getAttribute("data-activity"),
).toBe("awaiting-input");
expect(before.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
"awaiting-input",
);
@@ -100,6 +101,6 @@ describe("sidebar refresh keeps rows in place (docs/SIDEBAR.md)", () => {
push([session({ metadata: { name: "harmony" } })]);
expect(rowOf(el)).not.toBe(before);
expect(rowOf(el).querySelector(".leaf-name")?.textContent).toBe("harmony");
expect(within(rowOf(el)).getByText("harmony").textContent).toBe("harmony");
});
});
+35 -36
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -45,23 +47,29 @@ afterEach(() => {
document.body.replaceChildren();
});
function input(): HTMLInputElement {
return screen.getByRole("combobox") as HTMLInputElement;
}
function press(key: string, init: KeyboardEventInit = {}): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
fireEvent.keyDown(input(), { key, ...init });
}
function type(value: string): void {
const input = document.querySelector(".cmdbar-input") as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
// The bar re-renders off a native "input" listener, not React's "change" —
// fireEvent.input is the one that actually reaches it.
fireEvent.input(input(), { target: { value } });
}
function rowNames(): string[] {
return [...document.querySelectorAll(".cmdbar-row-name")].map((r) => r.textContent ?? "");
return screen
.queryAllByRole("option")
.map((row) => row.querySelector(".cmdbar-row-name")?.textContent ?? "");
}
function activeName(): string | undefined {
return document.querySelector(".cmdbar-row.is-active .cmdbar-row-name")?.textContent ?? undefined;
const active = screen.queryByRole("option", { selected: true });
return active?.querySelector(".cmdbar-row-name")?.textContent ?? undefined;
}
describe("empty-query ordering", () => {
@@ -211,7 +219,7 @@ describe("the overlay", () => {
it("opening twice leaves one overlay", () => {
open();
open();
expect(document.querySelectorAll(".cmdbar-overlay")).toHaveLength(1);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
expect(isCommandBarOpen()).toBe(true);
});
@@ -226,8 +234,7 @@ describe("the overlay", () => {
it("highlights inside the directory when that is the field that matched", () => {
open();
type("notes");
const row = document.querySelector(".cmdbar-row") as HTMLElement;
expect(row.dataset.sessionId).toBe("notes");
expect(screen.getByRole("option")).toHaveAttribute("data-session-id", "notes");
type("dev/wor");
expect(rowNames()).toEqual(["worker"]);
expect(
@@ -239,7 +246,7 @@ describe("the overlay", () => {
open();
type("zzz");
expect(rowNames()).toEqual([]);
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
expect(screen.getByText("No matches")).toBeDefined();
});
it("moves the highlight with the arrows and ctrl-p/ctrl-n, without wrapping", () => {
@@ -261,7 +268,7 @@ describe("the overlay", () => {
press("ArrowDown");
expect(activeName()).toBe("worker");
type("o");
expect(activeName()).toBe(document.querySelector(".cmdbar-row-name")?.textContent);
expect(activeName()).toBe(rowNames()[0]);
});
it("opens the highlighted session through the shared open path on Enter", () => {
@@ -291,17 +298,13 @@ describe("the overlay", () => {
expect(isCommandBarOpen()).toBe(false);
open();
(document.querySelector(".cmdbar-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
fireEvent.mouseDown(screen.getByRole("dialog"));
expect(isCommandBarOpen()).toBe(false);
});
it("stays open for a press inside the panel", () => {
open();
(document.querySelector(".cmdbar-panel") as HTMLElement).dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
fireEvent.mouseDown(document.querySelector(".cmdbar-panel") as HTMLElement);
expect(isCommandBarOpen()).toBe(true);
});
@@ -310,7 +313,7 @@ describe("the overlay", () => {
document.body.appendChild(before);
before.focus();
open();
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
expect(screen.getByRole("combobox")).toHaveFocus();
press("Escape");
expect(document.activeElement).toBe(before);
});
@@ -318,7 +321,7 @@ describe("the overlay", () => {
it("clicking a row opens that session", () => {
const onOpenSession = vi.fn();
open({ sessions, homeDir: "/home/me", commands: [], onOpenSession });
(document.querySelectorAll(".cmdbar-row")[1] as HTMLElement).click();
fireEvent.click(screen.getAllByRole("option")[1]);
expect(onOpenSession).toHaveBeenCalledWith("worker");
expect(isCommandBarOpen()).toBe(false);
});
@@ -330,12 +333,10 @@ function command(id: string, label: string, keys: string[], run = () => {}): Com
return { id, label, group: "Test", keys, scope: "global", run };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
function rowKeys(): string[][] {
return [...document.querySelectorAll(".cmdbar-row-keys")].map((el) =>
[...el.querySelectorAll("kbd")].map((k) => k.textContent ?? ""),
);
return screen
.queryAllByRole("option")
.map((row) => [...row.querySelectorAll("kbd")].map((k) => k.textContent ?? ""));
}
describe("parsing the query line", () => {
@@ -460,8 +461,7 @@ describe("the sigils in the overlay", () => {
it("lists recent dirs then projects under `~`", async () => {
open({ loadDirectories: async () => ["/home/me/dev/api", "/srv/www"] });
type("~");
await flush();
expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]);
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api", "/srv/www"]));
expect(document.querySelectorAll(".cmdbar-group-title")).toHaveLength(0);
});
@@ -476,9 +476,8 @@ describe("the sigils in the overlay", () => {
});
// The second `~` is a literal query character and reaches the matcher.
type("~~/dev/mae");
await flush();
await waitFor(() => expect(rowNames()).toEqual(["~/dev/maestro"]));
expect(asked).toEqual(["~/dev/"]);
expect(rowNames()).toEqual(["~/dev/maestro"]);
});
it("spawns in the highlighted directory on Enter", async () => {
@@ -488,7 +487,7 @@ describe("the sigils in the overlay", () => {
onSpawnDirectory: (path) => spawned.push(path),
});
type("~api");
await flush();
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api"]));
press("Enter");
expect(spawned).toEqual(["/home/me/dev/api"]);
expect(isCommandBarOpen()).toBe(false);
@@ -507,7 +506,7 @@ describe("the sigils in the overlay", () => {
listDirEntries: async () => [],
});
type("~");
await flush();
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api"]));
press("Enter");
document.removeEventListener(SPAWN_DIRECTORY_EVENT, listener);
expect(seen).toEqual(["/home/me/dev/api"]);
@@ -526,7 +525,7 @@ describe("the focus round-trip over a pane (#152)", () => {
const ta = terminalTextarea();
ta.focus();
openCommandBar({ sessions: [], commands: [] });
expect(document.activeElement).toBe(document.querySelector(".cmdbar-input"));
expect(screen.getByRole("combobox")).toHaveFocus();
press("Escape");
expect(document.activeElement).toBe(ta);
});
@@ -605,7 +604,7 @@ describe("the session action page (#151)", () => {
type("spwn");
expect(rowNames()).toEqual(["Spawn similar"]);
type("zzz");
expect(document.querySelector(".cmdbar-empty")?.textContent).toBe("No matches");
expect(screen.getByText("No matches")).toBeDefined();
});
it("does nothing on a command row", () => {
@@ -625,7 +624,7 @@ describe("the session action page (#151)", () => {
it("does nothing on a directory row", async () => {
open({ loadDirectories: () => Promise.resolve(["/home/me/dev/api"]) });
type("~");
await Promise.resolve();
await waitFor(() => expect(rowNames()).toEqual(["~/dev/api"]));
press("Tab");
expect(rowNames()).toEqual(["~/dev/api"]);
expect(headerText()).toBeUndefined();
@@ -639,7 +638,7 @@ describe("the session action page (#151)", () => {
press("Escape");
expect(isCommandBarOpen()).toBe(true);
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(input().value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
@@ -650,7 +649,7 @@ describe("the session action page (#151)", () => {
type("");
press("Backspace");
expect(headerText()).toBeUndefined();
expect((document.querySelector(".cmdbar-input") as HTMLInputElement).value).toBe("wor");
expect(input().value).toBe("wor");
expect(rowNames()).toEqual(["worker"]);
});
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ContextMenu } from "./ContextMenu.js";
afterEach(() => {
cleanup();
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
}
describe("ContextMenu (#125)", () => {
it("renders one item per entry, in order", () => {
render(
<ContextMenu
x={10}
y={20}
items={[
{ label: "Rename…", onSelect: () => {} },
{ label: "Kill session", onSelect: () => {}, danger: true },
]}
onClose={vi.fn()}
/>,
);
expect(labels()).toEqual(["Rename…", "Kill session"]);
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe("Kill session");
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: chosen }]} onClose={onClose} />);
fireEvent.click(screen.getByRole("menuitem"));
expect(chosen).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it("closes on Escape and on a press outside it", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent(document.body, new Event("pointerdown", { bubbles: true }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it("stays open for a press inside itself", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
const menu = document.querySelector(".context-menu") as HTMLElement;
fireEvent(menu, new Event("pointerdown", { bubbles: true }));
expect(onClose).not.toHaveBeenCalled();
});
it("closes on scroll and on resize", () => {
const onClose = vi.fn();
render(<ContextMenu x={0} y={0} items={[{ label: "Rename…", onSelect: () => {} }]} onClose={onClose} />);
fireEvent.scroll(window);
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.resize(window);
expect(onClose).toHaveBeenCalledTimes(2);
});
});
+86
View File
@@ -0,0 +1,86 @@
// React port of ../../contextMenu.ts (#125), the general-purpose version —
// unlike the sidebar's own menu, this one also closes on scroll/resize, since
// a menu anchored to coordinates goes stale the moment the page moves under it.
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export interface ContextMenuItem {
label: string;
onSelect(): void;
/** Renders the entry in the destructive style. */
danger?: boolean;
}
export interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose(): void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: x, top: y });
// Nudge back inside the viewport once the menu has a measurable size.
useLayoutEffect(() => {
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
setPos({ left: Math.max(0, Math.min(x, maxX)), top: Math.max(0, Math.min(y, maxY)) });
}, [x, y]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const onPointerDown = (e: Event) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("keydown", onKeyDown, true);
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("scroll", onClose, true);
window.addEventListener("resize", onClose);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("pointerdown", onPointerDown, true);
window.removeEventListener("scroll", onClose, true);
window.removeEventListener("resize", onClose);
};
}, [onClose]);
useEffect(() => {
(ref.current?.firstElementChild as HTMLElement | null)?.focus();
}, []);
return createPortal(
<div
ref={ref}
role="menu"
className="context-menu fixed z-modal min-w-[160px] rounded-r-2 border border-border-strong bg-surface py-sp-1 shadow-2"
style={{ left: pos.left, top: pos.top }}
>
{items.map((item) => (
<button
key={item.label}
type="button"
role="menuitem"
className={`context-menu-item block w-full px-sp-3 py-sp-1 text-left text-fs-12 hover:bg-surface-2 ${
item.danger ? "is-danger text-danger" : "text-ink"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClose();
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>,
document.body,
);
}
+1
View File
@@ -0,0 +1 @@
export { ContextMenu, type ContextMenuItem, type ContextMenuProps } from "./ContextMenu.js";
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
import { HelpOverlay } from "./HelpOverlay.js";
afterEach(() => {
cleanup();
});
function press(key: string, init: KeyboardEventInit = {}): void {
fireEvent.keyDown(window, { key, ...init });
}
describe("HelpOverlay", () => {
it("opens on ? and closes on a second ?", () => {
render(<HelpOverlay />);
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).not.toBeNull();
press("?", { shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
render(<HelpOverlay />);
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "?", shiftKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { ctrlKey: true });
expect(screen.queryByRole("dialog")).toBeNull();
input.remove();
});
it("closes on Escape, on the close button, and on a click outside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
press("Escape");
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).toBeNull();
press("?", { shiftKey: true });
fireEvent.click(screen.getByRole("dialog"));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("stays open for a click inside the panel", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
fireEvent.click(document.querySelector(".help-panel") as HTMLElement);
expect(screen.queryByRole("dialog")).not.toBeNull();
});
it("renders exactly the commands the registry has bound, grouped", () => {
render(<HelpOverlay />);
press("?", { shiftKey: true });
const groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
expect(
[...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
).toEqual(groups.flatMap((g) => g.items.map((c) => c.id)));
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
groups.flatMap((g) => g.items.map((c) => c.label)),
);
expect(
[...document.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
),
).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
});
});
@@ -0,0 +1,78 @@
// React port of ../../help.ts: the `?` overlay renders the command registry
// (commands.ts) directly — the same list that dispatches the keys — so a
// binding can never be documented as one thing and do another.
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { CommandSet, defineCommands, keyCaps } from "../../commands.js";
import { groupedCommands } from "../../help.js";
export function HelpOverlay() {
const [open, setOpen] = useState(false);
useEffect(() => {
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => setOpen((o) => !o) }));
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => setOpen(false) }));
const onKeydown = (e: KeyboardEvent) => {
if (open && overlayKeys.handle(e)) return;
helpKeys.handle(e);
};
window.addEventListener("keydown", onKeydown);
return () => window.removeEventListener("keydown", onKeydown);
}, [open]);
if (!open) return null;
const close = () => setOpen(false);
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
className="help-overlay fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<div className="help-panel max-h-[80vh] w-full max-w-[560px] overflow-auto rounded-r-3 border border-border bg-surface p-sp-4 shadow-2">
<header className="help-head flex items-center justify-between">
<h2 className="help-title m-0 text-fs-15 font-semibold text-ink">Keyboard shortcuts</h2>
<button
type="button"
aria-label="Close"
className="help-close rounded-r-1 px-sp-2 py-1 text-fs-15 text-muted hover:text-ink"
onClick={close}
>
✕
</button>
</header>
{groupedCommands().map((group) => (
<section key={group.title} className="help-group mt-sp-3">
<h3 className="help-group-title text-fs-12 font-semibold text-muted">{group.title}</h3>
<dl className="help-list mt-sp-1">
{group.items.map((cmd) => (
<div
key={cmd.id}
className="help-row flex items-center justify-between gap-sp-3 py-sp-1"
data-command-id={cmd.id}
>
<dt className="help-keys m-0 flex gap-sp-1">
{keyCaps(cmd.keys[0]).map((cap, i) => (
<kbd key={i} className="rounded-r-1 border border-border px-sp-1 text-fs-11">
{cap}
</kbd>
))}
</dt>
<dd className="help-desc m-0 text-fs-12 text-ink">{cmd.label}</dd>
</div>
))}
</dl>
</section>
))}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,74 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { KillDialog } from "./KillDialog.js";
afterEach(() => {
cleanup();
});
describe("KillDialog", () => {
it("focuses the kill button, so Enter confirms", () => {
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={vi.fn()} />);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Kill" }));
});
it("focuses the force button too", () => {
render(
<KillDialog
name="alpha"
blockers={null}
force
reason="worktree is dirty."
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Force kill" }));
});
it("names the blockers in the confirm body", () => {
render(
<KillDialog
name="alpha"
blockers={{ uncommitted_files: 2, untracked_files: 0, unpushed_commits: 0 }}
onConfirm={() => Promise.resolve()}
onCancel={vi.fn()}
/>,
);
expect(screen.getByText(/will be refused unless it is forced/)).not.toBeNull();
});
it("cancels on Cancel, on Escape and on a backdrop click", () => {
const onCancel = vi.fn();
const first = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
first.unmount();
const second = render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(2);
second.unmount();
render(<KillDialog name="alpha" blockers={null} onConfirm={() => Promise.resolve()} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("dialog"));
expect(onCancel).toHaveBeenCalledTimes(3);
});
it("disables the button while pending and shows a rejection inline, so the press can be retried", async () => {
let reject: (e: Error) => void = () => {};
const onConfirm = vi.fn(() => new Promise<void>((_, r) => (reject = r)));
render(<KillDialog name="alpha" blockers={null} onConfirm={onConfirm} onCancel={vi.fn()} />);
const kill = screen.getByRole("button", { name: "Kill" }) as HTMLButtonElement;
fireEvent.click(kill);
expect(onConfirm).toHaveBeenCalledOnce();
expect(kill.disabled).toBe(true);
reject(new Error("daemon unreachable"));
await waitFor(() => expect(kill.disabled).toBe(false));
expect(screen.getByText("Failed to kill: daemon unreachable")).not.toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
// React port of ../../killDialog.ts: the two kill prompts (#81, #160). The
// daemon refuses an unforced kill over a worktree holding unsaved work; the
// same component in `force` mode is the override that follows that refusal.
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { formatInTheWay, type InTheWay } from "../../types.js";
export interface KillDialogProps {
/** Session name, as the sidebar shows it. */
name: string;
/** What the card last saw in the way, when anything was. */
blockers: InTheWay | null;
/** Set once the daemon has refused an unforced kill: this pass forces it,
* and `reason` carries the daemon's refusal message. */
force?: boolean;
/** Message from the daemon's refusal, shown when there are no blockers to
* spell out. */
reason?: string;
/** Run the kill (or the forced kill). A rejection is rendered inline, and
* the dialog stays open so the press can be retried. */
onConfirm(): Promise<void>;
onCancel(): void;
}
export function KillDialog(props: KillDialogProps) {
const { name, blockers, force, reason, onConfirm, onCancel } = props;
const confirmRef = useRef<HTMLButtonElement>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
// The acting button takes the focus in both modes: the operator already
// asked for the kill, so Enter confirms it.
useEffect(() => confirmRef.current?.focus(), []);
const handleConfirm = () => {
setPending(true);
setError(null);
void onConfirm().catch((e: unknown) => {
setPending(false);
setError(`Failed to kill: ${(e as Error).message}`);
});
};
const body = blockers
? force
? `Its worktree still has ${formatInTheWay(blockers)}. Forcing the kill removes the session and leaves the worktree on disk.`
: `Its worktree still has ${formatInTheWay(blockers)}. The kill will be refused unless it is forced.`
: force
? `${reason ?? ""} Forcing the kill removes the session and leaves the worktree on disk.`
: "The session and everything running in it go away. Its worktree stays on disk.";
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-label={`Kill ${name}`}
className="kill-dialog fixed inset-0 z-modal flex items-center justify-center bg-bg/70"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="kill-dialog-panel min-w-[280px] max-w-[420px] rounded-r-2 border border-border-strong bg-surface p-sp-4 shadow-2">
<h2 className="kill-dialog-title m-0 text-fs-15 font-semibold text-ink">Kill &quot;{name}&quot;?</h2>
<p className="kill-dialog-body mt-sp-2 text-fs-13 text-ink">{body}</p>
{error && <p className="kill-dialog-error mt-sp-2 text-fs-12 text-danger">{error}</p>}
<div className="kill-dialog-actions mt-sp-3 flex justify-end gap-sp-2">
<button
type="button"
className="kill-dialog-cancel rounded-r-1 border border-border px-sp-3 py-sp-1 text-fs-12 text-ink"
onClick={onCancel}
>
Cancel
</button>
<button
ref={confirmRef}
type="button"
disabled={pending}
className={`rounded-r-1 bg-danger px-sp-3 py-sp-1 text-fs-12 text-accent-ink disabled:opacity-60 ${
force ? "kill-dialog-force" : "kill-dialog-kill"
}`}
onClick={handleConfirm}
>
{force ? "Force kill" : "Kill"}
</button>
</div>
</div>
</div>,
document.body,
);
}
+2
View File
@@ -0,0 +1,2 @@
export { KillDialog, type KillDialogProps } from "./KillDialog.js";
export { HelpOverlay } from "./HelpOverlay.js";
@@ -0,0 +1,79 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useViewport } from "./useViewport.js";
function Probe() {
useViewport();
return null;
}
class FakeVisualViewport {
height = 800;
offsetTop = 0;
private listeners: Record<string, Array<() => void>> = { resize: [], scroll: [] };
addEventListener(type: string, cb: () => void): void {
this.listeners[type].push(cb);
}
removeEventListener(type: string, cb: () => void): void {
this.listeners[type] = this.listeners[type].filter((l) => l !== cb);
}
fire(type: "resize" | "scroll"): void {
for (const l of this.listeners[type]) l();
}
}
let vv: FakeVisualViewport;
beforeEach(() => {
vv = new FakeVisualViewport();
vi.stubGlobal("visualViewport", vv);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
document.documentElement.className = "";
document.documentElement.removeAttribute("style");
});
describe("useViewport", () => {
it("mirrors the visual viewport's height and offset onto CSS custom properties", () => {
vv.height = 640;
vv.offsetTop = 12;
render(<Probe />);
const root = document.documentElement.style;
expect(root.getPropertyValue("--app-vh")).toBe("640px");
expect(root.getPropertyValue("--app-top")).toBe("12px");
});
it("no-ops when visualViewport is unavailable", () => {
vi.stubGlobal("visualViewport", undefined);
expect(() => render(<Probe />)).not.toThrow();
expect(document.documentElement.style.getPropertyValue("--app-vh")).toBe("");
});
it("marks kb-open once an editable shrinks the viewport well past the baseline", () => {
render(<Probe />);
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
vv.height = 800 - 200;
vv.fire("resize");
expect(document.documentElement.classList.contains("kb-open")).toBe(true);
input.blur();
vv.height = 800;
vv.fire("resize");
expect(document.documentElement.classList.contains("kb-open")).toBe(false);
input.remove();
});
});
+51
View File
@@ -0,0 +1,51 @@
// React port of ../../viewport.ts. Binds the app shell's height and position
// to the *visual* viewport rather than the layout one, so a pinned bottom bar
// (the attach view's special-key bar, NAP-342) lands above an open on-screen
// keyboard instead of behind it. See viewport.ts for the full mechanism.
import { useEffect } from "react";
const KB_OPEN_THRESHOLD = 150;
export function useViewport(): void {
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const root = document.documentElement;
let raf = 0;
let baseline = vv.height;
const isEditing = () => {
const a = document.activeElement as HTMLElement | null;
if (!a) return false;
return a.tagName === "TEXTAREA" || a.tagName === "INPUT" || a.isContentEditable;
};
const sync = () => {
raf = 0;
root.style.setProperty("--app-vh", `${Math.round(vv.height)}px`);
root.style.setProperty("--app-top", `${Math.round(vv.offsetTop)}px`);
// Nothing focused ⇒ keyboard can't be open ⇒ this height is the baseline.
const editing = isEditing();
if (!editing) baseline = vv.height;
const kbOpen = editing && baseline - vv.height > KB_OPEN_THRESHOLD;
root.classList.toggle("kb-open", kbOpen);
};
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(sync);
};
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
sync();
return () => {
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
if (raf) cancelAnimationFrame(raf);
};
}, []);
}
+154
View File
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
import { Drawer } from "./Drawer.js";
/** Force the mobile media query on, whatever happy-dom's viewport says
* (mirrors ../layout/MultiView's own `forceMobile` in multiview.test.ts). */
function forceMobile(matches: boolean): void {
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: query.includes("859") && matches,
media: query,
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
);
}
function shell(): HTMLElement {
return document.querySelector(".app-shell") as HTMLElement;
}
function touch(type: "touchstart" | "touchend", x: number, y: number): void {
const touches = [{ clientX: x, clientY: y }];
const event = new Event(type, { bubbles: true }) as unknown as {
touches: typeof touches;
changedTouches: typeof touches;
};
event.touches = touches;
event.changedTouches = touches;
act(() => {
document.dispatchEvent(event as unknown as Event);
});
}
beforeEach(() => forceMobile(true));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
function openViaToggle(): void {
act(() => {
document.dispatchEvent(new CustomEvent(TOGGLE_DRAWER_EVENT));
});
}
describe("Drawer (#35)", () => {
it("toggles open on the toggle event, off on desktop by class alone", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
expect(shell().classList.contains("drawer-open")).toBe(false);
openViaToggle();
expect(shell().classList.contains("drawer-open")).toBe(true);
});
it("closes when a session row is tapped", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
fireEvent.click(document.querySelector(".tree-leaf") as HTMLElement);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
act(() => {
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
});
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("closes on a backdrop click", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
openViaToggle();
fireEvent.click(document.querySelector(".mv-backdrop") as HTMLElement);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("opens on a rightward swipe and closes on a leftward one, only on mobile", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 100);
touch("touchend", 100, 100);
expect(shell().classList.contains("drawer-open")).toBe(true);
touch("touchstart", 100, 100);
touch("touchend", 0, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("ignores swipes on desktop", () => {
forceMobile(false);
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 100);
touch("touchend", 100, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
it("ignores a mostly-vertical or too-short gesture", () => {
render(
<Drawer sidebar={<div className="tree-leaf">row</div>}>
<div>content</div>
</Drawer>,
);
touch("touchstart", 0, 0);
touch("touchend", 10, 100);
expect(shell().classList.contains("drawer-open")).toBe(false);
});
});
+93
View File
@@ -0,0 +1,93 @@
// React port of ../../drawer.ts (#35): the mobile off-canvas sidebar. On
// desktop the `drawer-open` class is inert — CSS gates the drawer styling to
// narrow viewports (styles.css, `.app-shell`).
//
// Anything that puts a new view in the content area must close the drawer, or
// it renders behind the sidebar and the tap reads as a dead button — this
// listens for the same TOGGLE_DRAWER_EVENT/OPEN_SPAWN_EVENT/SPAWN_SIMILAR_EVENT
// the rest of the shell already raises, so it needs no wiring from callers.
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "../../catalog.js";
import { TOGGLE_DRAWER_EVENT } from "../../drawer.js";
export interface DrawerProps {
sidebar: ReactNode;
children: ReactNode;
className?: string;
}
export function Drawer({ sidebar, children, className }: DrawerProps) {
const [open, setOpen] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onToggle = () => setOpen((o) => !o);
const onClose = () => setOpen(false);
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.addEventListener(OPEN_SPAWN_EVENT, onClose);
document.addEventListener(SPAWN_SIMILAR_EVENT, onClose);
return () => {
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
document.removeEventListener(OPEN_SPAWN_EVENT, onClose);
document.removeEventListener(SPAWN_SIMILAR_EVENT, onClose);
};
}, []);
// Horizontal swipes drive the drawer (right opens, left closes) rather than
// the browser's back/forward navigation. Only a clearly horizontal gesture
// past a threshold counts, so vertical terminal scrolling (which owns its
// own touch handler) is untouched.
useEffect(() => {
const mobileMq = window.matchMedia("(max-width: 859px)");
let sx = 0;
let sy = 0;
let tracking = false;
const onTouchStart = (e: TouchEvent) => {
tracking = mobileMq.matches && e.touches.length === 1;
if (tracking) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
}
};
const onTouchEnd = (e: TouchEvent) => {
if (!tracking) return;
tracking = false;
const t = e.changedTouches[0];
const dx = t.clientX - sx;
const dy = t.clientY - sy;
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
setOpen((o) => {
if (dx > 0 && !o) return true;
if (dx < 0 && o) return false;
return o;
});
};
document.addEventListener("touchstart", onTouchStart, { passive: true });
document.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
document.removeEventListener("touchstart", onTouchStart);
document.removeEventListener("touchend", onTouchEnd);
};
}, []);
return (
<div className={`app-shell flex h-full min-h-0 ${open ? "drawer-open" : ""} ${className ?? ""}`}>
<div
ref={sidebarRef}
className="sidebar flex h-full min-h-0 flex-col bg-surface"
onClick={(e) => {
if ((e.target as HTMLElement).closest(".tree-leaf")) setOpen(false);
}}
>
{sidebar}
</div>
<section className="content min-h-0 flex-1">{children}</section>
<div className="mv-backdrop" onClick={() => setOpen(false)} />
</div>
);
}
+13 -9
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionPool } from "./connections.js";
@@ -15,6 +17,7 @@ function fakeConn(kind: string, id: string) {
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
el.dataset.kind = kind;
el.dataset.session = id;
el.dataset.testid = id; // lets a scoped `within(...).getByTestId` find this fake connection
return {
sessionId: id,
kind,
@@ -219,8 +222,8 @@ describe("ConnectionPool: view resolution races", () => {
expect(disposed).toEqual(["attach:" + A]);
// B's own element is still parked — A's repair didn't wipe its siblings
// out of the shared hidden cache.
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
expect(within(hidden).getByTestId(B)).toBeInTheDocument();
expect(within(hidden).getByTestId(A)).toHaveAttribute("data-kind", "structured");
});
it("does not replace a connection whose resolved view already matches", () => {
@@ -271,8 +274,9 @@ describe("ConnectionPool: view resolution races", () => {
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
expect(document.activeElement).toBe(pane.firstElementChild);
const conn = within(pane).getByTestId(A);
conn.focus();
expect(conn).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
@@ -294,12 +298,12 @@ describe("ConnectionPool: view resolution races", () => {
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
within(pane).getByTestId(A).focus();
const sidebarInput = document.createElement("input");
document.body.appendChild(sidebarInput);
sidebarInput.focus();
expect(document.activeElement).toBe(sidebarInput);
expect(sidebarInput).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
@@ -319,9 +323,9 @@ describe("ConnectionPool: view resolution races", () => {
pool.mount(A, pane);
pool.setFocus(A);
const textarea = document.createElement("textarea");
(pane.firstElementChild as HTMLElement).appendChild(textarea);
within(pane).getByTestId(A).appendChild(textarea);
textarea.focus();
expect(document.activeElement).toBe(textarea);
expect(textarea).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
@@ -339,7 +343,7 @@ describe("ConnectionPool: view resolution races", () => {
pool.mount(A, pane);
pool.setFocus(A);
(document.activeElement as HTMLElement | null)?.blur();
expect(document.activeElement).toBe(document.body);
expect(document.body).toHaveFocus();
mounts.length = 0;
socket.push([session(A, "Structured")]);
+14 -14
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { closeContextMenu, openContextMenu } from "./contextMenu.js";
@@ -9,7 +11,7 @@ afterEach(() => {
});
function labels(): string[] {
return [...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent ?? "");
return screen.getAllByRole("menuitem").map((b) => b.textContent ?? "");
}
describe("context menu (#125)", () => {
@@ -19,40 +21,38 @@ describe("context menu (#125)", () => {
{ label: "Kill session", onSelect: () => {}, danger: true },
]);
expect(labels()).toEqual(["Rename…", "Kill session"]);
expect(document.querySelector(".context-menu-item.is-danger")?.textContent).toBe(
"Kill session",
);
expect(screen.getByRole("menuitem", { name: "Kill session" })).toHaveClass("is-danger");
});
it("runs the selected entry and closes", () => {
const chosen = vi.fn();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: chosen }]);
(document.querySelector(".context-menu-item") as HTMLButtonElement).click();
fireEvent.click(screen.getByRole("menuitem", { name: "Rename…" }));
expect(chosen).toHaveBeenCalledOnce();
expect(document.querySelector(".context-menu")).toBeNull();
expect(screen.queryByRole("menu")).toBeNull();
});
it("opening a second menu replaces the first", () => {
openContextMenu(0, 0, [{ label: "first", onSelect: () => {} }]);
openContextMenu(0, 0, [{ label: "second", onSelect: () => {} }]);
expect(document.querySelectorAll(".context-menu")).toHaveLength(1);
expect(screen.getAllByRole("menu")).toHaveLength(1);
expect(labels()).toEqual(["second"]);
});
it("closes on Escape and on a press outside it", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(document.querySelector(".context-menu")).toBeNull();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("menu")).toBeNull();
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-menu")).toBeNull();
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("menu")).toBeNull();
});
it("stays open for a press inside itself", () => {
openContextMenu(0, 0, [{ label: "Rename…", onSelect: () => {} }]);
const menu = document.querySelector(".context-menu") as HTMLElement;
menu.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(document.querySelector(".context-menu")).not.toBeNull();
const menu = screen.getByRole("menu");
fireEvent.pointerDown(menu);
expect(screen.queryByRole("menu")).not.toBeNull();
});
});
+22 -20
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
@@ -13,27 +15,27 @@ let nextField: HTMLInputElement;
let docKeydown: ReturnType<typeof vi.fn>;
let ac: DirAutocompleteHandle | undefined;
function popup(): HTMLUListElement {
return document.querySelector(".dir-ac") as HTMLUListElement;
/** The dropdown stays in the DOM (with `hidden` toggling) rather than being
* added/removed, so queries need `{ hidden: true }` to see it while closed. */
function popup(): HTMLElement {
return screen.getByRole("listbox", { hidden: true });
}
function items(): HTMLElement[] {
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
return screen.getAllByRole("option", { hidden: true });
}
async function type(value: string): Promise<void> {
input.focus();
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
fireEvent.input(input, { target: { value } });
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
}
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
/** Fire a cancelable, bubbling keydown on the input; report whether the
* default browser behaviour (focus move, form submit) was suppressed. */
function press(key: string): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
input.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
const notPrevented = fireEvent.keyDown(input, { key });
return { defaultPrevented: !notPrevented };
}
beforeEach(() => {
@@ -57,26 +59,26 @@ describe("dirAutocomplete dismissal", () => {
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
expect(popup()).toBeVisible();
const { defaultPrevented } = press("Escape");
expect(defaultPrevented).toBe(true);
expect(popup().hidden).toBe(true);
expect(popup()).not.toBeVisible();
expect(input.value).toBe("/tmp/pro");
expect(document.activeElement).toBe(input);
expect(input).toHaveFocus();
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
});
it("blur dismisses the popup", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
expect(popup()).toBeVisible();
input.blur();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
expect(popup()).not.toBeVisible();
});
it("does not reopen on refocus until the operator types again", async () => {
@@ -87,10 +89,10 @@ describe("dirAutocomplete dismissal", () => {
input.focus();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
expect(popup()).not.toBeVisible();
await type("/tmp/ot");
expect(popup().hidden).toBe(false);
expect(popup()).toBeVisible();
});
});
@@ -104,13 +106,13 @@ describe("dirAutocomplete keyboard model", () => {
expect(input.value).toBe("/tmp/proj-a/");
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
expect(document.activeElement).toBe(nextField); // focus was not yanked back
expect(nextField).toHaveFocus(); // focus was not yanked back
});
it("Tab with nothing highlighted just moves on", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/nope-matches-nothing");
expect(popup().hidden).toBe(true);
expect(popup()).not.toBeVisible();
const { defaultPrevented } = press("Tab");
@@ -123,7 +125,7 @@ describe("dirAutocomplete keyboard model", () => {
await type("/tmp/");
press("ArrowDown");
expect(items()[1].classList.contains("is-active")).toBe(true);
expect(items()[1]).toHaveAttribute("aria-selected", "true");
const enter = press("Enter");
expect(enter.defaultPrevented).toBe(true); // no form submit
@@ -137,7 +139,7 @@ describe("dirAutocomplete opening", () => {
input.focus();
await vi.advanceTimersByTimeAsync(50);
expect(popup().hidden).toBe(false);
expect(popup()).toBeVisible();
expect(listDirMock).toHaveBeenCalled();
});
});
+5 -4
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import { fireEvent } from "@testing-library/react";
import { OPEN_SPAWN_EVENT, SPAWN_SIMILAR_EVENT } from "./catalog.js";
import { installDrawer, type Drawer } from "./drawer.js";
@@ -23,25 +24,25 @@ describe("mobile drawer", () => {
it("closes when a session row is tapped", () => {
const { shell, sidebar } = mount();
(sidebar.querySelector(".tree-leaf") as HTMLElement).click();
fireEvent.click(sidebar.querySelector(".tree-leaf") as HTMLElement);
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when a spawn is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(OPEN_SPAWN_EVENT));
fireEvent(document, new CustomEvent(OPEN_SPAWN_EVENT));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("closes when spawn similar is requested", () => {
const { shell } = mount();
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
fireEvent(document, new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: {} }));
expect(shell.classList.contains("drawer-open")).toBe(false);
});
it("leaves the backdrop click closing it", () => {
const { shell } = mount();
(shell.lastElementChild as HTMLElement).click();
fireEvent.click(shell.lastElementChild as HTMLElement);
expect(shell.classList.contains("drawer-open")).toBe(false);
});
});
+24 -24
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import { defineCommands, keyCaps, publishCommands, publishedCommands } from "./commands.js";
import { closeHelp, groupedCommands, installHelpKeybind, isHelpOpen, openHelp } from "./help.js";
@@ -14,11 +15,15 @@ afterEach(() => {
});
function press(key: string, init: KeyboardEventInit = {}): void {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
fireEvent.keyDown(window, { key, ...init });
}
function rows(): string[] {
return [...document.querySelectorAll(".help-row")].map((r) =>
function overlay(): HTMLElement {
return screen.getByRole("dialog", { name: "Keyboard shortcuts" });
}
function rows(root: HTMLElement): string[] {
return [...root.querySelectorAll(".help-row")].map((r) =>
[...r.querySelectorAll("kbd")].map((k) => k.textContent).join(" "),
);
}
@@ -26,22 +31,23 @@ function rows(): string[] {
describe("help overlay", () => {
it("renders exactly the commands the registry has bound, grouped", () => {
openHelp();
const dialog = overlay();
const groups = groupedCommands();
expect([...document.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
expect([...dialog.querySelectorAll(".help-group-title")].map((h) => h.textContent)).toEqual(
groups.map((g) => g.title),
);
expect(rows()).toEqual(
groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))),
);
expect([...document.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
expect(rows(dialog)).toEqual(groups.flatMap((g) => g.items.map((c) => keyCaps(c.keys[0]).join(" "))));
expect([...dialog.querySelectorAll(".help-desc")].map((d) => d.textContent)).toEqual(
groups.flatMap((g) => g.items.map((c) => c.label)),
);
});
it("names each row by its command id, so a row can't drift from its command", () => {
openHelp();
expect([...document.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId))
.toEqual(publishedCommands().map((c) => c.id));
const dialog = overlay();
expect(
[...dialog.querySelectorAll<HTMLElement>(".help-row")].map((r) => r.dataset.commandId),
).toEqual(publishedCommands().map((c) => c.id));
});
// #159: the leader comes from the daemon, so the overlay must render the
@@ -54,11 +60,9 @@ describe("help overlay", () => {
publishCommands(bound);
try {
openHelp();
const dialog = overlay();
const keysOf = (id: string) =>
[
...(document.querySelector(`.help-row[data-command-id="${id}"]`) as HTMLElement)
.querySelectorAll("kbd"),
]
[...dialog.querySelectorAll(`.help-row[data-command-id="${id}"] kbd`)]
.map((k) => k.textContent)
.join(" ");
expect(keysOf("pane.literalCtrlK")).toBe("Ctrl B then Ctrl K");
@@ -84,34 +88,30 @@ describe("help overlay", () => {
expect(isHelpOpen()).toBe(false);
openHelp();
(document.querySelector(".help-close") as HTMLButtonElement).click();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(isHelpOpen()).toBe(false);
openHelp();
(document.querySelector(".help-overlay") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
fireEvent.click(overlay());
expect(isHelpOpen()).toBe(false);
});
it("stays open for a click inside the panel", () => {
openHelp();
(document.querySelector(".help-panel") as HTMLElement).dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
fireEvent.click(screen.getByRole("heading", { name: "Keyboard shortcuts", level: 2 }));
expect(isHelpOpen()).toBe(true);
});
it("opening twice leaves one overlay", () => {
openHelp();
openHelp();
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
});
it("ignores ? while typing and when a non-shift modifier is held", () => {
const input = document.createElement("input");
document.body.appendChild(input);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "?", bubbles: true, shiftKey: true }));
fireEvent.keyDown(input, { key: "?", shiftKey: true });
expect(isHelpOpen()).toBe(false);
press("?", { ctrlKey: true });
@@ -122,6 +122,6 @@ describe("help overlay", () => {
installHelpKeybind();
press("?", { shiftKey: true });
expect(isHelpOpen()).toBe(true);
expect(document.querySelectorAll(".help-overlay")).toHaveLength(1);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
});
});
+8 -9
View File
@@ -1,10 +1,9 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from "vitest";
import {
closeForceKillDialog,
openForceKillDialog,
openKillConfirmDialog,
} from "./killDialog.js";
import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { closeForceKillDialog, openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
describe("kill dialogs", () => {
beforeEach(() => {
@@ -13,21 +12,21 @@ describe("kill dialogs", () => {
});
it("focuses the kill button, so Enter confirms", () => {
const el = openKillConfirmDialog({
openKillConfirmDialog({
name: "alpha",
blockers: null,
onKill: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
expect(screen.getByRole("button", { name: "Kill" })).toHaveFocus();
});
it("focuses the force button too", () => {
const el = openForceKillDialog({
openForceKillDialog({
name: "alpha",
blockers: null,
reason: "worktree is dirty.",
onForce: () => Promise.resolve(),
});
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
expect(screen.getByRole("button", { name: "Force kill" })).toHaveFocus();
});
});
+9 -11
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { addTab, emptyLayout, leaves, type Layout } from "./layout.js";
import { splitLeaf } from "./layout.js";
@@ -82,7 +84,7 @@ describe("multiview empty panes", () => {
await flush();
expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2);
expect(mv.el.querySelector(".mv-pane-empty")).toBeTruthy();
expect(screen.getByText(/Empty pane/)).toBeTruthy();
expect(mv.el.querySelector(".spawn-form")).toBeNull();
});
});
@@ -124,13 +126,9 @@ describe("multiview pane-drag swap", () => {
targetPane,
];
marker.dispatchEvent(
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
);
window.dispatchEvent(
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
);
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
fireEvent.pointerDown(marker, { clientX: 0, clientY: 0, bubbles: true, cancelable: true });
fireEvent.pointerMove(window, { clientX: 150, clientY: 50, bubbles: true, cancelable: true });
fireEvent.pointerUp(window, { clientX: 150, clientY: 50, bubbles: true, cancelable: true });
await flush();
}
@@ -384,7 +382,7 @@ describe("multiview mobile single pane", () => {
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
expect(mv.el).toHaveClass("mv-mobile-solo");
});
});
@@ -406,7 +404,7 @@ describe("multiview solo tab bar", () => {
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
expect(mv.el).toHaveClass("mv-solo-tab");
});
it("shows the tab bar again once a second tab appears", () => {
@@ -419,6 +417,6 @@ describe("multiview solo tab bar", () => {
document.body.appendChild(mv.el);
mv.render();
expect(mv.el.classList.contains("mv-solo-tab")).toBe(false);
expect(mv.el).not.toHaveClass("mv-solo-tab");
});
});
+28 -24
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
import type { Project } from "./types.js";
@@ -34,10 +35,14 @@ function stubFetch(info: unknown, projects: Project[]): void {
);
}
function projectBoxes(form: HTMLFormElement): HTMLButtonElement[] {
return within(form)
.getAllByRole("button")
.filter((b): b is HTMLButtonElement => b.classList.contains("project-box"));
}
function boxPaths(form: HTMLFormElement): string[] {
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
(b) => b.dataset.cwd ?? "",
);
return projectBoxes(form).map((b) => b.dataset.cwd ?? "");
}
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
@@ -74,7 +79,7 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
});
const form = (await mount([stale, fresh])).el;
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
const boxes = projectBoxes(form);
expect(boxes[1].classList.contains("is-stale")).toBe(true);
expect(boxes[0].classList.contains("is-stale")).toBe(false);
});
@@ -82,19 +87,19 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
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");
const box = within(form).getByRole("button", { name: /nixos-config/ });
expect(within(box).getByText("nixos-config")).toBeTruthy();
// 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");
expect(within(box).getByText("~/tea")).toBeTruthy();
});
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");
const box = within(form).getByRole("button", { name: /maestro/ });
expect(within(box).queryByText(/claude/)).toBeNull();
});
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
@@ -120,18 +125,15 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
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"));
const filter = within(form).getByPlaceholderText("Filter projects…");
fireEvent.input(filter, { target: { value: "tea" } });
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"));
fireEvent.input(filter, { target: { value: "bulgarians" } });
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
filter.value = "zzz";
filter.dispatchEvent(new Event("input"));
fireEvent.input(filter, { target: { value: "zzz" } });
expect(boxPaths(form)).toEqual([]);
expect(form.querySelector(".picker-empty")).toBeTruthy();
expect(within(form).getByText("No projects match.")).toBeTruthy();
});
it("picking a project selects its cwd and leaves the command alone", async () => {
@@ -159,10 +161,12 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
for (let i = 0; i < 20; i++) await Promise.resolve();
onChange.mockClear();
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
box.click();
const box = within(form).getByRole("button", { name: /maestro/ });
fireEvent.click(box);
const name = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
const name = within(form).getByPlaceholderText(
"named after your first prompt",
) as HTMLInputElement;
const last = onChange.mock.lastCall![0];
expect(last.cwd).toBe("/home/user/maestro");
// The directory and the command are independent choices: the preset stays
@@ -170,7 +174,7 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
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("");
expect(name.value).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);
@@ -179,15 +183,15 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
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();
const other = within(form).getByRole("button", { name: "Other directory…" });
fireEvent.click(other);
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")!;
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
expect(raw.value).toBe("/home/user");
});
});
+27 -31
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { renderSidebar } from "./catalog.js";
import { closeContextMenu } from "./contextMenu.js";
@@ -65,16 +66,12 @@ function cardIn(el: HTMLElement, s: Session): HTMLElement {
function openCardMenu(s: Session): { el: HTMLElement; row: HTMLElement } {
const el = mount(s);
const row = cardIn(el, s);
row.dispatchEvent(new Event("contextmenu", { bubbles: true, cancelable: true }));
fireEvent.contextMenu(row);
return { el, row };
}
function menuItem(label: string): HTMLButtonElement {
const found = [...document.querySelectorAll<HTMLButtonElement>(".context-menu-item")].find(
(b) => b.textContent === label,
);
if (!found) throw new Error(`no "${label}" entry in the context menu`);
return found;
function menuItem(label: string): HTMLElement {
return screen.getByRole("menuitem", { name: label });
}
describe("session name normalisation (#125)", () => {
@@ -93,10 +90,9 @@ describe("sidebar card context menu (#125)", () => {
it("suppresses the browser menu and offers rename, spawn similar and kill", () => {
const s = session();
const row = cardIn(mount(s), s);
const ev = new Event("contextmenu", { bubbles: true, cancelable: true });
row.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
expect([...document.querySelectorAll(".context-menu-item")].map((b) => b.textContent)).toEqual([
const notCancelled = fireEvent.contextMenu(row);
expect(notCancelled).toBe(false);
expect(screen.getAllByRole("menuitem").map((b) => b.textContent)).toEqual([
"Rename…",
"Spawn similar",
"Kill session",
@@ -108,7 +104,7 @@ describe("sidebar card context menu (#125)", () => {
openCardMenu(s);
const seen = vi.fn();
document.addEventListener("catalog-spawn-similar", seen);
menuItem("Spawn similar").click();
fireEvent.click(menuItem("Spawn similar"));
document.removeEventListener("catalog-spawn-similar", seen);
expect(seen).toHaveBeenCalledOnce();
});
@@ -117,40 +113,40 @@ describe("sidebar card context menu (#125)", () => {
describe("inline rename (#125)", () => {
it("pre-fills the field with the session's own name", () => {
const { row } = openCardMenu(session());
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
expect(input?.value).toBe("amber-falcon");
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox") as HTMLInputElement;
expect(input.value).toBe("amber-falcon");
expect(row.querySelector("span.leaf-name")).toBeNull();
});
it("starts empty for a session that has no name of its own", () => {
const { row } = openCardMenu(session({ metadata: {} }));
menuItem("Rename…").click();
expect(row.querySelector<HTMLInputElement>("input.leaf-rename")?.value).toBe("");
fireEvent.click(menuItem("Rename…"));
expect((within(row).getByRole("textbox") as HTMLInputElement).value).toBe("");
});
it("holds the tree still while the field is open", () => {
const s = session();
const { el, row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename");
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox");
// A catalog delta mid-edit must not rebuild the row out from under it.
document.dispatchEvent(new CustomEvent("catalog-rerender"));
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBe(input);
fireEvent(document, new CustomEvent("catalog-rerender"));
expect(within(cardIn(el, s)).getByRole("textbox")).toBe(input);
});
it("Enter PATCHes the session with the typed name", () => {
const s = session();
const { row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
input.value = "crimson-otter";
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox") as HTMLInputElement;
fireEvent.input(input, { target: { value: "crimson-otter" } });
const fetchMock = vi.fn(() =>
Promise.resolve(new Response(JSON.stringify(s), { status: 200 })),
);
vi.stubGlobal("fetch", fetchMock);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
fireEvent.keyDown(input, { key: "Enter" });
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
@@ -162,17 +158,17 @@ describe("inline rename (#125)", () => {
it("Escape cancels without a request and restores the name", () => {
const s = session();
const { el, row } = openCardMenu(s);
menuItem("Rename…").click();
const input = row.querySelector<HTMLInputElement>("input.leaf-rename") as HTMLInputElement;
input.value = "discarded";
fireEvent.click(menuItem("Rename…"));
const input = within(row).getByRole("textbox") as HTMLInputElement;
fireEvent.input(input, { target: { value: "discarded" } });
const fetchMock = vi.fn(() => Promise.reject(new Error("offline")));
vi.stubGlobal("fetch", fetchMock);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
fireEvent.keyDown(input, { key: "Escape" });
expect(fetchMock).not.toHaveBeenCalled();
// The row is rebuilt from the store, so the old name comes back.
expect(cardIn(el, s).querySelector(".leaf-name")?.textContent).toBe("amber-falcon");
expect(cardIn(el, s).querySelector("input.leaf-rename")).toBeNull();
expect(within(cardIn(el, s)).getByText("amber-falcon")).toBeTruthy();
expect(within(cardIn(el, s)).queryByRole("textbox")).toBeNull();
});
});
+23 -16
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { FOCUS_PANE_EVENT, renderSidebar } from "./catalog.js";
import type { CatalogListener, CatalogStore } from "./events.js";
@@ -50,7 +51,7 @@ function twoFolders(opts: Parameters<typeof renderSidebar>[1] = {}): {
}
function press(el: HTMLElement, key: string, init: KeyboardEventInit = {}): void {
el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, ...init }));
fireEvent.keyDown(el, { key, ...init });
}
function highlighted(el: HTMLElement): string | null {
@@ -61,11 +62,17 @@ function tree(el: HTMLElement): HTMLElement {
return el.querySelector<HTMLElement>(".tree")!;
}
function folderHeaders(el: HTMLElement): HTMLButtonElement[] {
return within(el)
.getAllByRole("button")
.filter((b): b is HTMLButtonElement => b.classList.contains("tree-folder-header"));
}
describe("sidebar keyboard navigation", () => {
it("walks the cards and never stops on an expanded folder", () => {
const { el } = twoFolders();
// The folders are there — the walk just refuses to land on them.
expect(el.querySelectorAll(".tree-folder-header")).toHaveLength(2);
expect(folderHeaders(el)).toHaveLength(2);
press(tree(el), "j");
expect(highlighted(el)).toBe(`session:${A}`);
@@ -89,8 +96,8 @@ describe("sidebar keyboard navigation", () => {
it("stops on a collapsed folder, since its cards are gone", () => {
const { el } = twoFolders();
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
header.click(); // collapse /one
const header = folderHeaders(el)[0];
fireEvent.click(header); // collapse /one
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
@@ -122,8 +129,8 @@ describe("sidebar keyboard navigation", () => {
it("c collapses and expands the highlighted folder", () => {
const { el } = twoFolders();
const header = el.querySelectorAll<HTMLElement>(".tree-folder-header")[0];
header.click(); // collapse /one, so the walk stops on its header
const header = folderHeaders(el)[0];
fireEvent.click(header); // collapse /one, so the walk stops on its header
press(tree(el), "j");
expect(highlighted(el)).toBe("folder:one");
@@ -136,11 +143,11 @@ describe("sidebar keyboard navigation", () => {
const { el } = twoFolders();
press(tree(el), "j");
press(tree(el), "r");
const field = el.querySelector<HTMLInputElement>(".leaf-rename");
const field = within(el).queryByRole("textbox");
expect(field).not.toBeNull();
// Escape settles the rename, so the module-level "a row is being renamed"
// flag doesn't leak into the next test.
field!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
fireEvent.keyDown(field!, { key: "Escape" });
});
it("x kills the highlighted session, once confirmed (#160)", async () => {
@@ -154,7 +161,7 @@ describe("sidebar keyboard navigation", () => {
press(tree(el), "j");
press(tree(el), "x");
expect(killed.some((r) => r.startsWith("DELETE"))).toBe(false);
document.querySelector<HTMLButtonElement>(".kill-dialog-kill")?.click();
fireEvent.click(screen.getByRole("button", { name: "Kill" }));
await Promise.resolve();
expect(killed.some((r) => r.startsWith("DELETE") && r.includes(A))).toBe(true);
});
@@ -164,7 +171,7 @@ describe("sidebar keyboard navigation", () => {
press(tree(el), "j");
const input = document.createElement("input");
tree(el).appendChild(input);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true }));
fireEvent.keyDown(input, { key: "j" });
expect(highlighted(el)).toBe(`session:${A}`);
});
});
@@ -178,7 +185,7 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
press(tree(el), "j");
expect(seen).toEqual([A, B]);
tree(el).dispatchEvent(new FocusEvent("blur"));
fireEvent.blur(tree(el));
expect(seen.at(-1)).toBe(null);
});
@@ -188,18 +195,18 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
fireEvent.pointerOver(card, { pointerType: "mouse" });
expect(seen).toEqual([]);
// Ctrl+hover — preview fires.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
fireEvent.pointerOver(card, { pointerType: "mouse", ctrlKey: true });
expect(seen).toEqual([A]);
// Deduplicated while still on the same card.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
fireEvent.pointerOver(card, { pointerType: "mouse", ctrlKey: true });
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
fireEvent.pointerLeave(tree(el), { pointerType: "mouse" });
expect(seen.at(-1)).toBe(null);
});
@@ -207,7 +214,7 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
fireEvent.pointerOver(card, { pointerType: "touch", ctrlKey: true });
expect(seen).toEqual([]);
});
});
+13 -8
View File
@@ -3,6 +3,8 @@
// The sidebar's search field is the touch entry point to the command bar
// (#157, docs/COMMAND-BAR.md "Touch entry"): tapping or focusing it asks the
// shell to open the bar with an empty query, instead of filtering the tree.
import "@testing-library/jest-dom/vitest";
import { fireEvent, within } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { OPEN_COMMAND_BAR_EVENT, renderSidebar } from "./catalog.js";
@@ -45,7 +47,9 @@ function storeWith(sessions: Session[]): CatalogStore {
function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
const handle = renderSidebar(storeWith(sessions));
document.body.appendChild(handle.el);
const search = handle.el.querySelector<HTMLInputElement>(".search")!;
const search = within(handle.el).getByRole("searchbox", {
name: "Search sessions",
}) as HTMLInputElement;
const opens: number[] = [];
const listener = () => opens.push(1);
document.addEventListener(OPEN_COMMAND_BAR_EVENT, listener);
@@ -64,14 +68,14 @@ function mount(sessions: Session[] = [session("alpha", "/home/n/alpha")]) {
describe("sidebar search field opens the command bar (#157)", () => {
it("asks the shell to open the bar when the field is focused", () => {
const t = mount();
t.search.dispatchEvent(new FocusEvent("focus"));
fireEvent.focus(t.search);
expect(t.opens).toHaveLength(1);
t.dispose();
});
it("opens on a tap, where a focus event may never arrive", () => {
const t = mount();
t.search.dispatchEvent(new Event("pointerdown", { bubbles: true }));
fireEvent.pointerDown(t.search);
expect(t.opens).toHaveLength(1);
t.dispose();
});
@@ -79,7 +83,7 @@ describe("sidebar search field opens the command bar (#157)", () => {
it("does not trap the keyboard in the field: it gives focus up on open", () => {
const t = mount();
t.search.focus();
expect(document.activeElement).not.toBe(t.search);
expect(t.search).not.toHaveFocus();
t.dispose();
});
@@ -98,15 +102,16 @@ describe("sidebar search field opens the command bar (#157)", () => {
it("no longer filters the tree in place", () => {
const t = mount([session("alpha", "/home/n/alpha"), session("beta", "/home/n/beta")]);
t.search.value = "alpha";
t.search.dispatchEvent(new Event("input"));
expect(t.handle.el.querySelectorAll(".tree-leaf")).toHaveLength(2);
fireEvent.input(t.search, { target: { value: "alpha" } });
// Both cards share one command ("claude") and so one accessible name; the
// DEV-only "kit" link would also match a bare role query for "link".
expect(within(t.handle.el).getAllByRole("link", { name: "claude, running, Idle" })).toHaveLength(2);
t.dispose();
});
it("has no 'No matches' state left: an empty catalog is empty, not filtered", () => {
const t = mount([]);
expect(t.handle.el.querySelector(".empty-title")?.textContent).toBe("No sessions yet");
expect(within(t.handle.el).getByText("No sessions yet")).toBeTruthy();
t.dispose();
});
});
+45 -44
View File
@@ -4,6 +4,7 @@
// 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 { fireEvent, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -84,19 +85,28 @@ describe("spawnForm Enter-to-submit (#97)", () => {
}
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 notCanceled = fireEvent.keyDown(el, { key: "Enter" });
return { defaultPrevented: !notCanceled };
}
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
/** The step-one project box for `project` — a plain button whose accessible
* name starts with its name, followed by its abbreviated parent path. */
const box = (container: HTMLElement, project: Project): HTMLButtonElement =>
within(container).getByRole("button", { name: new RegExp(`^${project.name}`) }) as HTMLButtonElement;
/** A step-two worktree choice by its label — inside the collapsed "Advanced"
* disclosure, so the role query must opt back in to hidden content. */
const wtChoice = (container: HTMLElement, name: string): HTMLButtonElement =>
within(container).getByRole("button", {
name: new RegExp(`^${name}`),
hidden: true,
}) as HTMLButtonElement;
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
const toggle = within(form).getByLabelText("Create git worktree") as HTMLInputElement;
expect(toggle.checked).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -113,36 +123,35 @@ describe("spawnForm Enter-to-submit (#97)", () => {
describe("submits immediately from ordinary fields", () => {
it("name field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
pressEnter(within(form).getByPlaceholderText("named after your first prompt"));
expect(spy).toHaveBeenCalledTimes(1);
});
it("worktree branch field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
pressEnter(within(form).getByLabelText("New branch name"));
expect(spy).toHaveBeenCalledTimes(1);
});
it("custom command field", async () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
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"));
fireEvent.click(within(form).getByRole("button", { name: /Custom/ }));
const raw = within(form).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(raw, { target: { value: "bash -l" } });
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -153,10 +162,9 @@ describe("spawnForm Enter-to-submit (#97)", () => {
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"));
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
fireEvent.input(raw, { target: { value: "/opt/xyz-nothing-here" } });
await vi.advanceTimersByTimeAsync(300);
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -168,11 +176,10 @@ describe("spawnForm Enter-to-submit (#97)", () => {
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]")!;
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
raw.focus();
raw.value = "/opt/";
raw.dispatchEvent(new Event("input", { bubbles: true }));
fireEvent.input(raw, { target: { value: "/opt/" } });
await vi.advanceTimersByTimeAsync(150);
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -188,7 +195,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
describe("two-step picker: first Enter chooses, second submits", () => {
it("project box", async () => {
const form = await mount([MAESTRO]);
const b = box(form, MAESTRO.path);
const b = box(form, MAESTRO);
b.focus();
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -204,9 +211,9 @@ describe("spawnForm Enter-to-submit (#97)", () => {
it("worktree choice", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO));
await drain();
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
const choice = wtChoice(form, WT_A.name);
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
const spy = submitSpy();
form.addEventListener("submit", spy);
@@ -223,16 +230,15 @@ describe("spawnForm Enter-to-submit (#97)", () => {
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 filter = within(form).getByPlaceholderText("Filter projects…") as HTMLInputElement;
fireEvent.input(filter, { target: { value: "rev" } });
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(filter);
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
expect(box(form, OTHER).classList.contains("is-active")).toBe(true);
});
it("submits in the seeded project rather than the freshest one", async () => {
@@ -243,37 +249,32 @@ describe("spawnForm Enter-to-submit (#97)", () => {
cwd: OTHER.path,
cwdRawVisible: false,
});
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
expect(box(form, OTHER).classList.contains("is-active")).toBe(true);
const spy = submitSpy();
form.addEventListener("submit", spy);
pressEnter(form.querySelector<HTMLInputElement>(".project-filter")!);
pressEnter(within(form).getByPlaceholderText("Filter projects…"));
expect(spy).toHaveBeenCalledTimes(1);
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
expect(box(form, OTHER).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();
fireEvent.click(box(form, MAESTRO));
await drain();
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
fireEvent.click(wtChoice(form, WT_A.name));
await drain();
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
filter.value = "maestro";
filter.dispatchEvent(new Event("input"));
const filter = within(form).getByPlaceholderText("Filter projects…") as HTMLInputElement;
fireEvent.input(filter, { target: { value: "maestro" } });
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);
expect(wtChoice(form, WT_A.name).classList.contains("is-active")).toBe(true);
});
});
});
+14 -8
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -52,14 +53,19 @@ describe("spawnForm naming (#131)", () => {
return handle.el;
}
// Forms from earlier tests in this file are never removed from
// `document.body` (only `destroy()`ed), so lookups are scoped to the form
// under test rather than going through the global `screen`.
const nameOf = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=name]")!.value;
(within(form).getByPlaceholderText("named after your first prompt") as HTMLInputElement).value;
const branchOf = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!.value;
(within(form).getByLabelText("New branch name") as HTMLInputElement).value;
it("leaves the name field empty so the daemon names the session", async () => {
const form = await mount();
expect(nameOf(form)).toBe("");
// No reroll control exists in the current markup; kept to guard against
// its (re-)introduction without a matching test update.
expect(form.querySelector(".reroll")).toBeNull();
});
@@ -78,9 +84,10 @@ describe("spawnForm naming (#131)", () => {
it("seeds the branch from a typed name, overriding the fetched handle", async () => {
const form = await mount();
const input = form.querySelector<HTMLInputElement>("input[name=name]")!;
input.value = "hand-typed";
input.dispatchEvent(new Event("input"));
const input = within(form).getByPlaceholderText(
"named after your first prompt",
) as HTMLInputElement;
fireEvent.input(input, { target: { value: "hand-typed" } });
expect(nameOf(form)).toBe("hand-typed");
expect(branchOf(form)).toBe("hand-typed");
@@ -92,9 +99,8 @@ describe("spawnForm naming (#131)", () => {
const form = handle.el;
document.body.appendChild(form);
const branch = form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!;
branch.value = "feat/mine";
branch.dispatchEvent(new Event("input"));
const branch = within(form).getByLabelText("New branch name") as HTMLInputElement;
fireEvent.input(branch, { target: { value: "feat/mine" } });
for (let i = 0; i < 40; i++) await Promise.resolve();
expect(branchOf(form)).toBe("feat/mine");
+17 -16
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
@@ -54,6 +56,14 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
}
// Tiles fold their icon's accessible name into the button's computed name,
// so lookups go by the stable `data-id` rather than by accessible role/name.
function tileById(form: HTMLFormElement, id: string): HTMLButtonElement {
return [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === id,
)!;
}
it("keeps an operator-picked tile after /api/info resolves late", async () => {
const { resolveInfo } = stubDeferredInfo();
let values: SpawnFormValues | undefined;
@@ -63,11 +73,9 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "zsh",
)!;
zshTile.click();
expect(zshTile.classList.contains("is-active")).toBe(true);
const zshTile = tileById(form, "zsh");
fireEvent.click(zshTile);
expect(zshTile).toHaveClass("is-active");
// /api/info lands late with a preset list that doesn't even include "zsh".
resolveInfo();
@@ -88,13 +96,9 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "custom",
)!;
customTile.click();
const cmdRaw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
cmdRaw.value = "htop";
cmdRaw.dispatchEvent(new Event("input"));
fireEvent.click(tileById(form, "custom"));
const cmdRaw = within(form).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(cmdRaw, { target: { value: "htop" } });
resolveInfo();
await drain();
@@ -116,9 +120,6 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
// Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
(t) => t.dataset.id === "fish",
);
expect(fishTile).toBeDefined();
expect(tileById(form, "fish")).toBeDefined();
});
});
+13 -13
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -93,15 +94,14 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
};
function typeRaw(form: HTMLFormElement, value: string): void {
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = value;
raw.dispatchEvent(new Event("input"));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
fireEvent.input(raw, { target: { value } });
}
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
const { form, stub } = await mount();
form.querySelector<HTMLElement>(".project-other")!.click();
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
for (let i = 1; i <= "/opt/somewhere".length; i++) {
typeRaw(form, "/opt/somewhere".slice(0, i));
@@ -119,7 +119,7 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
it("a stale probe answer cannot overwrite a newer input", async () => {
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
form.querySelector<HTMLElement>(".project-other")!.click();
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
// Probe for the old target, held in flight.
typeRaw(form, "/repo/old");
@@ -131,18 +131,18 @@ describe("spawnForm gitroot probe debounce (#74)", () => {
await drain();
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
expect(within(form).getByText((content) => content.includes("/repo/new"))).toBeTruthy();
expect((within(form).getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(
true,
);
// The late answer to the old target lands last but must lose.
stub.releaseGitRoot("/repo/old");
await drain();
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
expect(
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
).toBe(true);
expect(within(form).getByText((content) => content.includes("/repo/new"))).toBeTruthy();
expect((within(form).getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(
true,
);
});
});
+28 -32
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -50,25 +52,23 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el);
await drain();
const wrap = handle.el.querySelector<HTMLElement>(".structured-view")!;
const checkbox = wrap.querySelector<HTMLInputElement>("input")!;
const disclosure = handle.el.querySelector<HTMLElement>(".wt-collapsible")!;
expect(disclosure.hidden).toBe(true);
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(disclosure.hidden).toBe(false);
const checkbox = within(handle.el).getByLabelText("Structured view") as HTMLInputElement;
const wrap = checkbox.closest(".structured-view") as HTMLElement;
const toggle = within(handle.el).getByRole("button", { name: /Advanced/ });
expect(toggle).toHaveAttribute("aria-expanded", "false");
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "FOO=1 /usr/local/bin/codex";
command.dispatchEvent(new Event("input"));
fireEvent.click(custom);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "FOO=1 /usr/local/bin/codex" } });
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true);
command.value = "opencode";
command.dispatchEvent(new Event("input"));
fireEvent.input(command, { target: { value: "opencode" } });
expect(wrap.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
});
@@ -79,15 +79,14 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el);
await drain();
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
const checkbox = within(handle.el).getByLabelText("Structured view") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
checkbox.click();
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
fireEvent.click(handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "codex" } });
expect(checkbox.checked).toBe(false);
});
@@ -98,16 +97,14 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el);
await drain();
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "env -i codex";
command.dispatchEvent(new Event("input"));
fireEvent.click(handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "env -i codex" } });
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
const checkbox = within(handle.el).getByLabelText("Structured view") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
command.value = "opencode";
command.dispatchEvent(new Event("input"));
fireEvent.input(command, { target: { value: "opencode" } });
expect(checkbox.closest<HTMLElement>(".structured-view")!.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
@@ -123,13 +120,12 @@ describe("spawn form structured view", () => {
await drain();
if (tool === "codex") {
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
fireEvent.click(handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!);
const command = within(handle.el).getByPlaceholderText("bash -l") as HTMLInputElement;
fireEvent.input(command, { target: { value: "codex" } });
}
handle.el.requestSubmit();
fireEvent.submit(handle.el);
await drain();
expect(requests).toHaveLength(1);
@@ -148,8 +144,8 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el);
await drain();
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit();
fireEvent.click(within(handle.el).getByLabelText("Structured view"));
fireEvent.submit(handle.el);
await drain();
expect(requests).toHaveLength(1);
+28 -44
View File
@@ -1,4 +1,5 @@
// @vitest-environment happy-dom
import { fireEvent, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
@@ -80,15 +81,13 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
};
function pickProject(form: HTMLFormElement): void {
form.querySelector<HTMLButtonElement>(".project-other")!.click();
form
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click());
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
fireEvent.click(within(form).getByRole("button", { name: /^maestro/ }));
}
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
const form = await mount();
expect(form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
expect((within(form).getByLabelText("Create git worktree") as HTMLInputElement).checked).toBe(
true,
);
@@ -96,9 +95,7 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
await drain();
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(
"/home/user/maestro",
);
expect(within(form).getByText(/\/home\/user\/maestro/)).toBeTruthy();
});
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
@@ -117,14 +114,12 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
fireEvent.click(within(handle.el).getByRole("button", { name: /^wt-91/ }));
await drain();
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
false,
);
expect(
(within(handle.el).getByLabelText("Create git worktree") as HTMLInputElement).checked,
).toBe(false);
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
});
@@ -134,10 +129,9 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
// fire — debounced, so the quiet period must elapse first (#74).
form.querySelector<HTMLElement>(".project-other")!.click();
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
raw.value = "/opt/somewhere";
raw.dispatchEvent(new Event("input"));
fireEvent.click(within(form).getByRole("button", { name: "Other directory…" }));
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
fireEvent.input(raw, { target: { value: "/opt/somewhere" } });
await vi.advanceTimersByTimeAsync(250);
await drain();
@@ -148,13 +142,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
const form = await mount();
pickProject(form);
const radio = form.querySelector<HTMLInputElement>(
'input[name=worktree_branch_mode][value="existing"]',
)!;
radio.click();
fireEvent.click(within(form).getByLabelText("Existing branch"));
await drain();
const select = form.querySelector<HTMLSelectElement>(".branch-select")!;
const select = within(form).getByLabelText("Pick a branch") as HTMLSelectElement;
expect(urls.some((u) => u.includes("/api/branches?path="))).toBe(true);
const options = [...select.options];
expect(options.map((o) => o.value)).toEqual(["main", "spawn-v2", "free-branch"]);
@@ -169,10 +160,7 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
const form = await mount(undefined, (v) => changes.push(v));
await drain();
const radio = form.querySelector<HTMLInputElement>(
'input[name=worktree_branch_mode][value="existing"]',
)!;
radio.click();
fireEvent.click(within(form).getByLabelText("Existing branch"));
await drain();
const modes = changes.map((c) => (c as { worktreeBranchMode?: string }).worktreeBranchMode);
@@ -197,14 +185,14 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
it("collapses the advanced (worktree) section by default (#93)", async () => {
const form = await mount();
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
const toggle = within(form).getByRole("button", { name: /Advanced/ });
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
});
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
const form = await mount();
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
expect(within(form).getByText(/creates? a worktree/i)).toBeTruthy();
});
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
@@ -223,27 +211,25 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
document.body.appendChild(handle.el);
for (let i = 0; i < 40; i++) await Promise.resolve();
handle.el
.querySelectorAll<HTMLButtonElement>(".project-box")
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
fireEvent.click(within(handle.el).getByRole("button", { name: /^wt-91/ }));
await drain();
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
expect(within(handle.el).getByText(/no worktree/i)).toBeTruthy();
// Still collapsed — picking a project must not force it open.
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"false",
);
expect(
within(handle.el).getByRole("button", { name: /Advanced/ }).getAttribute("aria-expanded"),
).toBe("false");
});
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
const form = await mount();
const before = [...form.querySelectorAll(".field")];
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
fireEvent.click(within(form).getByRole("button", { name: /Advanced/ }));
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
"true",
);
expect(
within(form).getByRole("button", { name: /Advanced/ }).getAttribute("aria-expanded"),
).toBe("true");
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
});
@@ -256,16 +242,14 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
pickProject(handle.el);
handle.el
.querySelector<HTMLInputElement>('input[name=worktree_branch_mode][value="existing"]')!
.click();
fireEvent.click(within(handle.el).getByLabelText("Existing branch"));
handle.destroy();
gate.release?.();
await drain();
expect(
handle.el.querySelector<HTMLSelectElement>(".branch-select")!.options.length,
(within(handle.el).getByLabelText("Pick a branch") as HTMLSelectElement).options.length,
).toBe(0);
});
});
+29 -35
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { closeSpawnModal, openSpawnModal } from "./spawnModal.js";
@@ -26,8 +28,6 @@ async function flush(): Promise<void> {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
const modal = () => document.getElementById("modal-host");
describe("spawn modal", () => {
beforeEach(() => {
stubFetch();
@@ -41,66 +41,63 @@ describe("spawn modal", () => {
it("mounts the shared spawn form inside a dialog", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const host = modal()!;
expect(host).toBeTruthy();
expect(host.querySelector('[role="dialog"]')).toBeTruthy();
expect(host.querySelector("form.spawn-form")).toBeTruthy();
expect(host.querySelector("#spawn-title")?.textContent).toBe("Spawn session");
const dialog = await screen.findByRole("dialog", { name: "Spawn session" });
expect(within(dialog).getByRole("button", { name: "Spawn" })).toBeTruthy();
// The modal adds the Cancel the shared form doesn't ship.
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
expect(within(dialog).getByRole("button", { name: "Cancel" })).toBeTruthy();
});
it("opens with the project filter focused, harness before project", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
const filterInput = await screen.findByPlaceholderText("Filter projects…");
expect(filterInput).toHaveFocus();
const dialog = screen.getByRole("dialog");
const fieldLabels = within(dialog)
.getAllByText(/.+/, { selector: ".field-label" })
.map((el) => el.textContent);
expect(fieldLabels.slice(0, 2)).toEqual(["Harness", "Project"]);
});
it("titles a seeded open as a clone", async () => {
openSpawnModal({ seed: { command: "claude" }, onSuccess: () => {} });
await flush();
expect(modal()!.querySelector("#spawn-title")?.textContent).toBe("Spawn similar");
expect(await screen.findByRole("dialog", { name: "Spawn similar" })).toBeTruthy();
});
it("closes on Escape, on Cancel and on a backdrop click", async () => {
for (const dismiss of [
() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })),
() => (modal()!.querySelector(".form-actions .cancel") as HTMLButtonElement).click(),
() => modal()!.dispatchEvent(new MouseEvent("click", { bubbles: true })),
() => (modal()!.querySelector(".close") as HTMLButtonElement).click(),
() => fireEvent.keyDown(window, { key: "Escape" }),
() => fireEvent.click(screen.getByRole("button", { name: "Cancel" })),
() => fireEvent.click(screen.getByRole("dialog").parentElement!),
() => fireEvent.click(screen.getByRole("button", { name: "Close" })),
]) {
openSpawnModal({ onSuccess: () => {} });
await flush();
await screen.findByRole("dialog");
dismiss();
expect(modal()).toBeNull();
expect(screen.queryByRole("dialog")).toBeNull();
}
});
it("leaves no stray Escape handler behind after closing", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
await screen.findByRole("dialog");
closeSpawnModal();
// A second Escape with no modal up must not throw on the torn-down form.
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(modal()).toBeNull();
fireEvent.keyDown(window, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opening twice tears the first one down rather than stacking dialogs", async () => {
openSpawnModal({ onSuccess: () => {} });
await flush();
await screen.findByRole("dialog");
openSpawnModal({ onSuccess: () => {} });
await flush();
await screen.findByRole("dialog");
expect(document.querySelectorAll("#modal-host")).toHaveLength(1);
expect(document.querySelectorAll("form.spawn-form")).toHaveLength(1);
expect(screen.getAllByRole("dialog")).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "Spawn" })).toHaveLength(1);
});
it("closes before handing the created session to its caller", async () => {
@@ -127,15 +124,12 @@ describe("spawn modal", () => {
// A plain, worktree-free spawn: the point here is where the created
// session is handed over, not what the form validates.
seed: { command: "zsh", cwd: "/x", cwdRawVisible: true, worktreeEnabled: false },
onSuccess: (session) => seen.push({ session, stillOpen: modal() != null }),
onSuccess: (session) => seen.push({ session, stillOpen: screen.queryByRole("dialog") != null }),
});
await flush();
(modal()!.querySelector("form.spawn-form") as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
await flush();
fireEvent.submit(screen.getByRole("button", { name: "Spawn" }).closest("form")!);
await waitFor(() => expect(seen).toHaveLength(1));
expect(seen).toHaveLength(1);
expect(seen[0].session.id).toBe("s1");
expect(seen[0].stillOpen).toBe(false);
});
+15 -12
View File
@@ -1,4 +1,6 @@
// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { fireEvent, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DaemonLink } from "./daemonLink.js";
@@ -48,11 +50,13 @@ describe("status indicator", () => {
await link.start();
expect(ind.el.dataset.state).toBe("online");
const tip = ind.el.querySelector(".status-tip")!.textContent ?? "";
// `ind.el` is never appended to the document, so it's queried directly
// rather than through `screen`.
const tip = within(ind.el).getByRole("tooltip").textContent ?? "";
expect(tip).toContain("f89be4a716c7");
expect(tip).toContain("2026-09-02");
expect(tip).toContain("konishi");
expect(ind.el.getAttribute("aria-label")).toContain("Connected");
expect(ind.el).toHaveAccessibleName(/Connected/);
ind.dispose();
});
@@ -92,20 +96,19 @@ describe("restart overlay", () => {
});
const overlay = installRestartOverlay(link);
await link.start();
expect(document.querySelector(".restart-backdrop")).toBeNull();
expect(screen.queryByRole("alertdialog")).toBeNull();
reply = null;
link.noteDisconnected();
expect(document.querySelector(".restart-backdrop")).toBeNull(); // grace
expect(screen.queryByRole("alertdialog")).toBeNull(); // grace
sched.run();
await Promise.resolve();
const dialog = document.querySelector(".restart-modal");
expect(dialog?.textContent).toContain("Maestro is restarting");
expect(screen.getByRole("alertdialog")).toHaveTextContent("Maestro is restarting");
// Restarted on the same build: no reload, and the overlay goes away.
reply = info("aaa", "i2");
await link.probe();
expect(document.querySelector(".restart-backdrop")).toBeNull();
expect(screen.queryByRole("alertdialog")).toBeNull();
overlay.dispose();
});
@@ -127,12 +130,12 @@ describe("restart overlay", () => {
link.noteDisconnected();
sched.run();
await Promise.resolve();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
fireEvent.keyDown(window, { key: "n" });
expect(sawKey).toBe(false);
overlay.dispose();
link.stop();
window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
fireEvent.keyDown(window, { key: "n" });
expect(sawKey).toBe(true);
});
@@ -149,9 +152,9 @@ describe("restart overlay", () => {
link.noteDisconnected();
reply = info("bbb", "i2");
await link.probe();
const dialog = document.querySelector(".restart-modal");
expect(dialog?.textContent).toContain("Maestro was updated");
expect(dialog?.textContent).toContain("bbb");
const dialog = screen.getByRole("alertdialog");
expect(dialog).toHaveTextContent("Maestro was updated");
expect(dialog).toHaveTextContent("bbb");
overlay.dispose();
});
});
+166 -164
View File
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
vi.mock("./api.js", async () => {
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
@@ -99,16 +101,17 @@ describe("structured connection: data flow", () => {
it("converts long pasted text into a removable file and sends it without reply text", async () => {
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [], getData: () => "a".repeat(4000),
} });
input.dispatchEvent(paste);
fireEvent(input, paste);
expect(paste.defaultPrevented).toBe(true);
expect(input.value).toBe("");
expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt");
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
expect(input).toHaveValue("");
const attachments = conn.el.querySelector<HTMLElement>(".structured-attachments")!;
expect(within(attachments).getByText(/pasted-1\.txt/)).toBeInTheDocument();
fireEvent.submit(conn.el.querySelector("form")!);
await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt")));
expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 }));
conn.dispose();
@@ -118,7 +121,7 @@ describe("structured connection: data flow", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } });
conn.el.querySelector("textarea")!.dispatchEvent(paste);
fireEvent(within(conn.el).getByRole<HTMLTextAreaElement>("textbox"), paste);
expect(paste.defaultPrevented).toBe(false);
conn.dispose();
});
@@ -126,20 +129,19 @@ describe("structured connection: data flow", () => {
it("retains failed uploads for retry and lets the operator remove them", async () => {
vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed"));
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "Read this";
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [new File(["content"], "notes.txt")], getData: () => "",
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
expect(input.value).toBe("Read this");
fireEvent(input, paste);
fireEvent.submit(conn.el.querySelector("form")!);
await waitFor(() => expect(within(conn.el).getByText(/Upload failed/)).toBeInTheDocument());
expect(input).toHaveValue("Read this");
expect(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
expect(remove).not.toBeNull();
remove.click();
const remove = within(conn.el).getByRole("button", { name: "Remove notes.txt" });
fireEvent.click(remove);
expect(conn.el.querySelector<HTMLElement>(".structured-attachments")!.hidden).toBe(true);
conn.dispose();
});
@@ -159,8 +161,8 @@ describe("structured connection: data flow", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.textContent).toContain("hello");
expect(conn.el.textContent).toContain("hi there");
expect(within(conn.el).getByText("hello")).toBeInTheDocument();
expect(within(conn.el).getByText("hi there")).toBeInTheDocument();
conn.dispose();
});
@@ -223,12 +225,12 @@ describe("structured connection: data flow", () => {
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
const transcript = conn.el.querySelector<HTMLElement>(".structured-rows")!;
expect(transcript.querySelectorAll(".st-row")).toHaveLength(2);
expect(transcript.querySelector('[data-row-id="plan"]')).toBeNull();
expect(transcript.textContent).toContain("before plan");
expect(transcript.textContent).toContain("after plan");
expect(transcript.textContent).not.toContain("[plan]");
expect(within(transcript).getByText("before plan")).toBeInTheDocument();
expect(within(transcript).getByText("after plan")).toBeInTheDocument();
expect(within(transcript).queryByText(/\[plan\]/)).not.toBeInTheDocument();
expect(conn.el.querySelector(".structured-rail")).toBeNull();
conn.dispose();
});
@@ -267,14 +269,13 @@ describe("structured connection: data flow", () => {
},
});
expect(conn.el.querySelector(".structured-config-trigger")?.getAttribute("aria-label")).toBe(
"Session settings. Model: Opus. Effort: High",
);
(conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click();
const selects = conn.el.querySelectorAll<HTMLSelectElement>(".structured-config-select");
const trigger = within(conn.el).getByRole("button", {
name: "Session settings. Model: Opus. Effort: High",
});
fireEvent.click(trigger);
const selects = within(conn.el).getAllByRole<HTMLSelectElement>("combobox");
expect(selects).toHaveLength(2);
selects[0].value = "sonnet";
selects[0].dispatchEvent(new Event("change"));
fireEvent.change(selects[0], { target: { value: "sonnet" } });
expect(setStructuredConfig).toHaveBeenCalledWith("s1", {
config_id: "model",
value: "sonnet",
@@ -329,12 +330,12 @@ describe("structured connection: data flow", () => {
},
});
const transcript = conn.el.querySelector(".structured-rows")!;
const transcript = conn.el.querySelector<HTMLElement>(".structured-rows")!;
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"user:1",
"agent:1",
]);
expect(transcript.textContent).not.toContain("updated");
expect(within(transcript).queryByText(/updated/)).not.toBeInTheDocument();
ws.emit({
type: "projection_update",
@@ -359,7 +360,7 @@ describe("structured connection: data flow", () => {
expect([...transcript.querySelectorAll<HTMLElement>(".st-row")].map((el) => el.dataset.rowId)).toEqual([
"agent:1",
]);
expect(transcript.textContent).toContain("patched neighbor");
expect(within(transcript).getByText("patched neighbor")).toBeInTheDocument();
conn.dispose();
});
@@ -383,7 +384,7 @@ describe("structured connection: data flow", () => {
const rows = conn.el.querySelectorAll(".st-row-agent");
expect(rows).toHaveLength(1);
expect(rows[0].classList).not.toContain("st-row-enter");
expect(conn.el.textContent).toContain("partial done");
expect(within(conn.el).getByText("partial done")).toBeInTheDocument();
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
conn.dispose();
});
@@ -407,7 +408,7 @@ describe("structured connection: data flow", () => {
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("2 tool calls");
expect(within(groups[0]).getByText(/2 tool calls/)).toBeInTheDocument();
expect(groups[0].querySelector<HTMLElement>(".st-tool-group-body")!.getAttribute("aria-hidden")).toBe("true");
expect(conn.el.querySelector('[data-row-id="tool:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose();
@@ -436,7 +437,7 @@ describe("structured connection: data flow", () => {
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
// Header counts only tool rows, not intermediate text.
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
expect(within(groups[0]).getByText(/4 tool calls/)).toBeInTheDocument();
// Intermediate text (agent:2) is inside the group.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
// First text (agent:1) is outside — it precedes all tools.
@@ -522,7 +523,7 @@ describe("structured connection: data flow", () => {
ws.emit({ type: "reduced_state", state: idleState });
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
originalHead.click();
fireEvent.click(originalHead);
expect(originalHead.getAttribute("aria-expanded")).toBe("true");
const clickListener = vi.fn();
window.addEventListener("click", clickListener, { capture: true, once: true });
@@ -546,7 +547,7 @@ describe("structured connection: data flow", () => {
expect(patchedHead).not.toBe(originalHead);
expect(patchedHead.getAttribute("aria-expanded")).toBe("true");
expect(conn.el.querySelector<HTMLElement>(".st-tool-out")!.hidden).toBe(false);
expect(conn.el.textContent).toContain("still running");
expect(within(conn.el).getByText(/still running/)).toBeInTheDocument();
conn.dispose();
});
@@ -564,7 +565,7 @@ describe("structured connection: data flow", () => {
ws.emit({ type: "reduced_state", state: idleState });
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
originalHead.focus();
expect(document.activeElement).toBe(originalHead);
expect(originalHead).toHaveFocus();
ws.emit({
type: "projection_update",
@@ -577,7 +578,7 @@ describe("structured connection: data flow", () => {
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
expect(patchedHead).not.toBe(originalHead);
expect(document.activeElement).toBe(patchedHead);
expect(patchedHead).toHaveFocus();
conn.dispose();
});
@@ -593,7 +594,7 @@ describe("structured connection: data flow", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.focus();
ws.emit({
@@ -605,7 +606,7 @@ describe("structured connection: data flow", () => {
state: idleState,
});
expect(document.activeElement).toBe(input);
expect(input).toHaveFocus();
conn.dispose();
});
});
@@ -640,25 +641,24 @@ describe("structured connection: turn/composer gating", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
const menu = conn.el.querySelector<HTMLElement>(".structured-command-menu")!;
input.value = "/";
input.dispatchEvent(new Event("input"));
fireEvent.input(input, { target: { value: "/" } });
expect(menu.hidden).toBe(false);
expect([...menu.querySelectorAll(".structured-command-name")].map((node) => node.textContent)).toEqual([
"/blitz",
"/yolo",
]);
expect(menu.querySelector(".structured-command-hint")!.textContent).toBe("<milestone>");
expect(within(menu).getByText("<milestone>")).toBeInTheDocument();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
expect(input.value).toBe("/yolo ");
fireEvent.keyDown(input, { key: "ArrowDown", bubbles: true });
fireEvent.keyDown(input, { key: "Enter", bubbles: true });
expect(input).toHaveValue("/yolo ");
expect(menu.hidden).toBe(true);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true });
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "/yolo ");
conn.dispose();
});
@@ -681,15 +681,14 @@ describe("structured connection: turn/composer gating", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
input.value = "/hum";
input.dispatchEvent(new Event("input"));
const options = conn.el.querySelectorAll<HTMLElement>(".structured-command-option");
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
fireEvent.input(input, { target: { value: "/hum" } });
const options = within(conn.el).getAllByRole("option");
expect(options).toHaveLength(1);
expect(options[0].textContent).toContain("/humanizer");
options[0].click();
expect(input.value).toBe("/humanizer ");
expect(document.activeElement).toBe(input);
fireEvent.click(options[0]);
expect(input).toHaveValue("/humanizer ");
expect(input).toHaveFocus();
conn.dispose();
});
@@ -711,18 +710,18 @@ describe("structured connection: turn/composer gating", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "asd /s dsa";
input.setSelectionRange(6, 6);
input.dispatchEvent(new Event("input"));
fireEvent.input(input);
const options = conn.el.querySelectorAll<HTMLElement>(".structured-command-option");
const options = within(conn.el).getAllByRole("option");
expect([...options].map((option) => option.querySelector(".structured-command-name")!.textContent)).toEqual([
"/security-review",
"/status",
]);
options[0].click();
expect(input.value).toBe("asd /security-review dsa");
fireEvent.click(options[0]);
expect(input).toHaveValue("asd /security-review dsa");
expect(input.selectionStart).toBe(input.value.indexOf("dsa"));
expect(input.selectionEnd).toBe(input.selectionStart);
conn.dispose();
@@ -740,12 +739,12 @@ describe("structured connection: turn/composer gating", () => {
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "before /stale after";
input.setSelectionRange(9, 9);
input.dispatchEvent(new Event("input"));
conn.el.querySelector<HTMLElement>(".structured-command-option")!.click();
expect(input.value).toBe("before /status after");
fireEvent.input(input);
fireEvent.click(within(conn.el).getByRole("option"));
expect(input).toHaveValue("before /status after");
conn.dispose();
});
@@ -754,16 +753,18 @@ describe("structured connection: turn/composer gating", () => {
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
// happy-dom computes an empty accessible name for hidden elements, so a
// role+name query can't find this button while it starts out hidden.
const cancel = conn.el.querySelector(".structured-cancel")!;
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
expect(input).not.toBeDisabled();
expect(cancel).toBeVisible();
expect(within(conn.el).getByRole("button", { name: "Send" })).toBeInTheDocument();
ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
expect(input).not.toBeDisabled();
expect(cancel).not.toBeVisible();
expect(within(conn.el).getByRole("button", { name: "Send" })).toBeInTheDocument();
conn.dispose();
});
@@ -772,7 +773,7 @@ describe("structured connection: turn/composer gating", () => {
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
const activity = conn.el.querySelector<HTMLElement>(".structured-activity")!;
const activity = within(conn.el).getByRole("status", { hidden: true });
ws.emit({
type: "reduced_state",
state: {
@@ -786,7 +787,7 @@ describe("structured connection: turn/composer gating", () => {
usage: { used: 12_400, size: 200_000 },
},
});
expect(activity.hidden).toBe(false);
expect(activity).toBeVisible();
expect(activity.textContent).toContain("Thinking · 0s · 12.4k context / 200k context");
ws.emit({
@@ -798,7 +799,7 @@ describe("structured connection: turn/composer gating", () => {
});
expect(activity.textContent).toContain("Running 2 tools");
ws.emit({ type: "reduced_state", state: idleState });
expect(activity.hidden).toBe(true);
expect(activity).not.toBeVisible();
conn.dispose();
});
@@ -808,21 +809,21 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "do the thing";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing");
expect(input.value).toBe("");
expect(input).toHaveValue("");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
ws.emit({ type: "reduced_state", state: idleState });
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
input.value = "retry me";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
await Promise.resolve();
await Promise.resolve();
expect(input.value).toBe("retry me");
expect(conn.el.textContent).toContain("boom");
expect(input).toHaveValue("retry me");
expect(within(conn.el).getByText(/boom/)).toBeInTheDocument();
conn.dispose();
});
@@ -833,17 +834,17 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const send = conn.el.querySelector(".structured-send") as HTMLButtonElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
const send = within(conn.el).getByRole("button", { name: "Send" });
const form = conn.el.querySelector(".structured-composer") as HTMLFormElement;
input.value = "only once";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
fireEvent.submit(form);
input.value = "queued next";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
fireEvent.submit(form);
expect(input.disabled).toBe(false);
expect(send.disabled).toBe(false);
expect(input).not.toBeDisabled();
expect(send).not.toBeDisabled();
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
const queued = conn.el.querySelectorAll(".st-row-queued");
@@ -857,10 +858,10 @@ describe("structured connection: turn/composer gating", () => {
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
const submit = (text: string) => {
input.value = text;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
};
submit("first");
@@ -891,9 +892,9 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "first prompt";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
ws.emit({ type: "reduced_state", state: idleState });
input.value = "newer draft";
@@ -901,8 +902,8 @@ describe("structured connection: turn/composer gating", () => {
reject(new Error("late failure"));
await Promise.resolve().then(() => Promise.resolve());
expect(input.value).toBe("newer draft");
expect(conn.el.textContent).toContain("late failure");
expect(input).toHaveValue("newer draft");
expect(within(conn.el).getByText(/late failure/)).toBeInTheDocument();
conn.dispose();
});
@@ -916,21 +917,21 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "first prompt";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
ws.emit({ type: "reduced_state", state: idleState });
input.value = "second prompt";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
rejectFirst(new Error("stale failure"));
await Promise.resolve().then(() => Promise.resolve());
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
expect(input.value).toBe("");
expect(input.disabled).toBe(false);
expect(conn.el.textContent).not.toContain("stale failure");
expect(input).toHaveValue("");
expect(input).not.toBeDisabled();
expect(within(conn.el).queryByText(/stale failure/)).not.toBeInTheDocument();
conn.dispose();
});
@@ -943,16 +944,16 @@ describe("structured connection: turn/composer gating", () => {
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "do not restore";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
conn.dispose();
input.value = "detached draft";
reject(new Error("late failure"));
await Promise.resolve().then(() => Promise.resolve());
expect(input.value).toBe("detached draft");
expect(input).toHaveValue("detached draft");
expect(conn.el.querySelector(".structured-error")).toBeNull();
});
@@ -962,7 +963,7 @@ describe("structured connection: turn/composer gating", () => {
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: true, active_tool_calls: [] } } });
(conn.el.querySelector(".structured-cancel") as HTMLButtonElement).click();
fireEvent.click(within(conn.el).getByRole("button", { name: "Cancel turn" }));
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
conn.dispose();
});
@@ -984,9 +985,9 @@ describe("structured connection: approvals", () => {
},
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
const buttons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
expect(buttons).toHaveLength(3);
buttons[1].click(); // "Allow for session"
fireEvent.click(buttons[1]); // "Allow for session"
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_for_session");
conn.dispose();
});
@@ -1003,7 +1004,7 @@ describe("structured connection: approvals", () => {
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
expect(conn.el.querySelectorAll(".st-approval-acts button")).toHaveLength(3);
expect(within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ })).toHaveLength(3);
// Resolved on another client: state clears with no transcript delta at all.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
@@ -1032,10 +1033,10 @@ describe("structured connection: approvals", () => {
// 2+ tools in the turn → tool group exists; resolved approval is inside it.
const group = conn.el.querySelector(".st-tool-group");
expect(group).not.toBeNull();
const compact = group!.querySelector(".st-approval-compact");
const compact = group!.querySelector<HTMLElement>(".st-approval-compact");
expect(compact).not.toBeNull();
expect(compact!.textContent).toContain("Write file.ts");
expect(compact!.textContent).toContain("approved");
expect(within(compact!).getByText(/Write file\.ts/)).toBeInTheDocument();
expect(within(compact!).getByText(/approved/)).toBeInTheDocument();
// No full-card approval anywhere on the transcript.
expect(conn.el.querySelector(".st-approval")).toBeNull();
conn.dispose();
@@ -1064,7 +1065,9 @@ describe("structured connection: approvals", () => {
// Pending approval stays outside the tool group as a full card.
const approvalCard = conn.el.querySelector(".st-approval");
expect(approvalCard).not.toBeNull();
expect(conn.el.querySelectorAll(".st-approval-acts button").length).toBeGreaterThan(0);
expect(
within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ }).length,
).toBeGreaterThan(0);
// It should not be inside the tool group.
const group = conn.el.querySelector(".st-tool-group");
expect(group).not.toBeNull();
@@ -1084,12 +1087,12 @@ describe("structured connection: approvals", () => {
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
const buttons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
// Approval is superseded before the click is handled (buttons already
// disabled by the click handler is not the path under test here — this
// simulates the row having already flipped to "answered" underneath it).
ws.emit({ type: "reduced_state", state: idleState });
buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
fireEvent.click(buttons[0]);
expect(resolveStructuredApproval).not.toHaveBeenCalled();
conn.dispose();
});
@@ -1107,13 +1110,13 @@ describe("structured connection: approvals", () => {
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
expect(buttons[0].disabled).toBe(true);
const buttons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
fireEvent.click(buttons[0]);
expect(buttons[0]).toBeDisabled();
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].disabled).toBe(false);
expect(conn.el.textContent).toContain("network down");
expect(buttons[0]).not.toBeDisabled();
expect(buttons[1]).not.toBeDisabled();
expect(within(conn.el).getByText(/network down/)).toBeInTheDocument();
conn.dispose();
});
@@ -1133,8 +1136,8 @@ describe("structured connection: approvals", () => {
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
const buttons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
fireEvent.click(buttons[0]);
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_once");
// Superseded by a new approval before the in-flight POST settles.
@@ -1146,10 +1149,10 @@ describe("structured connection: approvals", () => {
reject(new Error("too late"));
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
expect(conn.el.textContent).not.toContain("too late");
const newButtons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
expect(within(conn.el).queryByText(/too late/)).not.toBeInTheDocument();
const newButtons = within(conn.el).getAllByRole("button", { name: /^(Allow once|Allow for session|Deny)$/ });
expect(newButtons).toHaveLength(3);
expect(newButtons[0].disabled).toBe(false);
expect(newButtons[0]).not.toBeDisabled();
conn.dispose();
});
});
@@ -1175,7 +1178,7 @@ describe("structured connection: reconnection/error/loading", () => {
type: "transcript_snapshot",
snapshot: { cursor: 0, rows: [row("user:1", "user", { content: { type: "text", text: "before" } })] },
});
expect(conn.el.textContent).toContain("before");
expect(within(conn.el).getByText("before")).toBeInTheDocument();
first.drop(1006, "");
expect(state()).toBe("connecting"); // "reconnecting…"
@@ -1184,7 +1187,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
expect(state()).toBe("open");
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
expect(conn.el.textContent).not.toContain("before"); // rebuilt wholesale, not merged
expect(within(conn.el).queryByText("before")).not.toBeInTheDocument(); // rebuilt wholesale, not merged
conn.dispose();
});
@@ -1238,7 +1241,7 @@ describe("structured connection: reconnection/error/loading", () => {
type: "transcript_snapshot",
snapshot: { cursor: 2, rows: [toolRow("tool:1", "before"), diffRow("diff:1", "first")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) fireEvent.click(head);
first.drop();
vi.advanceTimersByTime(1000);
@@ -1272,7 +1275,7 @@ describe("structured connection: reconnection/error/loading", () => {
type: "transcript_snapshot",
snapshot: { cursor: 2, rows: [toolRow("tool:gone", "gone"), toolRow("tool:plain", "plain")] },
});
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) head.click();
for (const head of conn.el.querySelectorAll<HTMLButtonElement>(".st-tool-head")) fireEvent.click(head);
ws.emit({
type: "transcript_snapshot",
@@ -1300,20 +1303,20 @@ describe("structured connection: reconnection/error/loading", () => {
first.open();
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "survive reconnect";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
first.drop();
vi.advanceTimersByTime(1000);
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
conn.dispose();
});
@@ -1325,9 +1328,9 @@ describe("structured connection: reconnection/error/loading", () => {
first.open();
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "finishes while disconnected";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
await Promise.resolve().then(() => Promise.resolve());
first.drop();
@@ -1336,7 +1339,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
conn.dispose();
});
@@ -1352,27 +1355,27 @@ describe("structured connection: reconnection/error/loading", () => {
first.open();
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "accepted after reconnect";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
first.drop();
vi.advanceTimersByTime(1000);
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
accept();
await Promise.resolve().then(() => Promise.resolve());
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
expect(second.closed).toBe(true);
third.open();
third.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
third.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
conn.dispose();
});
@@ -1391,9 +1394,9 @@ describe("structured connection: reconnection/error/loading", () => {
});
first.open();
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "accepted during backoff";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
first.drop();
vi.advanceTimersByTime(1000);
@@ -1407,7 +1410,7 @@ describe("structured connection: reconnection/error/loading", () => {
reconciliation.open();
reconciliation.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input).not.toBeDisabled();
conn.dispose();
expect(reconciliation.closed).toBe(true);
});
@@ -1423,9 +1426,9 @@ describe("structured connection: reconnection/error/loading", () => {
const conn = mount({ sockets: [first, second, late], shouldReconnect: () => true });
first.open();
first.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
input.value = "accepted after disposal";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
fireEvent.keyDown(input, { key: "Enter", bubbles: true, cancelable: true });
first.drop();
vi.advanceTimersByTime(1000);
@@ -1595,11 +1598,10 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
values.top = top;
if (manuallyScrolled) scroll.dispatchEvent(new Event("scroll"));
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
if (manuallyScrolled) fireEvent.scroll(scroll);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.value = "one\ntwo\nthree";
input.dispatchEvent(new Event("input"));
fireEvent.input(input, { target: { value: "one\ntwo\nthree" } });
values.height = 1080;
runFrames();
@@ -1617,7 +1619,7 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
values.top = 180;
scroll.dispatchEvent(new Event("scroll"));
fireEvent.scroll(scroll);
values.height = 1120;
ws.emit({
type: "projection_update",
@@ -1645,7 +1647,7 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
expect(values.top).toBe(180);
conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!.click();
fireEvent.click(conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!);
values.height = 1260;
runFrames();
expect(values.top).toBe(180);
@@ -1661,7 +1663,7 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
values.top = 750;
scroll.dispatchEvent(new Event("scroll"));
fireEvent.scroll(scroll);
values.height = 1100;
ws.emit({
type: "projection_update",
@@ -1686,7 +1688,7 @@ describe("structured connection: transcript scrolling", () => {
runFrames();
values.top = 160;
scroll.dispatchEvent(new Event("scroll"));
fireEvent.scroll(scroll);
first.drop();
vi.advanceTimersByTime(1000);
second.open();
@@ -1717,9 +1719,9 @@ describe("structured connection: transcript scrolling", () => {
conn.onUnmount();
expect(frames.size).toBe(0);
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
Object.defineProperty(input, "scrollHeight", { configurable: true, value: 120 });
input.dispatchEvent(new Event("input"));
fireEvent.input(input);
expect(frames.size).toBe(0);
conn.onMount({ focus: false });
expect(frames.size).toBe(2);
@@ -1753,7 +1755,7 @@ describe("structured connection: pane lifecycle", () => {
it("carries the same off-canvas sessions button as the terminal attach header", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const btn = conn.el.querySelector(".sessions-btn") as HTMLButtonElement;
const btn = within(conn.el).getByRole<HTMLButtonElement>("button", { name: "Show sessions" });
expect(btn).not.toBeNull();
expect(btn.getAttribute("aria-label")).toBe("Show sessions");
expect(btn.type).toBe("button");
@@ -1761,7 +1763,7 @@ describe("structured connection: pane lifecycle", () => {
let toggled = 0;
const onToggle = () => toggled++;
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
btn.click();
fireEvent.click(btn);
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
expect(toggled).toBe(1);
conn.dispose();
@@ -1774,11 +1776,11 @@ describe("structured connection: pane lifecycle", () => {
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
conn.focusTerminal();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).toBe(input);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
expect(input).toHaveFocus();
ws.emit({ type: "reduced_state", state: idleState });
conn.focusTerminal();
expect(document.activeElement).toBe(input);
expect(input).toHaveFocus();
conn.dispose();
});
@@ -1821,8 +1823,8 @@ describe("structured connection: deferred mount focus", () => {
conn.onUnmount(); // parked again before the frame below runs
runFrames();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
expect(input).not.toHaveFocus();
conn.dispose();
});
@@ -1838,8 +1840,8 @@ describe("structured connection: deferred mount focus", () => {
conn.onMount({ focus: false }); // the pane that actually won focus, without it
runFrames(); // both frames fire; only the latest should have any say
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
expect(input).not.toHaveFocus();
conn.dispose();
});
@@ -1853,8 +1855,8 @@ describe("structured connection: deferred mount focus", () => {
conn.onMount({ focus: true });
runFrames();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).toBe(input);
const input = within(conn.el).getByRole<HTMLTextAreaElement>("textbox");
expect(input).toHaveFocus();
conn.dispose();
});
});
+115 -94
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { fireEvent, waitFor, within } from "@testing-library/react";
import {
buildToolGroup,
@@ -23,9 +24,10 @@ describe("buildTranscriptRow: user/agent/thought", () => {
row("user", { content: { type: "text", text: "hi <b>there</b>" } }),
);
expect(el.className).toContain("st-row-user");
expect(el.textContent).toContain("hi <b>there</b>");
expect(within(el).getByText("hi <b>there</b>")).toBeTruthy();
// Neither element exists at all — no accessible text/role to query by, so
// this stays a direct DOM check for "never actual markup from user input".
expect(el.querySelector(".st-who")).toBeNull();
// never actual markup from user input
expect(el.querySelector("b")).toBeNull();
});
@@ -34,7 +36,7 @@ describe("buildTranscriptRow: user/agent/thought", () => {
row("agent_text", { content: { type: "text", text: "one **two** `three`\n\nfour" } }),
);
expect(el.className).toContain("st-row-agent");
const ps = el.querySelectorAll("p");
const ps = within(el).getAllByRole("paragraph");
expect(ps).toHaveLength(2);
expect(ps[0].innerHTML).toContain("<strong>two</strong>");
expect(ps[0].innerHTML).toContain("<code>three</code>");
@@ -48,18 +50,20 @@ describe("buildTranscriptRow: user/agent/thought", () => {
);
expect(el.className).toContain("st-row-thought");
expect(el.textContent).toBe("Planning comply tool commentary update");
expect(el.querySelector("strong")?.textContent).toBe("Planning comply tool commentary update");
expect(
within(el).getByText("Planning comply tool commentary update", { selector: "strong" }),
).toBeTruthy();
});
it("falls back to a labelled placeholder for non-text content, never crashing", () => {
const el = buildTranscriptRow(row("user", { content: { type: "image" } }));
expect(el.textContent).toContain("[image]");
expect(within(el).getByText("[image]")).toBeTruthy();
});
it("never clips text: a very long user message is not truncated", () => {
const long = "x".repeat(5000);
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
expect(el.textContent).toContain(long);
expect(within(el).getByText(long)).toBeTruthy();
});
it("copies message blocks on click and supports keyboard activation", async () => {
@@ -69,12 +73,10 @@ describe("buildTranscriptRow: user/agent/thought", () => {
row("agent_text", { content: { type: "text", text: "copy **this** exactly" } }),
);
expect(el.getAttribute("role")).toBe("button");
el.click();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith("copy **this** exactly");
el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await Promise.resolve();
expect(writeText).toHaveBeenCalledTimes(2);
fireEvent.click(el);
await waitFor(() => expect(writeText).toHaveBeenCalledWith("copy **this** exactly"));
fireEvent.keyDown(el, { key: "Enter" });
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2));
});
});
@@ -116,29 +118,30 @@ describe("buildTranscriptRow: tool calls", () => {
status: "completed",
}),
);
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Read");
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("src/lib.rs");
const head = within(el).getByRole("button");
expect(within(head).getByText("Read")).toBeTruthy();
expect(within(head).getByText("src/lib.rs")).toBeTruthy();
// No badge text/role exists at all in this case — nothing accessible to
// query for its absence, so this stays a direct DOM check.
expect(head.querySelector(".st-tool-meta")).toBeNull();
});
it("falls back to the kind label and full title when the title has no Name(arg) shape", () => {
const el = buildTranscriptRow(row("tool_call", { title: "does something odd", kind: "execute" }));
const head = el.querySelector(".st-tool-head")!;
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Execute");
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("does something odd");
const head = within(el).getByRole("button");
expect(within(head).getByText("Execute")).toBeTruthy();
expect(within(head).getByText("does something odd")).toBeTruthy();
});
it("shows a running badge for an in-progress call", () => {
const el = buildTranscriptRow(row("tool_call", { title: "Bash(sleep 1)", status: "in_progress" }));
const meta = el.querySelector(".st-tool-meta")!;
expect(meta.textContent).toBe("running");
const meta = within(el).getByText("running");
expect(meta.className).toContain("st-tool-meta-running");
});
it("shows a failed badge for a failed call", () => {
const el = buildTranscriptRow(row("tool_call", { title: "Bash(false)", status: "failed" }));
expect(el.querySelector(".st-tool-meta")!.textContent).toBe("failed");
expect(within(el).getByText("failed")).toBeTruthy();
});
it("starts collapsed and expands its text output on click", () => {
@@ -149,15 +152,17 @@ describe("buildTranscriptRow: tool calls", () => {
content: [{ type: "content", content: { type: "text", text: "match found" } }],
}),
);
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
const head = within(el).getByRole("button");
// The disclosure wrapper has no accessible role or text of its own before
// it is expanded, so it's located and inspected via raw DOM throughout.
const out = el.querySelector(".st-tool-out") as HTMLElement;
expect(out.hidden).toBe(true);
expect(head.getAttribute("aria-expanded")).toBe("false");
head.click();
fireEvent.click(head);
expect(out.hidden).toBe(false);
expect(head.getAttribute("aria-expanded")).toBe("true");
expect(out.textContent).toContain("match found");
head.click();
expect(within(out).getByText("match found")).toBeTruthy();
fireEvent.click(head);
expect(out.hidden).toBe(false);
expect(out.getAttribute("aria-hidden")).toBe("true");
expect(out.inert).toBe(true);
@@ -166,8 +171,8 @@ describe("buildTranscriptRow: tool calls", () => {
it("does not expand when there is nothing to show", () => {
const el = buildTranscriptRow(row("tool_call", { title: "Read(x)", status: "completed" }));
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
head.click();
const head = within(el).getByRole("button");
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("false");
});
@@ -179,10 +184,13 @@ describe("buildTranscriptRow: tool calls", () => {
rawOutput: { formatted_output: "ok", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("printf ok");
expect(el.querySelector(".st-tool-fact")!.textContent).toBe("Exit code0");
const raw = el.querySelector(".st-tool-raw") as HTMLDetailsElement;
fireEvent.click(within(el).getByRole("button"));
expect(within(el).getByText("printf ok")).toBeTruthy();
// "Exit code" and its value are separate elements with no shared text
// node, so getByText can't match "Exit code0" — locate via the label.
const fact = within(el).getByText("Exit code").closest(".st-tool-fact");
expect(fact?.textContent).toBe("Exit code0");
const raw = within(el).getByText("Raw JSON").closest("details") as HTMLDetailsElement;
expect(raw.open).toBe(false);
expect(raw.textContent).toContain('"timeout_ms": 1000');
expect(raw.textContent).toContain('"exit_code": 0');
@@ -196,14 +204,13 @@ describe("buildTranscriptRow: tool calls", () => {
rawOutput: { result: { count: 2 }, error: null },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const labels = [
...el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
].map((node) => node.querySelector(".st-tool-detail-label")?.textContent);
fireEvent.click(within(el).getByRole("button"));
const labels = within(el)
.getAllByText(/^(Arguments|Result)$/)
.map((node) => node.textContent);
expect(labels).toEqual(["Arguments", "Result"]);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"server": "github"');
const raw = within(el).getByText("Raw JSON").closest("details");
expect(raw?.textContent).toContain('"server": "github"');
});
it("renders a recognized web search query", () => {
@@ -213,23 +220,26 @@ describe("buildTranscriptRow: tool calls", () => {
rawInput: { type: "webSearch", query: "Agent Client Protocol", action: { type: "search" } },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-tool-detail-label")!.textContent).toBe("Query");
expect(el.querySelector(".st-tool-detail pre")!.textContent).toBe("Agent Client Protocol");
fireEvent.click(within(el).getByRole("button"));
expect(within(el).getByText("Query")).toBeTruthy();
expect(within(el).getByText("Agent Client Protocol")).toBeTruthy();
});
it("falls back to raw JSON for an unknown provider shape", () => {
const el = buildTranscriptRow(
row("tool_call", { title: "Custom", rawInput: { peculiar: [1, 2] }, rawOutput: "done" }),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
fireEvent.click(within(el).getByRole("button"));
// No provider-specific detail block has any known label to query by when
// none matched — asserting the absence of the whole class stays raw DOM.
expect(
el.querySelectorAll(
".st-tool-out > .st-disclosure-clip > .st-disclosure-inner > .st-tool-detail",
),
).toHaveLength(0);
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"peculiar"');
expect(el.querySelector(".st-tool-raw")!.textContent).toContain('"done"');
const raw = within(el).getByText("Raw JSON").closest("details");
expect(raw?.textContent).toContain('"peculiar"');
expect(raw?.textContent).toContain('"done"');
});
it("renders standardized content before provider-specific and raw data", () => {
@@ -245,7 +255,9 @@ describe("buildTranscriptRow: tool calls", () => {
rawOutput: { formatted_output: "typed result", exit_code: 0 },
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
fireEvent.click(within(el).getByRole("button"));
// Ordering across block kinds has no accessible query of its own — this
// stays a direct DOM structure check.
const inner = el.querySelector(".st-tool-out > .st-disclosure-clip > .st-disclosure-inner")!;
expect([...inner.children].map((node) => node.className)).toEqual([
"st-tool-text st-copyable",
@@ -255,8 +267,8 @@ describe("buildTranscriptRow: tool calls", () => {
"st-tool-fact",
"st-tool-raw",
]);
expect(el.querySelector(".st-tool-resource")!.textContent).toContain("file:///tmp/report");
expect(el.querySelector(".st-tool-terminal")!.textContent).toBe("Terminal · term-1");
expect(within(el).getByText("file:///tmp/report")).toBeTruthy();
expect(within(el).getByText("Terminal · term-1")).toBeTruthy();
});
it("renders safe typed image and audio blocks as media controls", () => {
@@ -269,9 +281,14 @@ describe("buildTranscriptRow: tool calls", () => {
],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect((el.querySelector(".st-tool-image") as HTMLImageElement).src).toBe("data:image/png;base64,aW1hZ2U=");
expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe("data:audio/mpeg;base64,YXVkaW8=");
fireEvent.click(within(el).getByRole("button"));
expect((within(el).getByRole("img") as HTMLImageElement).src).toBe(
"data:image/png;base64,aW1hZ2U=",
);
// <audio> has no ARIA role mapping, so it isn't reachable via getByRole.
expect((el.querySelector(".st-tool-audio") as HTMLAudioElement).src).toBe(
"data:audio/mpeg;base64,YXVkaW8=",
);
});
});
@@ -284,19 +301,21 @@ describe("buildToolGroup", () => {
const children = rows.map((tool) => buildTranscriptRow(tool));
const onExpanded = vi.fn();
const group = buildToolGroup("turn:u1", rows, children, false, onExpanded);
const head = group.querySelector<HTMLButtonElement>(".st-tool-group-head")!;
const head = within(group).getByRole("button", { name: "2 tool calls" });
// The body has no accessible role/text of its own while collapsed, so its
// disclosure state is inspected via raw DOM.
const body = group.querySelector<HTMLElement>(".st-tool-group-body")!;
expect(head.textContent).toContain("2 tool calls");
expect(body.getAttribute("aria-hidden")).toBe("true");
expect(body.inert).toBe(true);
head.click();
fireEvent.click(head);
expect(body.getAttribute("aria-hidden")).toBe("false");
expect(body.classList.contains("is-expanded")).toBe(true);
expect(onExpanded).toHaveBeenCalledWith(true);
const toolHeads = group.querySelectorAll<HTMLButtonElement>(".st-tool-head");
toolHeads[1].click();
expect(toolHeads[0].getAttribute("aria-expanded")).toBe("false");
expect(toolHeads[1].getAttribute("aria-expanded")).toBe("true");
const firstHead = within(group).getByRole("button", { name: "Read a.ts" });
const secondHead = within(group).getByRole("button", { name: "Bash test" });
fireEvent.click(secondHead);
expect(firstHead.getAttribute("aria-expanded")).toBe("false");
expect(secondHead.getAttribute("aria-expanded")).toBe("true");
});
});
@@ -309,14 +328,10 @@ describe("buildTranscriptRow: tool diffs", () => {
content: [{ type: "diff", path: "/repo/x.rs", oldText: "a\nb\n", newText: "a\nc\n" }],
}),
);
const meta = el.querySelector(".st-tool-meta")!;
expect(meta.textContent).toContain("+1");
expect(meta.textContent).toContain("−1");
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const diff = el.querySelector(".st-diff") as HTMLElement;
expect(diff).not.toBeNull();
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("+ c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("- b");
expect(within(el).getByText("+1 −1")).toBeTruthy();
fireEvent.click(within(el).getByRole("button"));
expect(within(el).getByText("+ c")).toBeTruthy();
expect(within(el).getByText("- b")).toBeTruthy();
});
it("gives the diff its own horizontally-scrolling container", () => {
@@ -326,9 +341,10 @@ describe("buildTranscriptRow: tool diffs", () => {
content: [{ type: "diff", path: "/repo/x.rs", newText: "new file\n" }],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
fireEvent.click(within(el).getByRole("button"));
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the
// stylesheet keys `overflow-x: auto` off, not a computed style.
// stylesheet keys `overflow-x: auto` off, not a computed style. The
// scroller itself has no accessible role/text, hence the raw query.
expect(el.querySelector(".st-diff")!.classList).toContain("st-diff");
});
@@ -339,8 +355,8 @@ describe("buildTranscriptRow: tool diffs", () => {
content: [{ type: "diff", path: "/repo/x.rs", oldText: "same\n", newText: "same" }],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
expect(el.querySelector(".st-ln-eof")!.textContent).toBe("\\ No newline at end of file");
fireEvent.click(within(el).getByRole("button"));
expect(within(el).getByText("\\ No newline at end of file")).toBeTruthy();
});
});
@@ -363,9 +379,8 @@ describe("buildTranscriptRow: approval", () => {
pending: true,
onDecide: () => Promise.resolve(),
});
const buttons = el.querySelectorAll(".st-approval-acts button");
expect(buttons).toHaveLength(3);
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
expect(within(el).getAllByRole("button")).toHaveLength(3);
expect(within(el).getByText("/etc/x")).toBeTruthy();
});
it("renders a compact one-liner once resolved", () => {
@@ -373,7 +388,7 @@ describe("buildTranscriptRow: approval", () => {
pending: false,
onDecide: () => Promise.resolve(),
});
expect(el.querySelector(".st-approval-acts")).toBeNull();
expect(within(el).queryByRole("button")).toBeNull();
expect(el.classList.contains("st-approval-compact")).toBe(true);
expect(el.textContent).toContain("approved");
});
@@ -381,15 +396,16 @@ describe("buildTranscriptRow: approval", () => {
it("invokes onDecide with the clicked decision and disables the buttons", () => {
const onDecide = vi.fn().mockResolvedValue(undefined);
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[2].click();
fireEvent.click(within(el).getByRole("button", { name: "Deny" }));
expect(onDecide).toHaveBeenCalledWith("deny");
expect(buttons[0].disabled).toBe(true);
expect(within(el).getByRole<HTMLButtonElement>("button", { name: "Allow once" }).disabled).toBe(
true,
);
});
it("treats a row with no approval state as not pending, defensively", () => {
const el = buildTranscriptRow(approvalRow(allThreeOptions));
expect(el.querySelector(".st-approval-acts")).toBeNull();
expect(within(el).queryByRole("button")).toBeNull();
});
it("omits Allow for session when the request offers no allow_always option", () => {
@@ -400,7 +416,9 @@ describe("buildTranscriptRow: approval", () => {
]),
{ pending: true, onDecide: () => Promise.resolve() },
);
const labels = [...el.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent);
const labels = within(el)
.getAllByRole("button")
.map((b) => b.textContent);
expect(labels).toEqual(["Allow once", "Deny"]);
});
@@ -409,9 +427,11 @@ describe("buildTranscriptRow: approval", () => {
approvalRow([{ optionId: "r2", name: "Reject always", kind: "reject_always" }]),
{ pending: true, onDecide: () => Promise.resolve() },
);
expect([...rejectAlwaysOnly.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent)).toEqual([
"Deny",
]);
expect(
within(rejectAlwaysOnly)
.getAllByRole("button")
.map((b) => b.textContent),
).toEqual(["Deny"]);
});
it("shows a 'no decision offered' note instead of an empty action row when nothing matches", () => {
@@ -421,31 +441,32 @@ describe("buildTranscriptRow: approval", () => {
});
// allow_once is offered, so this specific case still shows a button —
// use a kind the client doesn't map to any decision at all.
expect(el.querySelectorAll(".st-approval-acts button")).toHaveLength(1);
expect(within(el).getAllByRole("button")).toHaveLength(1);
const none = buildTranscriptRow(approvalRow([]), { pending: true, onDecide: () => Promise.resolve() });
expect(none.querySelector(".st-approval-acts")).toBeNull();
expect(none.querySelector(".st-approval-resolved")!.textContent).toBe("no decision offered");
expect(within(none).queryByRole("button")).toBeNull();
expect(within(none).getByText("no decision offered")).toBeTruthy();
});
it("re-enables the buttons when onDecide rejects", async () => {
const onDecide = vi.fn().mockRejectedValue(new Error("network down"));
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
expect(buttons[0].disabled).toBe(true);
await Promise.resolve().then(() => Promise.resolve());
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].disabled).toBe(false);
const allowOnce = within(el).getByRole<HTMLButtonElement>("button", { name: "Allow once" });
const allowSession = within(el).getByRole<HTMLButtonElement>("button", {
name: "Allow for session",
});
fireEvent.click(allowOnce);
expect(allowOnce.disabled).toBe(true);
await waitFor(() => expect(allowOnce.disabled).toBe(false));
expect(allowSession.disabled).toBe(false);
});
it("leaves the buttons disabled when onDecide resolves", async () => {
const onDecide = vi.fn().mockResolvedValue(undefined);
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
await Promise.resolve().then(() => Promise.resolve());
expect(buttons[0].disabled).toBe(true);
const allowOnce = within(el).getByRole<HTMLButtonElement>("button", { name: "Allow once" });
fireEvent.click(allowOnce);
await waitFor(() => expect(allowOnce.disabled).toBe(true));
});
});
+2 -2
View File
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { fireEvent } from "@testing-library/react";
import {
createPaletteSelect,
@@ -66,8 +67,7 @@ describe("palette selection", () => {
expect([...select.options].map((o) => o.value)).toEqual([...PALETTES]);
expect(select.value).toBe("graphite");
select.value = "periwinkle";
select.dispatchEvent(new Event("change"));
fireEvent.change(select, { target: { value: "periwinkle" } });
expect(document.documentElement.dataset.palette).toBe("periwinkle");
expect(localStorage.getItem("maestro-palette")).toBe("periwinkle");
});
+31 -27
View File
@@ -2,6 +2,7 @@
// Second step of the working-directory picker (#95). See the interaction rules
// block in spawnForm.ts for what each choice means.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, within } from "@testing-library/react";
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
import type { Project } from "./types.js";
@@ -86,23 +87,32 @@ describe("spawnForm worktree step (#95)", () => {
return handle.el;
}
// The worktree step lives inside the collapsed "Advanced" section, which
// these tests never expand — `{ hidden: true }` reaches into it, the way
// the plain DOM queries these replaced always did.
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
const choices = (form: HTMLFormElement) => [
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
];
const choices = (form: HTMLFormElement) =>
within(form)
.getAllByRole("button", { hidden: true })
.filter((b): b is HTMLButtonElement => b.classList.contains("wt-choice"));
const activeChoice = (form: HTMLFormElement) =>
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
const box = (form: HTMLFormElement, path: string) =>
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
const toggle = (form: HTMLFormElement) =>
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
within(form).getByRole("checkbox", {
name: "Create git worktree",
hidden: true,
}) as HTMLInputElement;
const otherBtn = (form: HTMLFormElement) =>
within(form).getByRole("button", { name: "Other directory…" });
it("hides the worktree step until a project is picked", async () => {
const form = await mount([MAESTRO, WT_A]);
expect(step(form).hidden).toBe(true);
expect(choices(form)).toEqual([]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
expect(step(form).hidden).toBe(false);
@@ -110,7 +120,7 @@ describe("spawnForm worktree step (#95)", () => {
it("keeps the worktree step inside the collapsed Advanced options section", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
const collapsible = form.querySelector<HTMLElement>(".wt-collapsible")!;
@@ -120,7 +130,7 @@ describe("spawnForm worktree step (#95)", () => {
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
@@ -130,24 +140,24 @@ describe("spawnForm worktree step (#95)", () => {
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
expect(toggle(form).checked).toBe(true);
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
expect(within(form).getByText(`repo: ${MAESTRO.path}`)).toBeTruthy();
// The scan already knows the repo — no probe.
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
});
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
const form = await mount([MAESTRO, WT_A, WT_B]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
existing.click();
fireEvent.click(existing);
await drain();
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
@@ -163,16 +173,12 @@ describe("spawnForm worktree step (#95)", () => {
it("switching back to '+ new' re-enables the create toggle", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
fireEvent.click(choices(form).find((c) => c.dataset.cwd === WT_A.path)!);
await drain();
choices(form)
.find((c) => c.dataset.kind === "new")!
.click();
fireEvent.click(choices(form).find((c) => c.dataset.kind === "new")!);
await drain();
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
@@ -182,14 +188,12 @@ describe("spawnForm worktree step (#95)", () => {
it("repopulates and resets to '+ new' when another project is picked", async () => {
const form = await mount([MAESTRO, WT_A, OTHER]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
choices(form)
.find((c) => c.dataset.cwd === WT_A.path)!
.click();
fireEvent.click(choices(form).find((c) => c.dataset.cwd === WT_A.path)!);
await drain();
box(form, OTHER.path).click();
fireEvent.click(box(form, OTHER.path));
await drain();
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
@@ -201,7 +205,7 @@ describe("spawnForm worktree step (#95)", () => {
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
// No main checkout in the scan, so the orphan worktree is its own box.
const form = await mount([WT_A]);
box(form, WT_A.path).click();
fireEvent.click(box(form, WT_A.path));
await drain();
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
@@ -213,11 +217,11 @@ describe("spawnForm worktree step (#95)", () => {
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
const form = await mount([MAESTRO, WT_A]);
box(form, MAESTRO.path).click();
fireEvent.click(box(form, MAESTRO.path));
await drain();
expect(step(form).hidden).toBe(false);
form.querySelector<HTMLButtonElement>(".project-other")!.click();
fireEvent.click(otherBtn(form));
await drain();
expect(step(form).hidden).toBe(true);
@@ -267,7 +271,7 @@ describe("spawnForm worktree step (#95)", () => {
await drain();
const form = handle.el;
const raw = form.querySelector<HTMLInputElement>(".cwd-raw")!;
const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement;
expect(form.querySelector<HTMLElement>(".cwd-raw-wrap")!.hidden).toBe(false);
expect(raw.value).toBe("/home/user/gone");
});