Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a568818a | ||
|
|
ad71147bd9 | ||
|
|
e9c3882cea | ||
|
|
c9562ab5eb | ||
|
|
bc87ddee06 | ||
|
|
554c9ebb31 | ||
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
e0edbc09d2 |
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 "{name}"?</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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user