From a00c2258997ab0ca623b57dab65e192350b5f31d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 19:23:40 +0100 Subject: [PATCH] test: split pure tool-card coverage --- ui/src/ui/chat/session-controls.test.ts | 7 +- ui/src/ui/chat/tool-cards.node.test.ts | 255 ++++++++++++++++++++++++ ui/src/ui/chat/tool-cards.test.ts | 235 +--------------------- 3 files changed, 258 insertions(+), 239 deletions(-) create mode 100644 ui/src/ui/chat/tool-cards.node.test.ts diff --git a/ui/src/ui/chat/session-controls.test.ts b/ui/src/ui/chat/session-controls.test.ts index d051027f2e8..161d21d43c8 100644 --- a/ui/src/ui/chat/session-controls.test.ts +++ b/ui/src/ui/chat/session-controls.test.ts @@ -160,14 +160,11 @@ function createChatHeaderState( return { state, request }; } -async function flushTasks(turns = 6) { - for (let i = 0; i < turns; i += 1) { - await Promise.resolve(); - } - await new Promise((resolve) => setTimeout(resolve, 0)); +async function flushTasks(turns = 8) { for (let i = 0; i < turns; i += 1) { await Promise.resolve(); } + await vi.dynamicImportSettled(); } afterEach(() => { diff --git a/ui/src/ui/chat/tool-cards.node.test.ts b/ui/src/ui/chat/tool-cards.node.test.ts new file mode 100644 index 00000000000..46c1e86e328 --- /dev/null +++ b/ui/src/ui/chat/tool-cards.node.test.ts @@ -0,0 +1,255 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; +import { buildToolCardSidebarContent, extractToolCards } from "./tool-cards.ts"; + +vi.mock("../icons.ts", () => ({ + icons: {}, +})); + +vi.mock("../tool-display.ts", () => ({ + formatToolDetail: () => undefined, + resolveToolDisplay: ({ name }: { name: string }) => ({ + name, + label: name + .split(/[._-]/g) + .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)) + .join(" "), + icon: "zap", + }), +})); + +describe("tool-card extraction", () => { + it("pretty-prints structured args and pairs tool output onto the same card", () => { + const cards = extractToolCards( + { + role: "assistant", + toolCallId: "call-1", + content: [ + { + type: "toolcall", + id: "call-1", + name: "browser.open", + arguments: { url: "https://example.com", retry: 0 }, + }, + { + type: "toolresult", + id: "call-1", + name: "browser.open", + text: "Opened page", + }, + ], + }, + "msg:1", + ); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + id: "msg:1:call-1", + name: "browser.open", + outputText: "Opened page", + }); + expect(cards[0]?.inputText).toContain('"url": "https://example.com"'); + expect(cards[0]?.inputText).toContain('"retry": 0'); + }); + + it("preserves string args verbatim and keeps empty-output cards", () => { + const cards = extractToolCards( + { + role: "assistant", + toolCallId: "call-2", + content: [ + { + type: "toolcall", + name: "deck_manage", + arguments: "with Example Deck", + }, + ], + }, + "msg:2", + ); + + expect(cards).toHaveLength(1); + expect(cards[0]?.inputText).toBe("with Example Deck"); + expect(cards[0]?.outputText).toBeUndefined(); + }); + + it("preserves tool-call input payloads from tool_use blocks", () => { + const cards = extractToolCards( + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "call-2b", + name: "deck_manage", + input: { deck: "Example Deck", mode: "preview" }, + }, + ], + }, + "msg:2b", + ); + + expect(cards).toHaveLength(1); + expect(cards[0]?.inputText).toContain('"deck": "Example Deck"'); + expect(cards[0]?.inputText).toContain('"mode": "preview"'); + }); + + it("pairs interleaved nameless tool results in content order", () => { + const cards = extractToolCards( + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "browser.open", + input: { url: "https://example.com/a" }, + }, + { + type: "tool_result", + name: "browser.open", + text: "Opened A", + }, + { + type: "tool_use", + name: "browser.open", + input: { url: "https://example.com/b" }, + }, + { + type: "tool_result", + name: "browser.open", + text: "Opened B", + }, + ], + }, + "msg:ordered", + ); + + expect(cards).toHaveLength(2); + expect(cards[0]).toMatchObject({ + inputText: '{\n "url": "https://example.com/a"\n}', + outputText: "Opened A", + }); + expect(cards[1]).toMatchObject({ + inputText: '{\n "url": "https://example.com/b"\n}', + outputText: "Opened B", + }); + }); + + it("builds sidebar content with input and empty output status", () => { + const [card] = extractToolCards( + { + role: "assistant", + toolCallId: "call-3", + content: [ + { + type: "toolcall", + name: "deck_manage", + arguments: "with Example Deck", + }, + ], + }, + "msg:3", + ); + + const sidebar = buildToolCardSidebarContent(card); + expect(sidebar).toContain("## Deck Manage"); + expect(sidebar).toContain("### Tool input"); + expect(sidebar).toContain("with Example Deck"); + expect(sidebar).toContain("### Tool output"); + expect(sidebar).toContain("No output"); + }); + + it("extracts canvas handle payloads into canvas previews", () => { + const [card] = extractToolCards( + { + role: "tool", + toolName: "canvas_render", + content: JSON.stringify({ + kind: "canvas", + view: { + backend: "canvas", + id: "cv_inline", + url: "/__openclaw__/canvas/documents/cv_inline/index.html", + }, + presentation: { + target: "assistant_message", + title: "Inline demo", + preferred_height: 420, + }, + }), + }, + "msg:view:1", + ); + + expect(card?.preview).toMatchObject({ + kind: "canvas", + surface: "assistant_message", + render: "url", + viewId: "cv_inline", + url: "/__openclaw__/canvas/documents/cv_inline/index.html", + title: "Inline demo", + preferredHeight: 420, + }); + }); + + it("does not create previews for non-assistant canvas or generic outputs", () => { + const cases = [ + { + name: "tool-card target", + toolName: "canvas_render", + content: JSON.stringify({ + kind: "canvas", + view: { + backend: "canvas", + id: "cv_tool_card", + url: "/__openclaw__/canvas/documents/cv_tool_card/index.html", + }, + presentation: { + target: "tool_card", + title: "Tool card demo", + }, + }), + }, + { + name: "inline html", + toolName: "canvas_render", + content: JSON.stringify({ + kind: "canvas", + source: { + type: "html", + content: "
hello
", + }, + presentation: { + target: "assistant_message", + title: "Status", + preferred_height: 300, + }, + }), + }, + { + name: "malformed json", + toolName: "canvas_render", + content: '{"kind":"present_view","view":{"id":"broken"}', + }, + { + name: "generic text", + toolName: "browser.open", + content: "present_view: cv_widget", + }, + ] as const; + + for (const testCase of cases) { + const [card] = extractToolCards( + { + role: "tool", + toolName: testCase.toolName, + content: testCase.content, + }, + `msg:view:${testCase.name}`, + ); + + expect(card?.preview, testCase.name).toBeUndefined(); + } + }); +}); diff --git a/ui/src/ui/chat/tool-cards.test.ts b/ui/src/ui/chat/tool-cards.test.ts index ab83169c3c2..1c58fa6b66d 100644 --- a/ui/src/ui/chat/tool-cards.test.ts +++ b/ui/src/ui/chat/tool-cards.test.ts @@ -2,7 +2,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { buildToolCardSidebarContent, extractToolCards, renderToolCard } from "./tool-cards.ts"; +import { renderToolCard } from "./tool-cards.ts"; vi.mock("../icons.ts", () => ({ icons: {}, @@ -21,239 +21,6 @@ vi.mock("../tool-display.ts", () => ({ })); describe("tool-cards", () => { - it("pretty-prints structured args and pairs tool output onto the same card", () => { - const cards = extractToolCards( - { - role: "assistant", - toolCallId: "call-1", - content: [ - { - type: "toolcall", - id: "call-1", - name: "browser.open", - arguments: { url: "https://example.com", retry: 0 }, - }, - { - type: "toolresult", - id: "call-1", - name: "browser.open", - text: "Opened page", - }, - ], - }, - "msg:1", - ); - - expect(cards).toHaveLength(1); - expect(cards[0]).toMatchObject({ - id: "msg:1:call-1", - name: "browser.open", - outputText: "Opened page", - }); - expect(cards[0]?.inputText).toContain('"url": "https://example.com"'); - expect(cards[0]?.inputText).toContain('"retry": 0'); - }); - - it("preserves string args verbatim and keeps empty-output cards", () => { - const cards = extractToolCards( - { - role: "assistant", - toolCallId: "call-2", - content: [ - { - type: "toolcall", - name: "deck_manage", - arguments: "with Example Deck", - }, - ], - }, - "msg:2", - ); - - expect(cards).toHaveLength(1); - expect(cards[0]?.inputText).toBe("with Example Deck"); - expect(cards[0]?.outputText).toBeUndefined(); - }); - - it("preserves tool-call input payloads from tool_use blocks", () => { - const cards = extractToolCards( - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call-2b", - name: "deck_manage", - input: { deck: "Example Deck", mode: "preview" }, - }, - ], - }, - "msg:2b", - ); - - expect(cards).toHaveLength(1); - expect(cards[0]?.inputText).toContain('"deck": "Example Deck"'); - expect(cards[0]?.inputText).toContain('"mode": "preview"'); - }); - - it("pairs interleaved nameless tool results in content order", () => { - const cards = extractToolCards( - { - role: "assistant", - content: [ - { - type: "tool_use", - name: "browser.open", - input: { url: "https://example.com/a" }, - }, - { - type: "tool_result", - name: "browser.open", - text: "Opened A", - }, - { - type: "tool_use", - name: "browser.open", - input: { url: "https://example.com/b" }, - }, - { - type: "tool_result", - name: "browser.open", - text: "Opened B", - }, - ], - }, - "msg:ordered", - ); - - expect(cards).toHaveLength(2); - expect(cards[0]).toMatchObject({ - inputText: '{\n "url": "https://example.com/a"\n}', - outputText: "Opened A", - }); - expect(cards[1]).toMatchObject({ - inputText: '{\n "url": "https://example.com/b"\n}', - outputText: "Opened B", - }); - }); - - it("builds sidebar content with input and empty output status", () => { - const [card] = extractToolCards( - { - role: "assistant", - toolCallId: "call-3", - content: [ - { - type: "toolcall", - name: "deck_manage", - arguments: "with Example Deck", - }, - ], - }, - "msg:3", - ); - - const sidebar = buildToolCardSidebarContent(card); - expect(sidebar).toContain("## Deck Manage"); - expect(sidebar).toContain("### Tool input"); - expect(sidebar).toContain("with Example Deck"); - expect(sidebar).toContain("### Tool output"); - expect(sidebar).toContain("No output"); - }); - - it("extracts canvas handle payloads into canvas previews", () => { - const [card] = extractToolCards( - { - role: "tool", - toolName: "canvas_render", - content: JSON.stringify({ - kind: "canvas", - view: { - backend: "canvas", - id: "cv_inline", - url: "/__openclaw__/canvas/documents/cv_inline/index.html", - }, - presentation: { - target: "assistant_message", - title: "Inline demo", - preferred_height: 420, - }, - }), - }, - "msg:view:1", - ); - - expect(card?.preview).toMatchObject({ - kind: "canvas", - surface: "assistant_message", - render: "url", - viewId: "cv_inline", - url: "/__openclaw__/canvas/documents/cv_inline/index.html", - title: "Inline demo", - preferredHeight: 420, - }); - }); - - it("does not create previews for non-assistant canvas or generic outputs", () => { - const cases = [ - { - name: "tool-card target", - toolName: "canvas_render", - content: JSON.stringify({ - kind: "canvas", - view: { - backend: "canvas", - id: "cv_tool_card", - url: "/__openclaw__/canvas/documents/cv_tool_card/index.html", - }, - presentation: { - target: "tool_card", - title: "Tool card demo", - }, - }), - }, - { - name: "inline html", - toolName: "canvas_render", - content: JSON.stringify({ - kind: "canvas", - source: { - type: "html", - content: "
hello
", - }, - presentation: { - target: "assistant_message", - title: "Status", - preferred_height: 300, - }, - }), - }, - { - name: "malformed json", - toolName: "canvas_render", - content: '{"kind":"present_view","view":{"id":"broken"}', - }, - { - name: "generic text", - toolName: "browser.open", - content: "present_view: cv_widget", - }, - ] as const; - - for (const testCase of cases) { - const [card] = extractToolCards( - { - role: "tool", - toolName: testCase.toolName, - content: testCase.content, - }, - `msg:view:${testCase.name}`, - ); - - expect(card?.preview, testCase.name).toBeUndefined(); - } - }); - it("renders expanded cards with inline input and output sections", () => { const container = document.createElement("div"); const toggle = vi.fn();