diff --git a/web/src/barActions.test.ts b/web/src/barActions.test.ts index 6a19097..d15f8be 100644 --- a/web/src/barActions.test.ts +++ b/web/src/barActions.test.ts @@ -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(".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(".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(".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(".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(".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", + ); }); }); diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts index 9447f5c..a6f33e9 100644 --- a/web/src/catalog.test.ts +++ b/web/src/catalog.test.ts @@ -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 { @@ -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(`[data-session-id="${id}"]`); } -/** Let the kill chain's awaits (fetch, its json body) settle. */ -async function flush(): Promise { - 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(".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(".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(".leaf-kill")?.click(); - await flush(); - document.querySelector(".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(".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(".leaf-kill")?.click(); - await flush(); - document.querySelector(".kill-dialog-kill")?.click(); - await flush(); - const dialog = document.querySelector(".kill-dialog"); - expect(dialog?.textContent).toContain("1 untracked file"); - dialog?.querySelector(".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(".leaf-kill")?.click(); - await flush(); - document.querySelector(".kill-dialog-kill")?.click(); - await flush(); - document.querySelector(".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(".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(".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(".card-line1"); + const el = mount(storeWith([s])); + const row = rowFor(el, s.id)!; + const line1 = row.querySelector(".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(".card-line2"); + const el = mount(storeWith([s])); + const row = rowFor(el, s.id)!; + const line2 = row.querySelector(".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(".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(".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(".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(".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(".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(".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(".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(".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(".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(".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$/); }); }); diff --git a/web/src/catalogPatch.test.ts b/web/src/catalogPatch.test.ts index 453b3c9..001b480 100644 --- a/web/src/catalogPatch.test.ts +++ b/web/src/catalogPatch.test.ts @@ -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"); }); }); diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts index abb8bd9..de29197 100644 --- a/web/src/commandBar.test.ts +++ b/web/src/commandBar.test.ts @@ -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"]); }); diff --git a/web/src/connections.test.ts b/web/src/connections.test.ts index 5173770..32ef9c4 100644 --- a/web/src/connections.test.ts +++ b/web/src/connections.test.ts @@ -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")]); diff --git a/web/src/contextMenu.test.ts b/web/src/contextMenu.test.ts index 3b356d9..1961818 100644 --- a/web/src/contextMenu.test.ts +++ b/web/src/contextMenu.test.ts @@ -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(); }); }); diff --git a/web/src/dirAutocomplete.test.ts b/web/src/dirAutocomplete.test.ts index 3c9e811..00dc78c 100644 --- a/web/src/dirAutocomplete.test.ts +++ b/web/src/dirAutocomplete.test.ts @@ -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; 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(".dir-ac-item")]; + return screen.getAllByRole("option", { hidden: true }); } async function type(value: string): Promise { 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(); }); }); diff --git a/web/src/drawer.test.ts b/web/src/drawer.test.ts index 4f8c216..e8e9490 100644 --- a/web/src/drawer.test.ts +++ b/web/src/drawer.test.ts @@ -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); }); }); diff --git a/web/src/help.test.ts b/web/src/help.test.ts index 3ce1c2a..522dddd 100644 --- a/web/src/help.test.ts +++ b/web/src/help.test.ts @@ -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(".help-row")].map((r) => r.dataset.commandId)) - .toEqual(publishedCommands().map((c) => c.id)); + const dialog = overlay(); + expect( + [...dialog.querySelectorAll(".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); }); }); diff --git a/web/src/killDialog.test.ts b/web/src/killDialog.test.ts index e3cca27..aac7478 100644 --- a/web/src/killDialog.test.ts +++ b/web/src/killDialog.test.ts @@ -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(); }); }); diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts index d95a047..9d327ea 100644 --- a/web/src/multiview.test.ts +++ b/web/src/multiview.test.ts @@ -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"); }); }); diff --git a/web/src/projectPicker.test.ts b/web/src/projectPicker.test.ts index 17da120..c62820f 100644 --- a/web/src/projectPicker.test.ts +++ b/web/src/projectPicker.test.ts @@ -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(".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(".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(".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(".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(".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(".project-box")!; - box.click(); + const box = within(form).getByRole("button", { name: /maestro/ }); + fireEvent.click(box); - const name = form.querySelector("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(".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(".cwd-raw")!; + const raw = within(form).getByPlaceholderText("/home/user/project") as HTMLInputElement; expect(raw.value).toBe("/home/user"); }); }); diff --git a/web/src/rename.test.ts b/web/src/rename.test.ts index 0a01301..52f8593 100644 --- a/web/src/rename.test.ts +++ b/web/src/rename.test.ts @@ -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(".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("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("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("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("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("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(); }); }); diff --git a/web/src/sidebarNav.test.ts b/web/src/sidebarNav.test.ts index 71c93b8..b1d6c39 100644 --- a/web/src/sidebarNav.test.ts +++ b/web/src/sidebarNav.test.ts @@ -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[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(".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(".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(".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(".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(".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(".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(".tree-leaf")[0]; - card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true })); + fireEvent.pointerOver(card, { pointerType: "touch", ctrlKey: true }); expect(seen).toEqual([]); }); }); diff --git a/web/src/sidebarSearch.test.ts b/web/src/sidebarSearch.test.ts index 9d72849..1fd448f 100644 --- a/web/src/sidebarSearch.test.ts +++ b/web/src/sidebarSearch.test.ts @@ -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(".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(); }); }); diff --git a/web/src/spawnFormEnter.test.ts b/web/src/spawnFormEnter.test.ts index 18b14de..7d750c5 100644 --- a/web/src/spawnFormEnter.test.ts +++ b/web/src/spawnFormEnter.test.ts @@ -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(`.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("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("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("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('.cmd-tiles .harness-card[data-id="custom"]')!.click(); - const raw = form.querySelector(".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(".project-other")!.click(); - const raw = form.querySelector("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(".project-other")!.click(); - const raw = form.querySelector("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(`.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(".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(".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(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click(); + fireEvent.click(wtChoice(form, WT_A.name)); await drain(); - const filter = form.querySelector(".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(`.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); }); }); }); diff --git a/web/src/spawnFormNames.test.ts b/web/src/spawnFormNames.test.ts index 5c9b087..7a1582f 100644 --- a/web/src/spawnFormNames.test.ts +++ b/web/src/spawnFormNames.test.ts @@ -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("input[name=name]")!.value; + (within(form).getByPlaceholderText("named after your first prompt") as HTMLInputElement).value; const branchOf = (form: HTMLFormElement) => - form.querySelector("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("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("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"); diff --git a/web/src/spawnFormPreset.test.ts b/web/src/spawnFormPreset.test.ts index 459e94e..8b41a23 100644 --- a/web/src/spawnFormPreset.test.ts +++ b/web/src/spawnFormPreset.test.ts @@ -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(".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(".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(".harness-card")].find( - (t) => t.dataset.id === "custom", - )!; - customTile.click(); - const cmdRaw = form.querySelector(".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(".harness-card")].find( - (t) => t.dataset.id === "fish", - ); - expect(fishTile).toBeDefined(); + expect(tileById(form, "fish")).toBeDefined(); }); }); diff --git a/web/src/spawnFormProbe.test.ts b/web/src/spawnFormProbe.test.ts index b711340..f516d13 100644 --- a/web/src/spawnFormProbe.test.ts +++ b/web/src/spawnFormProbe.test.ts @@ -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("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(".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(".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(".wt-repo")!.textContent).toContain("/repo/new"); - expect( - form.querySelector("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(".wt-repo")!.textContent).toContain("/repo/new"); - expect( - form.querySelector("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, + ); }); }); diff --git a/web/src/spawnFormStructured.test.ts b/web/src/spawnFormStructured.test.ts index 298fe40..d9485f7 100644 --- a/web/src/spawnFormStructured.test.ts +++ b/web/src/spawnFormStructured.test.ts @@ -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(".structured-view")!; - const checkbox = wrap.querySelector("input")!; - const disclosure = handle.el.querySelector(".wt-collapsible")!; - expect(disclosure.hidden).toBe(true); - handle.el.querySelector(".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('[data-id="custom"]')!; - custom.click(); - const command = handle.el.querySelector(".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("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('[data-id="custom"]')!.click(); - const command = handle.el.querySelector(".cmd-raw")!; - command.value = "codex"; - command.dispatchEvent(new Event("input")); + fireEvent.click(handle.el.querySelector('[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('[data-id="custom"]')!.click(); - const command = handle.el.querySelector(".cmd-raw")!; - command.value = "env -i codex"; - command.dispatchEvent(new Event("input")); + fireEvent.click(handle.el.querySelector('[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("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(".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('[data-id="custom"]')!.click(); - const command = handle.el.querySelector(".cmd-raw")!; - command.value = "codex"; - command.dispatchEvent(new Event("input")); + fireEvent.click(handle.el.querySelector('[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("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); diff --git a/web/src/spawnFormWorktree.test.ts b/web/src/spawnFormWorktree.test.ts index 6a39479..889c9c8 100644 --- a/web/src/spawnFormWorktree.test.ts +++ b/web/src/spawnFormWorktree.test.ts @@ -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(".project-other")!.click(); - form - .querySelectorAll(".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("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(".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(".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("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(".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(".project-other")!.click(); - const raw = form.querySelector("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( - 'input[name=worktree_branch_mode][value="existing"]', - )!; - radio.click(); + fireEvent.click(within(form).getByLabelText("Existing branch")); await drain(); - const select = form.querySelector(".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( - '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(".wt-toggle")!; + const toggle = within(form).getByRole("button", { name: /Advanced/ }); expect(toggle.getAttribute("aria-expanded")).toBe("false"); expect(form.querySelector(".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(".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(".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(".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(".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(".wt-toggle")!.click(); + fireEvent.click(within(form).getByRole("button", { name: /Advanced/ })); - expect(form.querySelector(".wt-toggle")!.getAttribute("aria-expanded")).toBe( - "true", - ); + expect( + within(form).getByRole("button", { name: /Advanced/ }).getAttribute("aria-expanded"), + ).toBe("true"); expect(form.querySelector(".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('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(".branch-select")!.options.length, + (within(handle.el).getByLabelText("Pick a branch") as HTMLSelectElement).options.length, ).toBe(0); }); }); diff --git a/web/src/spawnModal.test.ts b/web/src/spawnModal.test.ts index 13dce7d..5ae7c12 100644 --- a/web/src/spawnModal.test.ts +++ b/web/src/spawnModal.test.ts @@ -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 { 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); }); diff --git a/web/src/statusIndicator.test.ts b/web/src/statusIndicator.test.ts index 629363f..298fc70 100644 --- a/web/src/statusIndicator.test.ts +++ b/web/src/statusIndicator.test.ts @@ -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(); }); }); diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index de28fff..f47a3fd 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -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("./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("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(".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("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("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('[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(".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(".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(".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("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(".structured-rows")!; expect([...transcript.querySelectorAll(".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(".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(".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(".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(".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(".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(".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(".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(".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(".structured-input")!; + const input = within(conn.el).getByRole("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(".structured-input")!; + const input = within(conn.el).getByRole("textbox"); const menu = conn.el.querySelector(".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(""); + expect(within(menu).getByText("")).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(".structured-input")!; - input.value = "/hum"; - input.dispatchEvent(new Event("input")); - const options = conn.el.querySelectorAll(".structured-command-option"); + const input = within(conn.el).getByRole("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(".structured-input")!; + const input = within(conn.el).getByRole("textbox"); input.value = "asd /s dsa"; input.setSelectionRange(6, 6); - input.dispatchEvent(new Event("input")); + fireEvent.input(input); - const options = conn.el.querySelectorAll(".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(".structured-input")!; + const input = within(conn.el).getByRole("textbox"); input.value = "before /stale after"; input.setSelectionRange(9, 9); - input.dispatchEvent(new Event("input")); - conn.el.querySelector(".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("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(".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("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("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("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("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("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("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(".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(".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(".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(".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(".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(".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(".st-tool-head")) head.click(); + for (const head of conn.el.querySelectorAll(".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(".st-tool-head")) head.click(); + for (const head of conn.el.querySelectorAll(".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("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("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("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("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("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(".structured-input")!; + if (manuallyScrolled) fireEvent.scroll(scroll); + const input = within(conn.el).getByRole("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(".st-tool-head")!.click(); + fireEvent.click(conn.el.querySelector(".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(".structured-input")!; + const input = within(conn.el).getByRole("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("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("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("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("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("textbox"); + expect(input).toHaveFocus(); conn.dispose(); }); }); diff --git a/web/src/structuredRender.test.ts b/web/src/structuredRender.test.ts index 622739f..83fa0d4 100644 --- a/web/src/structuredRender.test.ts +++ b/web/src/structuredRender.test.ts @@ -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 there" } }), ); expect(el.className).toContain("st-row-user"); - expect(el.textContent).toContain("hi there"); + expect(within(el).getByText("hi there")).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("two"); expect(ps[0].innerHTML).toContain("three"); @@ -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=", + ); + //