From e57eb459330c372ee446323f31ea745211ba2b41 Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 24 Jun 2026 11:44:40 +0900 Subject: [PATCH] Clarify repeated test assertions --- .../composer/composer-suggestions.test.ts | 48 +++--- .../chat/protocol/inbound/routing.test.ts | 157 +++++++++--------- .../protocol/server-requests/approval.test.ts | 30 ++-- .../blocks-and-messages.test.tsx | 34 ++-- .../message-stream/pending-requests.test.tsx | 6 +- .../ui/message-stream/stream-items.test.tsx | 21 +-- tests/support/dom.ts | 8 + 7 files changed, 156 insertions(+), 148 deletions(-) diff --git a/tests/features/chat/conversation/composer/composer-suggestions.test.ts b/tests/features/chat/conversation/composer/composer-suggestions.test.ts index bff6d938..619acb23 100644 --- a/tests/features/chat/conversation/composer/composer-suggestions.test.ts +++ b/tests/features/chat/conversation/composer/composer-suggestions.test.ts @@ -146,7 +146,7 @@ describe("composer suggestions", () => { }); it("uses recent files only for empty wikilink suggestions", () => { - expect(wikiLinkSuggestions("", notes).map((suggestion) => suggestion.replacement)).toEqual([ + expect(suggestionReplacements(wikiLinkSuggestions("", notes))).toEqual([ "[[projects/Alpha]]", "[[thoughts/Alpha]]", "[[Assets/Diagram.png]]", @@ -195,7 +195,7 @@ describe("composer suggestions", () => { it("suggests headings inside a completed wikilink without suggesting block references", () => { const heading = expectPresent(activeComposerSuggestions("[[Beta Note#impl", notes, [])[0]); - expect(activeComposerSuggestions("[[Beta Note#", notes, []).map((suggestion) => suggestion.replacement)).toEqual([ + expect(suggestionReplacements(activeComposerSuggestions("[[Beta Note#", notes, []))).toEqual([ "[[Beta Note#Overview]]", "[[Beta Note#Implementation Details]]", ]); @@ -298,17 +298,21 @@ describe("composer suggestions", () => { ]; expect( - activeComposerSuggestions("/archive ", notes, [], threads, [], null, { activeThreadId: "019abcde-0000-7000-8000-000000000002" }).map( - (suggestion) => suggestion.replacement, + suggestionReplacements( + activeComposerSuggestions("/archive ", notes, [], threads, [], null, { + activeThreadId: "019abcde-0000-7000-8000-000000000002", + }), ), ).toEqual(["019abcde-0000-7000-8000-000000000002", "019abcde-0000-7000-8000-000000000001", "019abcde-0000-7000-8000-000000000003"]); expect( activeComposerSuggestions("/rename ", notes, [], threads, [], null, { activeThreadId: "019abcde-0000-7000-8000-000000000002" })[0] ?.replacement, ).toBe("019abcde-0000-7000-8000-000000000002"); - expect( - activeComposerSuggestions("/archive ", notes, [], threads, [], null, { activeThreadId: null }).map((item) => item.replacement), - ).toEqual(["019abcde-0000-7000-8000-000000000001", "019abcde-0000-7000-8000-000000000002", "019abcde-0000-7000-8000-000000000003"]); + expect(suggestionReplacements(activeComposerSuggestions("/archive ", notes, [], threads, [], null, { activeThreadId: null }))).toEqual([ + "019abcde-0000-7000-8000-000000000001", + "019abcde-0000-7000-8000-000000000002", + "019abcde-0000-7000-8000-000000000003", + ]); expect( activeComposerSuggestions("/archive latest", notes, [], threads, [], null, { activeThreadId: "019abcde-0000-7000-8000-000000000002", @@ -324,9 +328,10 @@ describe("composer suggestions", () => { ]; const activeThreadId = "019abcde-0000-7000-8000-000000000002"; - expect(activeComposerSuggestions("/resume ", notes, [], threads, [], null, { activeThreadId }).map((item) => item.replacement)).toEqual( - ["019abcde-0000-7000-8000-000000000001", "019abcde-0000-7000-8000-000000000003"], - ); + expect(suggestionReplacements(activeComposerSuggestions("/resume ", notes, [], threads, [], null, { activeThreadId }))).toEqual([ + "019abcde-0000-7000-8000-000000000001", + "019abcde-0000-7000-8000-000000000003", + ]); expect(activeComposerSuggestions("/refer current", notes, [], threads, [], null, { activeThreadId })).toEqual([]); expect(activeComposerSuggestions("/archive current", notes, [], threads, [], null, { activeThreadId })[0]?.replacement).toBe( activeThreadId, @@ -348,13 +353,7 @@ describe("composer suggestions", () => { }); it("suggests slash subcommands from command definitions", () => { - expect(activeComposerSuggestions("/goal ", notes, []).map((suggestion) => suggestion.replacement)).toEqual([ - "set", - "edit", - "pause", - "resume", - "clear", - ]); + expect(suggestionReplacements(activeComposerSuggestions("/goal ", notes, []))).toEqual(["set", "edit", "pause", "resume", "clear"]); expect(activeComposerSuggestions("/goal p", notes, [])[0]).toMatchObject({ display: "pause", detail: "/goal pause - Pause the current thread goal.", @@ -385,7 +384,7 @@ describe("composer suggestions", () => { replacement: "gpt-5.4-mini", appendSpaceOnInsert: true, }); - expect(activeComposerSuggestions("/model ", notes, [], [], models).map((suggestion) => suggestion.replacement)).toEqual([ + expect(suggestionReplacements(activeComposerSuggestions("/model ", notes, [], [], models))).toEqual([ "default", "gpt-5.4-mini", "gpt-5.5", @@ -404,9 +403,12 @@ describe("composer suggestions", () => { replacement: "high", appendSpaceOnInsert: true, }); - expect( - activeComposerSuggestions("/reasoning ", notes, [], [], models, "gpt-5.4-mini").map((suggestion) => suggestion.replacement), - ).toEqual(["default", "minimal", "low", "medium"]); + expect(suggestionReplacements(activeComposerSuggestions("/reasoning ", notes, [], [], models, "gpt-5.4-mini"))).toEqual([ + "default", + "minimal", + "low", + "medium", + ]); expect(activeComposerSuggestions("/reasoning x", notes, [], [], models, "gpt-5.4-mini")).toEqual([]); expect(activeComposerSuggestions("/reasoning high", notes, [], [], models, "gpt-5.5")).toEqual([]); expect(activeComposerSuggestions("/reasoning high ", notes, [], [], models, "gpt-5.5")).toEqual([]); @@ -480,3 +482,7 @@ describe("composer suggestions", () => { expect(composerSuggestionSignature("/stat", 5)).not.toBe(dismissed); }); }); + +function suggestionReplacements(suggestions: readonly { replacement: string }[]): string[] { + return suggestions.map((suggestion) => suggestion.replacement); +} diff --git a/tests/features/chat/protocol/inbound/routing.test.ts b/tests/features/chat/protocol/inbound/routing.test.ts index b994aff0..ef51f3d9 100644 --- a/tests/features/chat/protocol/inbound/routing.test.ts +++ b/tests/features/chat/protocol/inbound/routing.test.ts @@ -13,6 +13,9 @@ import type { ServerNotification, ServerRequest } from "../../../../../src/app-s import { chatStateFixture, chatStateWith } from "../../support/state"; const activeScope = { activeThreadId: "thread-active", activeTurnId: "turn-active" }; +type RouteScope = Parameters[1]; +type NotificationRouteKind = ReturnType["kind"]; +type RequestRouteKind = ReturnType["kind"]; describe("chat inbound routing", () => { it("keeps routed notification methods covered by matching planners", () => { @@ -39,9 +42,9 @@ describe("chat inbound routing", () => { }, } satisfies Extract; - expect(routeServerNotification(notification, activeScope).kind).toBe("turnLifecycle"); - expect(routeServerNotification(notification, { activeThreadId: "thread-other", activeTurnId: "turn-active" }).kind).toBe("inactive"); - expect(routeServerNotification(notification, { activeThreadId: "thread-active", activeTurnId: "turn-other" }).kind).toBe("inactive"); + expectNotificationRouteKind(notification, "turnLifecycle"); + expectNotificationRouteKind(notification, "inactive", { activeThreadId: "thread-other", activeTurnId: "turn-active" }); + expectNotificationRouteKind(notification, "inactive", { activeThreadId: "thread-active", activeTurnId: "turn-other" }); }); it("routes thread-started notifications as global thread catalog events", () => { @@ -50,10 +53,8 @@ describe("chat inbound routing", () => { params: { thread: threadSnapshot("thread-active") }, } satisfies Extract; - expect(routeServerNotification(notification, activeScope).kind).toBe("threadLifecycle"); - expect(routeServerNotification(notification, { activeThreadId: "thread-other", activeTurnId: "turn-active" }).kind).toBe( - "threadLifecycle", - ); + expectNotificationRouteKind(notification, "threadLifecycle"); + expectNotificationRouteKind(notification, "threadLifecycle", { activeThreadId: "thread-other", activeTurnId: "turn-active" }); }); it.each([ @@ -64,16 +65,12 @@ describe("chat inbound routing", () => { { name: "MCP elicitation", request: mcpElicitationRequest(), kind: "mcpElicitation" }, { name: "current time", request: currentTimeRequest("thread-active"), kind: "currentTime" }, { name: "dynamic tool call", request: dynamicToolCallRequest(), kind: "unsupported" }, - ])("classifies $name server requests and extracts scope", ({ request, kind }) => { - expect(routeServerRequest(request, activeScope).kind).toBe(kind); + ] as const)("classifies $name server requests and extracts scope", ({ request, kind }) => { + expectRequestRouteKind(request, kind); if ("turnId" in request.params) { - expect( - routeServerRequest({ ...request, params: { ...request.params, turnId: "turn-other" } } as ServerRequest, activeScope).kind, - ).toBe("inactive"); + expectRequestRouteKind({ ...request, params: { ...request.params, turnId: "turn-other" } } as ServerRequest, "inactive"); } - expect( - routeServerRequest({ ...request, params: { ...request.params, threadId: "thread-other" } } as ServerRequest, activeScope).kind, - ).toBe("inactive"); + expectRequestRouteKind({ ...request, params: { ...request.params, threadId: "thread-other" } } as ServerRequest, "inactive"); }); it("marks scoped messages inactive when the thread or turn does not match", () => { @@ -86,63 +83,55 @@ describe("chat inbound routing", () => { params: { threadId: "thread-active", turnId: "turn-other", itemId: "item", delta: "ignored" }, } satisfies Extract; - expect(routeServerNotification(otherThread, activeScope).kind).toBe("inactive"); - expect(routeServerNotification(otherTurn, activeScope).kind).toBe("inactive"); + expectNotificationRouteKind(otherThread, "inactive"); + expectNotificationRouteKind(otherTurn, "inactive"); }); it("marks delayed stream updates inactive after the active thread returns to idle", () => { const idleActiveThreadScope = { activeThreadId: "thread-active", activeTurnId: null }; - expect(routeServerNotification(agentDeltaNotification(), idleActiveThreadScope).kind).toBe("inactive"); + expectNotificationRouteKind(agentDeltaNotification(), "inactive", idleActiveThreadScope); }); it("marks delayed turn-scoped requests inactive after the active thread returns to idle", () => { const idleActiveThreadScope = { activeThreadId: "thread-active", activeTurnId: null }; const request = userInputRequest({ threadId: "thread-active" }); - expect(routeServerRequest(request, idleActiveThreadScope).kind).toBe("inactive"); + expectRequestRouteKind(request, "inactive", idleActiveThreadScope); }); it("routes thread catalog notifications even when another thread is active", () => { - expect(routeServerNotification({ method: "thread/archived", params: { threadId: "thread-other" } }, activeScope).kind).toBe( - "threadLifecycle", - ); - expect( - routeServerNotification({ method: "thread/name/updated", params: { threadId: "thread-other", threadName: "Renamed" } }, activeScope) - .kind, - ).toBe("threadLifecycle"); - expect(routeServerNotification({ method: "thread/unarchived", params: { threadId: "thread-other" } }, activeScope).kind).toBe( + expectNotificationRouteKind({ method: "thread/archived", params: { threadId: "thread-other" } }, "threadLifecycle"); + expectNotificationRouteKind( + { method: "thread/name/updated", params: { threadId: "thread-other", threadName: "Renamed" } }, "threadLifecycle", ); + expectNotificationRouteKind({ method: "thread/unarchived", params: { threadId: "thread-other" } }, "threadLifecycle"); }); it("keeps active-thread, broadcast, and targeted-thread notification routing distinct", () => { - expect(routeServerNotification(threadSettingsUpdatedNotification(), activeScope).kind).toBe("threadLifecycle"); - expect( - routeServerNotification( - { - method: "thread/settings/updated", - params: { ...threadSettingsUpdatedNotification().params, threadId: "thread-other" }, - }, - activeScope, - ).kind, - ).toBe("inactive"); + expectNotificationRouteKind(threadSettingsUpdatedNotification(), "threadLifecycle"); + expectNotificationRouteKind( + { + method: "thread/settings/updated", + params: { ...threadSettingsUpdatedNotification().params, threadId: "thread-other" }, + }, + "inactive", + ); - expect(routeServerNotification({ method: "skills/changed", params: {} }, activeScope).kind).toBe("diagnosticStatus"); - expect(routeServerNotification(threadArchivedNotification("thread-other"), activeScope).kind).toBe("threadLifecycle"); + expectNotificationRouteKind({ method: "skills/changed", params: {} }, "diagnosticStatus"); + expectNotificationRouteKind(threadArchivedNotification("thread-other"), "threadLifecycle"); }); it("keeps active-thread-only lifecycle notifications scoped to the active thread", () => { const notification = threadSettingsUpdatedNotification(); - expect( - routeServerNotification( - { - method: "thread/settings/updated", - params: { ...notification.params, threadId: "thread-other" }, - }, - activeScope, - ).kind, - ).toBe("inactive"); + expectNotificationRouteKind( + { + method: "thread/settings/updated", + params: { ...notification.params, threadId: "thread-other" }, + }, + "inactive", + ); }); it.each([ @@ -150,8 +139,8 @@ describe("chat inbound routing", () => { { name: "user input", request: userInputRequest(), kind: "userInput" }, { name: "MCP elicitation", request: mcpElicitationRequest(), kind: "mcpElicitation" }, { name: "current time", request: currentTimeRequest("thread-active"), kind: "currentTime" }, - ])("classifies $name before unsupported requests", ({ request, kind }) => { - expect(routeServerRequest(request, { activeThreadId: null, activeTurnId: null }).kind).toBe(kind); + ] as const)("classifies $name before unsupported requests", ({ request, kind }) => { + expectRequestRouteKind(request, kind, { activeThreadId: null, activeTurnId: null }); }); it("classifies inactive requests before request-family handling", () => { @@ -166,7 +155,7 @@ describe("chat inbound routing", () => { request, })), )("keeps unscoped unsupported request $name out of active-turn routing", ({ request }) => { - expect(routeServerRequest(request, activeScope).kind).toBe("unsupported"); + expectRequestRouteKind(request, "unsupported"); }); it("routes unknown runtime server requests to the unknown fallback", () => { @@ -176,16 +165,14 @@ describe("chat inbound routing", () => { params: { threadId: "thread-active", turnId: "turn-active" }, } as unknown as ServerRequest; - expect(routeServerRequest(request, activeScope).kind).toBe("unknown"); - expect( - routeServerRequest( - { - ...request, - params: { threadId: "thread-other", turnId: "turn-active" }, - } as unknown as ServerRequest, - activeScope, - ).kind, - ).toBe("inactive"); + expectRequestRouteKind(request, "unknown"); + expectRequestRouteKind( + { + ...request, + params: { threadId: "thread-other", turnId: "turn-active" }, + } as unknown as ServerRequest, + "inactive", + ); }); it.each([ @@ -197,8 +184,8 @@ describe("chat inbound routing", () => { { name: "server request resolved", notification: serverRequestResolvedNotification(), kind: "requestResolved" }, { name: "MCP startup status", notification: mcpStartupStatusNotification(), kind: "diagnosticStatus" }, { name: "warning", notification: warningNotification(), kind: "userVisibleNotice" }, - ])("classifies $name notifications without mutating state", ({ notification, kind }) => { - expect(routeServerNotification(notification, activeScope).kind).toBe(kind); + ] as const)("classifies $name notifications without mutating state", ({ notification, kind }) => { + expectNotificationRouteKind(notification, kind); }); it("leaves unhandled app-server notifications explicit", () => { @@ -219,16 +206,14 @@ describe("chat inbound routing", () => { params: { threadId: "thread-active", turnId: "turn-active" }, } as unknown as ServerNotification; - expect(routeServerNotification(notification, activeScope).kind).toBe("unhandled"); - expect( - routeServerNotification( - { - ...notification, - params: { threadId: "thread-active", turnId: "turn-other" }, - } as unknown as ServerNotification, - activeScope, - ).kind, - ).toBe("inactive"); + expectNotificationRouteKind(notification, "unhandled"); + expectNotificationRouteKind( + { + ...notification, + params: { threadId: "thread-active", turnId: "turn-other" }, + } as unknown as ServerNotification, + "inactive", + ); }); it("safely ignores unknown runtime notifications in the planner", () => { @@ -249,25 +234,37 @@ describe("chat inbound routing", () => { { name: "terminal interaction", notification: terminalInteractionNotification }, { name: "model verification", notification: modelVerificationNotification }, ])("still scopes unhandled turn notification $name", ({ notification }) => { - expect(routeServerNotification(notification("thread-active", "turn-active"), activeScope).kind).toBe("unhandled"); - expect(routeServerNotification(notification("thread-other", "turn-active"), activeScope).kind).toBe("inactive"); - expect(routeServerNotification(notification("thread-active", "turn-other"), activeScope).kind).toBe("inactive"); + expectNotificationRouteKind(notification("thread-active", "turn-active"), "unhandled"); + expectNotificationRouteKind(notification("thread-other", "turn-active"), "inactive"); + expectNotificationRouteKind(notification("thread-active", "turn-other"), "inactive"); }); it.each([ { name: "thread status changed", notification: threadStatusChangedNotification }, { name: "thread closed", notification: threadClosedNotification }, ])("still scopes unhandled thread lifecycle notification $name", ({ notification }) => { - expect(routeServerNotification(notification("thread-active"), activeScope).kind).toBe("unhandled"); - expect(routeServerNotification(notification("thread-other"), activeScope).kind).toBe("inactive"); + expectNotificationRouteKind(notification("thread-active"), "unhandled"); + expectNotificationRouteKind(notification("thread-other"), "inactive"); }); it("scopes MCP startup status notifications when app-server provides a thread id", () => { - expect(routeServerNotification(mcpStartupStatusNotificationForThread("thread-active"), activeScope).kind).toBe("diagnosticStatus"); - expect(routeServerNotification(mcpStartupStatusNotificationForThread("thread-other"), activeScope).kind).toBe("inactive"); + expectNotificationRouteKind(mcpStartupStatusNotificationForThread("thread-active"), "diagnosticStatus"); + expectNotificationRouteKind(mcpStartupStatusNotificationForThread("thread-other"), "inactive"); }); }); +function expectNotificationRouteKind( + notification: ServerNotification, + expectedKind: NotificationRouteKind, + scope: RouteScope = activeScope, +): void { + expect(routeServerNotification(notification, scope).kind).toBe(expectedKind); +} + +function expectRequestRouteKind(request: ServerRequest, expectedKind: RequestRouteKind, scope: RouteScope = activeScope): void { + expect(routeServerRequest(request, scope).kind).toBe(expectedKind); +} + function commandApprovalRequest(): ServerRequest { return { id: 1, diff --git a/tests/features/chat/protocol/server-requests/approval.test.ts b/tests/features/chat/protocol/server-requests/approval.test.ts index 42c245c6..501fae1d 100644 --- a/tests/features/chat/protocol/server-requests/approval.test.ts +++ b/tests/features/chat/protocol/server-requests/approval.test.ts @@ -26,6 +26,15 @@ function approvalActionOptions(approval: NonNullable>): (string | null)[] { + return approvalActionOptions(approval).map((option) => option.label); +} + +function approvalResponseAt(approval: NonNullable>, index: number): unknown { + const option = expectPresent(approvalActionOptions(approval)[index]); + return approvalResponse(approval, option.action); +} + describe("approval model", () => { it("classifies command approvals and builds v2 decisions", () => { const request: ServerRequest = { @@ -50,8 +59,8 @@ describe("approval model", () => { expect(approval.title).toBe("Command approval"); expect(approval.summary).toBe("npm run build"); - expect(approvalActionOptions(approval).map((option) => option.label)).toEqual(["Allow", "Allow session", "Deny", "Cancel"]); - expect(approvalResponse(approval, expectPresent(approvalActionOptions(approval)[1]).action)).toEqual({ decision: "acceptForSession" }); + expect(approvalActionLabels(approval)).toEqual(["Allow", "Allow session", "Deny", "Cancel"]); + expect(approvalResponseAt(approval, 1)).toEqual({ decision: "acceptForSession" }); }); it("builds permission grants only for accept actions", () => { @@ -129,9 +138,9 @@ describe("approval model", () => { expect(options.map((option) => option.label)).toEqual(["Allow network rule", "Allow network rule", "Deny"]); expect(options.map((option) => option.className)).toEqual(["", "", "mod-warning"]); expect(new Set(options.map((option) => option.id)).size).toBe(options.length); - expect(approvalResponse(approval, expectPresent(options[0]).action)).toEqual({ decision: allowRegistryDecision }); - expect(approvalResponse(approval, expectPresent(options[1]).action)).toEqual({ decision: allowApiDecision }); - expect(approvalResponse(approval, expectPresent(options[2]).action)).toEqual({ decision: "decline" }); + expect(approvalResponseAt(approval, 0)).toEqual({ decision: allowRegistryDecision }); + expect(approvalResponseAt(approval, 1)).toEqual({ decision: allowApiDecision }); + expect(approvalResponseAt(approval, 2)).toEqual({ decision: "decline" }); expect(createApprovalResultItem(approval, expectPresent(options[0]).action)).toMatchObject({ approval: { status: "allowed for session" }, }); @@ -171,7 +180,7 @@ describe("approval model", () => { className: "mod-warning", }, ]); - expect(approvalResponse(approval, expectPresent(options[0]).action)).toEqual({ decision: futureDecision }); + expect(approvalResponseAt(approval, 0)).toEqual({ decision: futureDecision }); }); it("falls back to generic command approval actions when app-server omits decisions", () => { @@ -195,7 +204,7 @@ describe("approval model", () => { }), ); - expect(approvalActionOptions(approval).map((option) => option.label)).toEqual(["Allow", "Allow session", "Deny", "Cancel"]); + expect(approvalActionLabels(approval)).toEqual(["Allow", "Allow session", "Deny", "Cancel"]); expect(approvalResponse(approval, "accept-session")).toEqual({ decision: "acceptForSession" }); }); @@ -431,10 +440,9 @@ describe("approval model", () => { ({ request, acceptSession, cancel }) => { const approval = expectPresent(toPendingApproval(request)); if (approval.kind === "command") { - const options = approvalActionOptions(approval); - expect(approvalResponse(approval, expectPresent(options[0]).action)).toEqual(acceptSession); - expect(approvalResponse(approval, expectPresent(options[1]).action)).toEqual(cancel); - expect(approvalResponse(approval, expectPresent(options[2]).action)).toEqual({ decision: "decline" }); + expect(approvalResponseAt(approval, 0)).toEqual(acceptSession); + expect(approvalResponseAt(approval, 1)).toEqual(cancel); + expect(approvalResponseAt(approval, 2)).toEqual({ decision: "decline" }); return; } expect(approvalResponse(approval, "accept-session")).toEqual(acceptSession); diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx index 765ef497..0b812398 100644 --- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx @@ -9,7 +9,7 @@ import { implementPlanTargetFromState } from "../../../../../src/features/chat/a import { MESSAGE_CONTENT_RENDERED_EVENT } from "../../../../../src/features/chat/ui/message-stream/content-events"; import { MarkdownMessageRenderer } from "../../../../../src/features/chat/ui/message-stream/markdown-renderer"; import { deferred } from "../../../../support/async"; -import { topLevelDetailsSummaries } from "../../../../support/dom"; +import { attributeValues, textContents, topLevelDetailsSummaries } from "../../../../support/dom"; import "./setup"; import { expectPresent, @@ -71,11 +71,11 @@ describe("message stream rendering and message action menu", () => { }), ); - expect( - [...parent.querySelectorAll("[data-codex-panel-block-key]")].map((element) => - element.getAttribute("data-codex-panel-block-key"), - ), - ).toEqual(["item:u1", "activity:turn-t1-activity", "item:a1"]); + expect(attributeValues(parent, "[data-codex-panel-block-key]", "data-codex-panel-block-key")).toEqual([ + "item:u1", + "activity:turn-t1-activity", + "item:a1", + ]); const activitySummary = parent.querySelector('[data-codex-panel-block-key="activity:turn-t1-activity"] summary'); expect(activitySummary?.textContent).toBe("Work details"); expect(activitySummary?.tabIndex).toBe(-1); @@ -205,13 +205,13 @@ describe("message stream rendering and message action menu", () => { expect(element.classList.contains("codex-panel__execution--completed")).toBe(true); expect(topLevelDetailsSummaries(element)).toEqual(["auto-review"]); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["auto-review"]); + expect(textContents(element, "details summary")).toEqual(["auto-review"]); expect(element.textContent).not.toContain("Details"); expect(element.textContent).not.toContain("â–¶Review"); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusapproved"); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionapply patch"); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("filessrc/ui/detail-view.ts\nsrc/ui/message-stream.ts"); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([]); + expect(textContents(element, ".codex-panel__output-title")).toEqual([]); }); it("renders structured system result details as visible selectable meta rows", () => { @@ -251,10 +251,7 @@ describe("message stream rendering and message action menu", () => { expect(element.querySelector(".codex-panel__output-title")).toBeNull(); expect(element.querySelector(".codex-panel__meta-grid")).toBeNull(); expect(element.querySelectorAll(".codex-panel__system-result-grid")).toHaveLength(1); - expect([...element.querySelectorAll(".codex-panel__system-result-heading")].map((heading) => heading.textContent)).toEqual([ - "Thread", - "Runtime", - ]); + expect(textContents(element, ".codex-panel__system-result-heading")).toEqual(["Thread", "Runtime"]); expect(element.querySelector(".codex-panel__system-result-grid")?.textContent).toContain("/helpShow available Codex slash commands."); expect(element.querySelector(".codex-panel__system-result-grid")?.textContent).toContain( "/resume [thread]Resume a recent Codex thread.", @@ -985,9 +982,9 @@ describe("message stream rendering and message action menu", () => { expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("npm run check (exit 1)"); expect(element.querySelector(".codex-panel__stream-summary")?.getAttribute("title")).toBeNull(); expect(topLevelDetailsSummaries(element)).toEqual(["command"]); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["command"]); + expect(textContents(element, "details summary")).toEqual(["command"]); expect(element.textContent).not.toContain("Details"); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual(["Output"]); + expect(textContents(element, ".codex-panel__output-title")).toEqual(["Output"]); expect(element.querySelector(".codex-panel__output pre")?.textContent).toBe("stderr details"); expect(element.querySelector("details")?.hasAttribute("open")).toBe(false); }); @@ -1043,7 +1040,7 @@ describe("message stream rendering and message action menu", () => { const element = renderMessageBlockElement(block); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["read"]); + expect(textContents(element, "details summary")).toEqual(["read"]); }); it("derives command summaries from semantic command targets instead of item text", () => { @@ -1091,12 +1088,9 @@ describe("message stream rendering and message action menu", () => { expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("src/main.ts"); expect(topLevelDetailsSummaries(element)).toEqual(["file change"]); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["file change"]); + expect(textContents(element, "details summary")).toEqual(["file change"]); expect(element.textContent).not.toContain("Details"); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([ - "update src/main.ts", - "Patch output", - ]); + expect(textContents(element, ".codex-panel__output-title")).toEqual(["update src/main.ts", "Patch output"]); }); it("derives file change summaries from changes and status instead of item text", () => { diff --git a/tests/features/chat/ui/message-stream/pending-requests.test.tsx b/tests/features/chat/ui/message-stream/pending-requests.test.tsx index 1b64bc85..2befd768 100644 --- a/tests/features/chat/ui/message-stream/pending-requests.test.tsx +++ b/tests/features/chat/ui/message-stream/pending-requests.test.tsx @@ -9,7 +9,7 @@ import { import type { PendingApproval, PendingMcpElicitation, PendingUserInput } from "../../../../../src/domain/pending-requests/model"; import type { PendingRequestBlockContext } from "../../../../../src/features/chat/ui/message-stream/context"; import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; -import { changeInputValue } from "../../../../support/dom"; +import { changeInputValue, textContents } from "../../../../support/dom"; import "./setup"; import { actEvent, @@ -210,7 +210,7 @@ describe("pending request renderer decisions", () => { expect(parent.querySelector(".codex-panel__approval-details summary")?.textContent).toBe("Request details"); expect(parent.querySelector(".codex-panel__approval-details summary")?.tabIndex).toBe(-1); expect(parent.querySelector(".codex-panel__user-input .codex-panel__pending-request-title")?.textContent).toBe("Codex needs input"); - expect([...parent.querySelectorAll(".codex-panel__pending-request-button")].map((button) => button.textContent)).toEqual([ + expect(textContents(parent, ".codex-panel__pending-request-button")).toEqual([ "Allow", "Allow session", "Deny", @@ -352,7 +352,7 @@ describe("pending request renderer decisions", () => { ); const buttons = [...parent.querySelectorAll(".codex-panel__pending-request-button")]; - expect(buttons.map((button) => button.textContent)).toEqual(["Allow network rule", "Deny"]); + expect(textContents(parent, ".codex-panel__pending-request-button")).toEqual(["Allow network rule", "Deny"]); const allowButton = buttons.at(0); if (!allowButton) throw new Error("Missing allow button"); actEvent(() => { diff --git a/tests/features/chat/ui/message-stream/stream-items.test.tsx b/tests/features/chat/ui/message-stream/stream-items.test.tsx index ac8ab9bd..58c52cad 100644 --- a/tests/features/chat/ui/message-stream/stream-items.test.tsx +++ b/tests/features/chat/ui/message-stream/stream-items.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items"; -import { topLevelDetailsSummaries } from "../../../../support/dom"; +import { textContents, topLevelDetailsSummaries } from "../../../../support/dom"; import "./setup"; import { expectPresent, @@ -40,13 +40,10 @@ describe("message stream item renderer decisions", () => { expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("123"); expect(topLevelDetailsSummaries(element)).toEqual(["github.pull_request_read"]); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["github.pull_request_read"]); + expect(textContents(element, "details summary")).toEqual(["github.pull_request_read"]); expect(element.querySelector("details summary")?.tabIndex).toBe(-1); expect(element.textContent).not.toContain("Details"); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([ - "Arguments JSON", - "Result JSON", - ]); + expect(textContents(element, ".codex-panel__output-title")).toEqual(["Arguments JSON", "Result JSON"]); }); it("renders steering activity as a compact two-line tool summary", () => { @@ -220,13 +217,13 @@ describe("message stream item renderer decisions", () => { expect(topLevelDetailsSummaries(element)).toEqual(["hook"]); expect(element.classList.contains("codex-panel__execution--completed")).toBe(true); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["hook"]); + expect(textContents(element, "details summary")).toEqual(["hook"]); expect(element.querySelector(".codex-panel__stream-summary")?.textContent).toBe("postToolUse: Formatted 1 file."); expect(element.textContent).not.toContain("Details"); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statuscompleted"); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("eventpostToolUse"); expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("messageFormatted 1 file."); - expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual(["Hook output"]); + expect(textContents(element, ".codex-panel__output-title")).toEqual(["Hook output"]); expect(element.querySelector(".codex-panel__output pre")?.textContent).toBe("feedback: ok"); }); @@ -497,7 +494,7 @@ describe("message stream item renderer decisions", () => { expect(element.querySelector(".codex-panel__message-role")?.textContent).toBe("agent"); const summary = expectPresent(element.querySelector(".codex-panel__stream-summary")); expect(summary.textContent).toBe("spawn child: Inspect the renderer. (completed)"); - expect([...element.querySelectorAll("details summary")].map((detailsSummary) => detailsSummary.textContent)).toEqual(["agent"]); + expect(textContents(element, "details summary")).toEqual(["agent"]); expect(element.textContent).toContain("targetchild"); expect(element.textContent).toContain("PromptInspect the renderer."); expect(element.textContent).toContain("childcompleted: Done"); @@ -559,13 +556,11 @@ describe("message stream item renderer decisions", () => { const element = renderMessageBlockElement(block); - const agentRows = [...element.querySelectorAll(".codex-panel__meta-grid dt, .codex-panel__meta-grid dd")].map( - (node) => node.textContent, - ); + const agentRows = textContents(element, ".codex-panel__meta-grid dt, .codex-panel__meta-grid dd"); expect(agentRows).toContain("019e061e"); expect(agentRows).toContain("completed: Done"); expect(agentRows.join("")).not.toContain("a".repeat(180)); - expect([...element.querySelectorAll("details summary")].map((summary) => summary.textContent)).toEqual(["agent"]); + expect(textContents(element, "details summary")).toEqual(["agent"]); expect(element.querySelector("details summary")?.tabIndex).toBe(-1); expect(element.textContent).toContain("Agent output 019e061e"); expect(element.textContent).toContain(longMessage); diff --git a/tests/support/dom.ts b/tests/support/dom.ts index 45a927ce..91e631ce 100644 --- a/tests/support/dom.ts +++ b/tests/support/dom.ts @@ -17,6 +17,14 @@ export function topLevelDetailsSummaries(element: HTMLElement): (string | null)[ .map((details) => details.querySelector("summary")?.textContent ?? null); } +export function textContents(element: ParentNode, selector: string): (string | null)[] { + return [...element.querySelectorAll(selector)].map((node) => node.textContent); +} + +export function attributeValues(element: ParentNode, selector: string, attribute: string): (string | null)[] { + return [...element.querySelectorAll(selector)].map((node) => node.getAttribute(attribute)); +} + export function changeInputValue(input: HTMLInputElement | HTMLTextAreaElement, value: string): void { const prototype = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const valueDescriptor = Object.getOwnPropertyDescriptor(prototype, "value");