Compare commits

...
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 66a568818a fix(maestro): re-read session before sidecar write in mark_session_dead
ci / web (push) Failing after 6s
ci / flake (push) Failing after 55s
ci / rust (push) Successful in 6m7s
mark_session_dead_with_worktree_settlement cloned the session under the
lock, dropped it, did async work, then wrote the stale clone to the
sidecar. A haiku rename (or any other metadata update) that landed
between the clone and the write was silently overwritten, so the sidecar
reverted to name_source: Generated and the next daemon recovery
re-triggered autoname.

Re-read record.session under the lock at write time so the sidecar
always reflects the latest state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 15:14:34 +01:00
Miguel Palhas ad71147bd9 Closes #252: port all tests to React Testing Library 2026-09-09 02:07:52 +01:00
Miguel Palhas e9c3882cea Closes #242: port daemon link and restart overlay to React 2026-09-09 02:07:49 +01:00
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 bc87ddee06 feat(web): port daemonLink and restartOverlay to React (#242)
DaemonLink.tsx holds the link state machine as a Zustand store (same
shape as catalogStore/connectionStore) plus a thin controller component
wiring the catalog store's `connected` field to it. RestartOverlay.tsx
renders the blocking modal for down/updating, reusing the existing CSS.

Closes #242
2026-09-09 01:59:49 +01:00
Miguel Palhas 554c9ebb31 Closes #240: add useHashRouter hook 2026-09-09 01:51:02 +01:00
Miguel PalhasandClaude Sonnet 5 af5ad1a227 feat(web): add useHashRouter hook for React shell (#240)
Ports main.ts's parseRoute/dispatch hash parsing into a React hook:
same #/ and #/attach/:id shape, current route plus a navigate function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 01:43:46 +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
48 changed files with 2595 additions and 861 deletions
+12 -12
View File
@@ -2916,17 +2916,16 @@ async fn mark_session_dead_with_worktree_settlement(
// Release anyone attached; the scrollback survives for a later
// attach.
record.bridge.seal();
let session = record.session.clone();
let worktree = record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone());
Some((session, worktree))
Some(worktree)
}
_ => None,
}
};
let Some((mut session, worktree)) = flipped else {
let Some(worktree) = flipped else {
return false;
};
let stream = inner
@@ -2943,9 +2942,6 @@ async fn mark_session_dead_with_worktree_settlement(
if settle_worktree {
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt).await {
// The tree is settled (removed); a stale "in the way" would
// describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
@@ -2957,13 +2953,17 @@ async fn mark_session_dead_with_worktree_settlement(
}
}
// Settle has now been applied or refused either way (#115): from here on
// its outcomes are safe to observe.
{
// its outcomes are safe to observe. Re-read the session from the record
// so the sidecar write includes any updates (e.g. a haiku rename) that
// landed between the initial clone and now.
let session = {
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.settled = true;
}
}
let Some(record) = sessions.get_mut(&id) else {
return false;
};
record.settled = true;
record.session.clone()
};
let _ = inner.sidecar.write(&session);
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
+37 -3
View File
@@ -11,14 +11,14 @@ use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource};
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
use tempfile::TempDir;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture,
tmux: TmuxFixture,
_state_dir: TempDir,
}
@@ -35,7 +35,7 @@ fn fixture() -> Fixture {
Fixture {
manager,
sidecar,
_tmux: tmux,
tmux,
_state_dir: state_dir,
}
}
@@ -171,3 +171,37 @@ async fn a_blank_name_changes_nothing() {
let live = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&live), NameSource::Generated);
}
/// A session that dies naturally (liveness watcher marks it dead) must not
/// overwrite a haiku rename that landed before the death. Regression test for
/// the stale-clone sidecar write in `mark_session_dead`.
#[tokio::test]
async fn session_death_preserves_the_haiku_name() {
let fx = fixture();
let id = spawn(&fx.manager, None).await;
fx.manager.claim_autoname(id).await.unwrap();
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
// Verify the sidecar has Haiku before the session dies.
let before = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(display::name_source(&before), NameSource::Haiku);
// Kill the tmux session directly so the liveness watcher picks it up.
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
fx.manager
.start_liveness_watcher(std::time::Duration::from_millis(25));
common::wait_for_async(std::time::Duration::from_secs(3), || async {
fx.manager.status(id).await == Some(SessionStatus::Dead)
})
.await;
let after = fx.sidecar.read(id).unwrap().unwrap();
assert_eq!(
display::name_source(&after),
NameSource::Haiku,
"mark_session_dead must not overwrite a haiku rename in the sidecar"
);
assert_eq!(display::custom_name(&after), Some("haiku-name"));
}
+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,215 @@
import { describe, expect, it, vi } from "vitest";
import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.js";
import type { DaemonInfo } from "../../types.js";
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
return {
hostname: "konishi",
version: "0.1.0",
presets: [],
build: { commit, commit_date: date, dirty: false },
instance,
};
}
/** Scheduler whose timers only fire when the test says so, recording the
* delay each was scheduled with. */
function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } {
let next = 1;
const timers = new Map<number, () => void>();
const delays: number[] = [];
return {
delays,
setTimer(fn, ms) {
const h = next++;
timers.set(h, fn);
delays.push(ms);
return h;
},
clearTimer(h) {
timers.delete(h);
},
run() {
const due = [...timers.entries()];
timers.clear();
for (const [, fn] of due) fn();
},
pending: () => timers.size,
};
}
describe("compareIdentity", () => {
it("reads a changed commit as an upgrade", () => {
expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded");
});
it("reads a changed instance on the same commit as a plain restart", () => {
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted");
});
it("reads an unchanged process as no event at all", () => {
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same");
});
it("falls back to the instance when the build is unknown", () => {
// A daemon built outside a checkout reports empty commits; the instance is
// still enough to see the restart, and never claims a phantom upgrade.
expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted");
expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same");
});
});
describe("formatRelease", () => {
it("pairs the short hash with a readable date", () => {
expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe(
"f89be4a716c7 · 2026-09-02",
);
});
it("marks a dirty build", () => {
expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)");
});
it("says so when the release is unknown", () => {
expect(formatRelease(undefined)).toBe("unknown release");
});
});
describe("daemon link store", () => {
it("starts in the connecting state until the first probe lands", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: manual(),
reload: () => {},
});
expect(store.getState().state).toBe("connecting");
await store.getState().probe();
expect(store.getState().state).toBe("online");
expect(store.getState().build?.commit).toBe("aaa");
});
it("holds the modal back during the grace period, then blocks", async () => {
const sched = manual();
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
graceMs: 1000,
});
store.getState().noteDisconnected();
// Still inside the grace window: the dot goes amber, nothing blocks.
expect(store.getState().state).toBe("reconnecting");
sched.run();
await Promise.resolve();
expect(store.getState().state).toBe("down");
});
it("unlocks without reloading when the same build comes back", async () => {
const sched = manual();
const reload = vi.fn();
let reply: DaemonInfo | null = info("aaa", "i1");
const store = createDaemonLinkStore({
fetchInfo: async () => {
if (!reply) throw new Error("down");
return reply;
},
scheduler: sched,
reload,
});
await store.getState().probe();
reply = null;
store.getState().noteDisconnected();
sched.run(); // grace expires → blocked, poll scheduled
await Promise.resolve();
expect(store.getState().state).toBe("down");
// Same commit, new process: a restart, not an upgrade — the page stays.
reply = info("aaa", "i2");
await store.getState().probe();
expect(reload).not.toHaveBeenCalled();
expect(store.getState().state).toBe("online");
});
it("reloads the page when the daemon comes back on a new commit", async () => {
const sched = manual();
const reload = vi.fn();
let reply = info("aaa", "i1");
const store = createDaemonLinkStore({
fetchInfo: async () => reply,
scheduler: sched,
reload,
});
await store.getState().probe();
store.getState().noteDisconnected();
reply = info("bbb", "i2");
await store.getState().probe();
expect(reload).toHaveBeenCalledTimes(1);
// Left blocked deliberately: the reload is what ends this page's life, and
// flashing an unlocked UI in between would let the user act on stale code.
expect(store.getState().state).toBe("updating");
});
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
const sched = manual();
let attempts = 0;
const store = createDaemonLinkStore({
fetchInfo: async () => {
attempts += 1;
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
store.getState().noteDisconnected();
sched.run(); // grace → first poll
await Promise.resolve();
await Promise.resolve();
const first = attempts;
expect(first).toBeGreaterThan(0);
sched.run(); // next poll tick
await Promise.resolve();
await Promise.resolve();
expect(attempts).toBeGreaterThan(first);
store.getState().stop();
});
it("notifies subscribers on every state change", async () => {
const sched = manual();
const seen: string[] = [];
const store = createDaemonLinkStore({
fetchInfo: async () => info("aaa", "i1"),
scheduler: sched,
reload: () => {},
});
store.subscribe((s) => seen.push(s.state));
await store.getState().probe();
store.getState().noteDisconnected();
sched.run();
await Promise.resolve();
expect(seen).toContain("reconnecting");
});
it("schedules the grace timer and poll at the documented defaults", async () => {
const sched = manual();
const store = createDaemonLinkStore({
fetchInfo: async () => {
throw new Error("down");
},
scheduler: sched,
reload: () => {},
});
expect(GRACE_MS).toBe(1200);
expect(POLL_MS).toBe(700);
store.getState().noteDisconnected();
expect(sched.delays).toEqual([GRACE_MS]);
sched.run(); // grace fires → schedules the first poll
await Promise.resolve();
await Promise.resolve();
expect(sched.delays).toEqual([GRACE_MS, POLL_MS]);
});
});
@@ -0,0 +1,71 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore } from "./DaemonLink.js";
import { DaemonLink } from "./DaemonLink.js";
afterEach(() => cleanup());
describe("DaemonLink", () => {
it("probes once on mount, regardless of the initial connected value", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
render(<DaemonLink store={store} connected={false} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
});
it("does not treat the initial connected value as a transition", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
// Only the mount probe ran — no extra noteConnected() probe on top of it.
expect(store.getState().state).toBe("online");
});
it("forwards a later connected drop to noteDisconnected", async () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
scheduler: { setTimer: () => 0, clearTimer: () => {} },
});
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
});
it("forwards a later connected recovery to noteConnected", async () => {
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
const { rerender } = render(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(store.getState().state).toBe("online");
// The socket dropped: noteDisconnected() moves off "online" so the coming
// recovery isn't a no-op.
rerender(<DaemonLink store={store} connected={false} />);
expect(store.getState().state).toBe("reconnecting");
fetchInfo.mockClear();
rerender(<DaemonLink store={store} connected={true} />);
await Promise.resolve();
expect(fetchInfo).toHaveBeenCalledTimes(1);
expect(store.getState().state).toBe("online");
});
it("stops the store's polling on unmount", () => {
const store = createDaemonLinkStore({
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
});
const stop = vi.spyOn(store.getState(), "stop");
const { unmount } = render(<DaemonLink store={store} connected={false} />);
unmount();
expect(stop).toHaveBeenCalledTimes(1);
});
});
+234
View File
@@ -0,0 +1,234 @@
// React port of ../../daemonLink.ts. The link to the daemon behind this page:
// is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx)
// renders what this reports. See docs/RESTART.md for the restart contract.
import { useEffect, useRef } from "react";
import { create } from "zustand";
import { getDaemonInfo } from "../../api.js";
import type { BuildInfo, DaemonInfo } from "../../types.js";
/**
* - `connecting` — first probe hasn't landed yet.
* - `online` — the daemon answered and is the same process, or a resolved new one.
* - `reconnecting` — the socket dropped, but within the grace window; a blip
* shouldn't throw a modal in the user's face.
* - `down` — unreachable past the grace window: the overlay blocks the UI and
* the poll runs until it answers.
* - `updating` — it answered on a different commit; the page is reloading and
* stays blocked until it does.
*/
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
/** What happened between two `/api/info` replies. */
export type RestartKind = "same" | "restarted" | "upgraded";
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
* to ride out a wifi hiccup, short enough that a real restart is visible. */
export const GRACE_MS = 1200;
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
export const POLL_MS = 700;
/**
* Compare two daemon replies.
*
* The commit decides first: a different one means new code, which for a browser
* means new assets. The per-process `instance` catches the rest — a restart of
* the same build, which needs no reload. A daemon built outside a git checkout
* reports an empty commit, so an empty-vs-empty pair never counts as an
* upgrade; the instance still reports the restart.
*/
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
const a = before.build?.commit ?? "";
const b = after.build?.commit ?? "";
if (a && b && a !== b) return "upgraded";
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
return "same";
}
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
* the build carried uncommitted changes. Empty builds say so rather than
* rendering a blank tooltip. */
export function formatRelease(build: BuildInfo | undefined): string {
if (!build?.commit) return "unknown release";
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
const parts = [build.commit];
if (day) parts.push(`· ${day}`);
if (build.dirty) parts.push("(dirty)");
return parts.join(" ");
}
/** Schedules a probe/grace timer; returns a handle `clearTimer` understands. */
export interface Scheduler {
setTimer(fn: () => void, ms: number): number;
clearTimer(handle: number): void;
}
const browserScheduler: Scheduler = {
setTimer: (fn, ms) => window.setTimeout(fn, ms),
clearTimer: (h) => window.clearTimeout(h),
};
export interface DaemonLinkState {
state: LinkState;
/** The release the daemon last reported. Undefined until the first probe,
* and on a daemon too old to carry one. */
build?: BuildInfo;
/** Hostname the daemon reports, for the indicator's tooltip. */
hostname?: string;
/** How many probes have failed in the current outage — the overlay counts
* attempts so a long restart doesn't look frozen. */
attempts: number;
}
export interface DaemonLinkActions {
/** One `/api/info` round-trip. Resolves `true` when the daemon answered. */
probe(): Promise<boolean>;
/** The events socket dropped. Starts the grace timer and the poll. */
noteDisconnected(): void;
/** The events socket came back. It carries no identity of its own, so the
* state only clears once a probe confirms *which* daemon answered — a
* reconnect to an upgraded daemon must reload, not resume. */
noteConnected(): void;
/** Stop polling. */
stop(): void;
}
export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions;
export interface DaemonLinkOptions {
/** Injectable for tests; defaults to `GET /api/info`. */
fetchInfo?: () => Promise<DaemonInfo>;
scheduler?: Scheduler;
/** What an upgrade does. Injectable so tests don't navigate. */
reload?: () => void;
graceMs?: number;
pollMs?: number;
}
/** Builds an isolated daemon-link store — same shape as
* {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook
* and a plain object (`getState`/`subscribe`), so it drives the overlay
* whether or not React is the one calling `noteConnected`/`noteDisconnected`. */
export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) {
const fetchInfo = opts.fetchInfo ?? getDaemonInfo;
const scheduler = opts.scheduler ?? browserScheduler;
const reload = opts.reload ?? (() => window.location.reload());
const graceMs = opts.graceMs ?? GRACE_MS;
const pollMs = opts.pollMs ?? POLL_MS;
let identity: DaemonInfo | null = null;
let graceTimer: number | null = null;
let pollTimer: number | null = null;
return create<DaemonLinkStore>((set, get) => {
function clearTimers(): void {
if (graceTimer !== null) {
scheduler.clearTimer(graceTimer);
graceTimer = null;
}
if (pollTimer !== null) {
scheduler.clearTimer(pollTimer);
pollTimer = null;
}
}
async function probe(): Promise<boolean> {
let info: DaemonInfo;
try {
info = await fetchInfo();
} catch {
set((s) => ({ attempts: s.attempts + 1 }));
return false;
}
const before = identity;
identity = info;
if (before && compareIdentity(before, info) === "upgraded") {
// Stay blocked through the reload: an unlocked frame here would let the
// user drive new server code from the bundle it just replaced.
set({ state: "updating", build: info.build, hostname: info.hostname });
reload();
return true;
}
clearTimers();
set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 });
return true;
}
async function poll(): Promise<void> {
const state = get().state;
if (state === "online" || state === "updating") return;
const ok = await probe();
if (ok || pollTimer !== null) return;
pollTimer = scheduler.setTimer(() => {
pollTimer = null;
void poll();
}, pollMs);
}
return {
state: "connecting",
build: undefined,
hostname: undefined,
attempts: 0,
probe,
noteDisconnected() {
const state = get().state;
if (state === "down" || state === "updating") return;
set({ state: "reconnecting", attempts: 0 });
if (graceTimer === null) {
graceTimer = scheduler.setTimer(() => {
graceTimer = null;
if (get().state === "reconnecting") set({ state: "down" });
void poll();
}, graceMs);
}
},
noteConnected() {
const state = get().state;
if (state === "online" || state === "updating") return;
void probe();
},
stop: clearTimers,
};
});
}
export type DaemonLinkStoreApi = ReturnType<typeof createDaemonLinkStore>;
export interface DaemonLinkProps {
/** The link store this instance drives — `RestartOverlay` (and, later, the
* sidebar's status indicator) read the same one. */
store: DaemonLinkStoreApi;
/** The catalog store's `connected` field (docs/RESTART.md): the events
* socket dying is the earliest signal a restart happened, ahead of any
* poll. Pass `useStore(catalogStore, (s) => s.connected)`. */
connected: boolean;
}
/**
* Renders nothing — the controller that wires a `DaemonLinkStore` to the
* catalog's socket state. Mount once near the app root; `RestartOverlay`
* reads the store it drives.
*/
export function DaemonLink({ store, connected }: DaemonLinkProps): null {
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
void store.getState().probe();
return;
}
if (connected) store.getState().noteConnected();
else store.getState().noteDisconnected();
}, [store, connected]);
useEffect(() => () => store.getState().stop(), [store]);
return null;
}
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDaemonLinkStore, type DaemonLinkStoreApi } from "./DaemonLink.js";
import { RestartOverlay } from "./RestartOverlay.js";
afterEach(() => cleanup());
function storeAt(state: "connecting" | "online" | "reconnecting" | "down" | "updating"): DaemonLinkStoreApi {
const store = createDaemonLinkStore({ scheduler: { setTimer: () => 0, clearTimer: () => {} } });
store.setState({ state });
return store;
}
describe("RestartOverlay", () => {
it("renders nothing while connecting, online, or reconnecting", () => {
for (const state of ["connecting", "online", "reconnecting"] as const) {
const { unmount } = render(<RestartOverlay store={storeAt(state)} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
unmount();
}
});
it("blocks with a restarting message when down", () => {
render(<RestartOverlay store={storeAt("down")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro is restarting");
expect(dialog.textContent).toContain("Waiting for the daemon to come back…");
});
it("counts attempts once more than one probe has failed", () => {
const store = storeAt("down");
store.setState({ attempts: 3 });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("(3 attempts)");
});
it("says it is reloading when updating", () => {
render(<RestartOverlay store={storeAt("updating")} />);
const dialog = screen.getByRole("alertdialog");
expect(dialog.textContent).toContain("Maestro was updated");
expect(dialog.textContent).toContain("Reloading this page onto the new release…");
});
it("names the release from the store's build", () => {
const store = storeAt("down");
store.setState({ build: { commit: "f89be4a", commit_date: "2026-09-02T00:00:00Z", dirty: false } });
render(<RestartOverlay store={store} />);
expect(screen.getByRole("alertdialog").textContent).toContain("f89be4a · 2026-09-02");
});
it("clears the moment the store reports the link is back", () => {
const store = storeAt("down");
const { rerender } = render(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).not.toBeNull();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("swallows keyboard shortcuts while blocking, but not once cleared", () => {
const store = storeAt("down");
const handler = vi.fn();
window.addEventListener("keydown", handler);
const { rerender } = render(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).not.toHaveBeenCalled();
store.setState({ state: "online" });
rerender(<RestartOverlay store={store} />);
fireEvent.keyDown(window, { key: "n" });
expect(handler).toHaveBeenCalledTimes(1);
window.removeEventListener("keydown", handler);
});
});
@@ -0,0 +1,62 @@
// React port of ../../restartOverlay.ts. Blocking overlay for a daemon that
// went away — see docs/RESTART.md.
//
// Rendered while the link is `down` or `updating`, gone the moment it is
// back. It owns no polling of its own: `DaemonLink` polls, this only renders
// what the store reports.
import { useEffect } from "react";
import { useStore } from "zustand";
import { formatRelease, type DaemonLinkStoreApi } from "./DaemonLink.js";
export interface RestartOverlayProps {
/** The same store a sibling `DaemonLink` drives. */
store: DaemonLinkStoreApi;
}
/** The poll runs while this is up; saying how many tries have gone by is what
* separates "still restarting" from "wedged" for someone staring at it. */
function attemptText(attempts: number): string {
if (attempts <= 1) return "Waiting for the daemon to come back…";
return `Waiting for the daemon to come back… (${attempts} attempts)`;
}
function swallow(e: KeyboardEvent): void {
e.stopImmediatePropagation();
}
export function RestartOverlay({ store }: RestartOverlayProps) {
const state = useStore(store, (s) => s.state);
const build = useStore(store, (s) => s.build);
const attempts = useStore(store, (s) => s.attempts);
const blocking = state === "down" || state === "updating";
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
// does not stop them: `n` would still open a spawn form against a daemon
// that is not there. Swallow keys in the capture phase until the link is
// back, without `preventDefault` so the browser's own chords still work.
useEffect(() => {
if (!blocking) return;
window.addEventListener("keydown", swallow, true);
return () => window.removeEventListener("keydown", swallow, true);
}, [blocking]);
if (!blocking) return null;
const updating = state === "updating";
return (
<div className="modal-backdrop restart-backdrop">
<div className="modal restart-modal" role="alertdialog" aria-modal="true" aria-labelledby="restart-title">
<div className="restart-spinner" aria-hidden="true" />
<h2 id="restart-title">{updating ? "Maestro was updated" : "Maestro is restarting"}</h2>
<p className="restart-detail">
{updating ? "Reloading this page onto the new release…" : attemptText(attempts)}
</p>
<p className="restart-release">{formatRelease(build)}</p>
</div>
</div>
);
}
@@ -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);
});
});
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useHashRouter, type Route, type UseHashRouterResult } from "./useHashRouter.js";
let controls: UseHashRouterResult;
function Probe() {
controls = useHashRouter();
return <div>{controls.route.kind === "attach" ? `attach:${controls.route.sessionId}` : "catalog"}</div>;
}
beforeEach(() => {
location.hash = "";
});
afterEach(cleanup);
describe("useHashRouter", () => {
it("starts on catalog for an empty hash", () => {
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("resolves #/attach/:id to the attach route", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:s1");
});
it("decodes an encoded session id", () => {
location.hash = `#/attach/${encodeURIComponent("weird id/x")}`;
const view = render(<Probe />);
expect(view.container.textContent).toBe("attach:weird id/x");
});
it("falls back to catalog for an unknown route", () => {
location.hash = "#/nonsense";
const view = render(<Probe />);
expect(view.container.textContent).toBe("catalog");
});
it("follows a hashchange fired outside the hook", async () => {
const view = render(<Probe />);
// happy-dom dispatches `hashchange` on a real timer, like a browser — not
// synchronously with the assignment.
location.hash = "#/attach/s9";
await waitFor(() => expect(view.container.textContent).toBe("attach:s9"));
});
it("navigate updates the hash and the route", async () => {
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s2" } satisfies Route));
expect(location.hash).toBe("#/attach/s2");
await waitFor(() => expect(view.container.textContent).toBe("attach:s2"));
});
it("navigate back to catalog sets the root hash", async () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "catalog" }));
expect(location.hash).toBe("#/");
await waitFor(() => expect(view.container.textContent).toBe("catalog"));
});
it("navigating to the already-current route still updates state", () => {
location.hash = "#/attach/s1";
const view = render(<Probe />);
act(() => controls.navigate({ kind: "attach", sessionId: "s1" } satisfies Route));
expect(view.container.textContent).toBe("attach:s1");
});
it("stops listening after unmount", async () => {
const view = render(<Probe />);
view.unmount();
location.hash = "#/attach/s3";
// No assertion beyond "doesn't throw" — a leaked listener would only ever
// surface as noise in a later test's act() warnings.
await new Promise((r) => setTimeout(r, 0));
});
});
+47
View File
@@ -0,0 +1,47 @@
// React port of main.ts's `parseRoute`/`dispatch` pair (issue #240). Same two
// routes, same hash shape (`#/`, `#/attach/:id`) — kept so a deep link or a
// bookmark from the vanilla shell still resolves.
import { useCallback, useEffect, useState } from "react";
export type Route = { kind: "catalog" } | { kind: "attach"; sessionId: string };
function parseHash(hash: string): Route {
const raw = hash.replace(/^#/, "");
if (raw === "" || raw === "/") return { kind: "catalog" };
const m = raw.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "catalog" };
}
function routeToHash(route: Route): string {
return route.kind === "catalog" ? "#/" : `#/attach/${encodeURIComponent(route.sessionId)}`;
}
export interface UseHashRouterResult {
route: Route;
navigate(route: Route): void;
}
export function useHashRouter(): UseHashRouterResult {
const [route, setRoute] = useState<Route>(() => parseHash(location.hash));
useEffect(() => {
const onHashChange = () => setRoute(parseHash(location.hash));
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
const navigate = useCallback((next: Route) => {
const hash = routeToHash(next);
// Setting an unchanged hash never fires `hashchange`, so the state update
// that would otherwise ride on that event has to happen here instead.
if (location.hash === hash) {
setRoute(next);
} else {
location.hash = hash;
}
}, []);
return { route, navigate };
}
+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");
});