From f93b7da4c4f6af8332f5c35845a5080a45ece68d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 18:34:03 +0100 Subject: [PATCH] test: merge chat attachment cases --- ui/src/ui/views/chat.test.ts | 499 ++++++++++++++--------------------- 1 file changed, 192 insertions(+), 307 deletions(-) diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 64f39986612..8e159728a43 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -22,6 +22,12 @@ function flushTasks() { return new Promise((resolve) => queueMicrotask(resolve)); } +async function flushAssistantAttachmentAvailabilityChecks() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + function createProps(overrides: Partial = {}): ChatProps { return { sessionKey: "main", @@ -819,99 +825,63 @@ describe("chat view", () => { expect(container.textContent).toContain("Tic-Tac-Toe"); }); - it("renders assistant_message canvas results inside the assistant bubble when tool rows are hidden", () => { + it("renders hidden assistant_message canvas results with the configured sandbox", () => { const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - messages: [ - { - id: "assistant-canvas-inline", - role: "assistant", - content: [{ type: "text", text: "Inline canvas result." }], - timestamp: Date.now(), - }, - ], - toolMessages: [ - { - id: "tool-artifact-inline", - role: "tool", - toolCallId: "call-artifact-inline", - toolName: "canvas_render", - content: JSON.stringify({ - kind: "canvas", - view: { - backend: "canvas", - id: "cv_inline", - url: "/__openclaw__/canvas/documents/cv_inline/index.html", - title: "Inline demo", - preferred_height: 360, - }, - presentation: { - target: "assistant_message", - }, - }), - timestamp: Date.now() + 1, - }, - ], - }), - ), - container, - ); + const renderCanvas = (params: { embedSandboxMode?: "trusted"; suffix: string }) => + render( + renderChat( + createProps({ + ...(params.embedSandboxMode ? { embedSandboxMode: params.embedSandboxMode } : {}), + showToolCalls: false, + messages: [ + { + id: `assistant-canvas-inline-${params.suffix}`, + role: "assistant", + content: [{ type: "text", text: "Inline canvas result." }], + timestamp: Date.now(), + }, + ], + toolMessages: [ + { + id: `tool-artifact-inline-${params.suffix}`, + role: "tool", + toolCallId: `call-artifact-inline-${params.suffix}`, + toolName: "canvas_render", + content: JSON.stringify({ + kind: "canvas", + view: { + backend: "canvas", + id: `cv_inline_${params.suffix}`, + url: `/__openclaw__/canvas/documents/cv_inline_${params.suffix}/index.html`, + title: "Inline demo", + preferred_height: 360, + }, + presentation: { + target: "assistant_message", + }, + }), + timestamp: Date.now() + 1, + }, + ], + }), + ), + container, + ); - const iframe = container.querySelector(".chat-tool-card__preview-frame"); + renderCanvas({ suffix: "default" }); + + let iframe = container.querySelector(".chat-tool-card__preview-frame"); expect(iframe).not.toBeNull(); expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts"); - expect(iframe?.getAttribute("src")).toBe("/__openclaw__/canvas/documents/cv_inline/index.html"); + expect(iframe?.getAttribute("src")).toBe( + "/__openclaw__/canvas/documents/cv_inline_default/index.html", + ); expect(container.textContent).toContain("Inline canvas result."); expect(container.textContent).toContain("Inline demo"); expect(container.textContent).toContain("Raw details"); - }); - it("uses trusted embed sandbox mode when configured", () => { - const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - embedSandboxMode: "trusted", - messages: [ - { - id: "assistant-canvas-isolated", - role: "assistant", - content: [{ type: "text", text: "Inline canvas result." }], - timestamp: Date.now(), - }, - ], - toolMessages: [ - { - id: "tool-artifact-inline-isolated", - role: "tool", - toolCallId: "call-artifact-inline-isolated", - toolName: "canvas_render", - content: JSON.stringify({ - kind: "canvas", - view: { - backend: "canvas", - id: "cv_inline_isolated", - url: "/__openclaw__/canvas/documents/cv_inline_isolated/index.html", - title: "Inline demo", - preferred_height: 360, - }, - presentation: { - target: "assistant_message", - }, - }), - timestamp: Date.now() + 1, - }, - ], - }), - ), - container, - ); - - const iframe = container.querySelector(".chat-tool-card__preview-frame"); + renderCanvas({ embedSandboxMode: "trusted", suffix: "trusted" }); + iframe = container.querySelector(".chat-tool-card__preview-frame"); expect(iframe?.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin"); }); @@ -1145,9 +1115,7 @@ describe("chat view", () => { render(template(), container); expect(container.textContent).toContain("Checking..."); - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAssistantAttachmentAvailabilityChecks(); expect(fetchMock).toHaveBeenCalledWith( "/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&token=session-token&meta=1", @@ -1205,15 +1173,11 @@ describe("chat view", () => { ); renderWithToken(null); - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAssistantAttachmentAvailabilityChecks(); expect(container.textContent).toContain("Unavailable"); renderWithToken("fresh-token"); - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAssistantAttachmentAvailabilityChecks(); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenNthCalledWith( @@ -1293,7 +1257,7 @@ describe("chat view", () => { expect(container.textContent).toContain("Done"); }); - it("allows Windows file URLs inside allowed preview roots", async () => { + it("allows platform-specific local assistant attachments inside preview roots", async () => { resetAssistantAttachmentAvailabilityCacheForTest(); const fetchMock = vi.fn(async (url: string) => { if (!url.includes("meta=1")) { @@ -1306,79 +1270,82 @@ describe("chat view", () => { }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: ["C:\\tmp\\openclaw"], - onRequestUpdate: () => undefined, - messages: [ + + const renderCase = (params: { + expectedUrl: string; + message: ChatProps["messages"][number]; + roots: string[]; + }) => { + render( + renderChat( + createProps({ + showToolCalls: false, + basePath: "/openclaw", + localMediaPreviewRoots: params.roots, + onRequestUpdate: () => undefined, + messages: [params.message], + }), + ), + container, + ); + return params.expectedUrl; + }; + + const cases = [ + renderCase({ + roots: ["C:\\tmp\\openclaw"], + message: { + id: "assistant-windows-file-url", + role: "assistant", + content: "Windows image\nMEDIA:file:///C:/tmp/openclaw/test%20image.png", + timestamp: Date.now(), + }, + expectedUrl: + "/openclaw/__openclaw__/assistant-media?source=%2FC%3A%2Ftmp%2Fopenclaw%2Ftest%2520image.png&meta=1", + }), + renderCase({ + roots: ["c:\\users\\test\\pictures"], + message: { + id: "assistant-windows-path-case-differs", + role: "assistant", + content: "Windows image\nMEDIA:C:\\Users\\Test\\Pictures\\test image.png", + timestamp: Date.now(), + }, + expectedUrl: + "/openclaw/__openclaw__/assistant-media?source=C%3A%5CUsers%5CTest%5CPictures%5Ctest+image.png&meta=1", + }), + renderCase({ + roots: ["/Users/test/Pictures"], + message: normalizeMessage({ + id: "assistant-tilde-local-media", + role: "assistant", + content: [ + { type: "text", text: "Home image" }, { - id: "assistant-windows-file-url", - role: "assistant", - content: "Windows image\nMEDIA:file:///C:/tmp/openclaw/test%20image.png", - timestamp: Date.now(), + type: "attachment", + attachment: { + url: "~/Pictures/test image.png", + kind: "image", + label: "test image.png", + mimeType: "image/png", + }, }, ], + timestamp: Date.now(), }), - ), - container, - ); + expectedUrl: + "/openclaw/__openclaw__/assistant-media?source=%7E%2FPictures%2Ftest+image.png&meta=1", + }), + ]; - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); + await flushAssistantAttachmentAvailabilityChecks(); - expect(fetchMock).toHaveBeenCalledWith( - "/openclaw/__openclaw__/assistant-media?source=%2FC%3A%2Ftmp%2Fopenclaw%2Ftest%2520image.png&meta=1", - expect.objectContaining({ credentials: "same-origin", method: "GET" }), - ); - expect(container.textContent).not.toContain("Outside allowed folders"); - vi.unstubAllGlobals(); - }); - - it("allows Windows local assistant attachments when path casing differs", async () => { - resetAssistantAttachmentAvailabilityCacheForTest(); - const fetchMock = vi.fn(async (url: string) => { - if (!url.includes("meta=1")) { - throw new Error(`Unexpected fetch: ${url}`); - } - return { - ok: true, - json: async () => ({ available: true }), - }; - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: ["c:\\users\\test\\pictures"], - onRequestUpdate: () => undefined, - messages: [ - { - id: "assistant-windows-path-case-differs", - role: "assistant", - content: "Windows image\nMEDIA:C:\\Users\\Test\\Pictures\\test image.png", - timestamp: Date.now(), - }, - ], - }), - ), - container, - ); - - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(fetchMock).toHaveBeenCalledWith( - "/openclaw/__openclaw__/assistant-media?source=C%3A%5CUsers%5CTest%5CPictures%5Ctest+image.png&meta=1", - expect.objectContaining({ credentials: "same-origin", method: "GET" }), - ); + for (const expectedUrl of cases) { + expect(fetchMock).toHaveBeenCalledWith( + expectedUrl, + expect.objectContaining({ credentials: "same-origin", method: "GET" }), + ); + } expect(container.textContent).not.toContain("Outside allowed folders"); vi.unstubAllGlobals(); }); @@ -1439,62 +1406,6 @@ describe("chat view", () => { vi.unstubAllGlobals(); }); - it("allows tilde local assistant attachments inside home-based preview roots", async () => { - resetAssistantAttachmentAvailabilityCacheForTest(); - const fetchMock = vi.fn(async (url: string) => { - if (!url.includes("meta=1")) { - throw new Error(`Unexpected fetch: ${url}`); - } - return { - ok: true, - json: async () => ({ available: true }), - }; - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: ["/Users/test/Pictures"], - onRequestUpdate: () => undefined, - messages: [ - normalizeMessage({ - id: "assistant-tilde-local-media", - role: "assistant", - content: [ - { type: "text", text: "Home image" }, - { - type: "attachment", - attachment: { - url: "~/Pictures/test image.png", - kind: "image", - label: "test image.png", - mimeType: "image/png", - }, - }, - ], - timestamp: Date.now(), - }), - ], - }), - ), - container, - ); - - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(fetchMock).toHaveBeenCalledWith( - "/openclaw/__openclaw__/assistant-media?source=%7E%2FPictures%2Ftest+image.png&meta=1", - expect.objectContaining({ credentials: "same-origin", method: "GET" }), - ); - expect(container.textContent).not.toContain("Outside allowed folders"); - vi.unstubAllGlobals(); - }); - it("routes inline canvas blocks through the scoped canvas host when available", () => { const container = document.createElement("div"); render( @@ -1750,100 +1661,74 @@ describe("chat view", () => { ); }); - it("lets a split tool call collapse even when a separate tool output shares its toolCallId", async () => { + it("lets a tool call collapse while keeping matching tool output visible", async () => { const container = document.createElement("div"); - const props = createProps({ - autoExpandToolCalls: true, - messages: [ - { - id: "assistant-6", - role: "assistant", - toolCallId: "call-6", - content: [ - { - type: "toolcall", - id: "call-6", - name: "sessions_spawn", - arguments: { mode: "session", thread: true }, - }, - ], - timestamp: Date.now(), - }, - { - id: "tool-6", - role: "tool", - toolCallId: "call-6", - toolName: "sessions_spawn", - content: JSON.stringify({ status: "error" }, null, 2), - timestamp: Date.now() + 1, - }, - ], - }); - const rerender = () => { - render(renderChat({ ...props, onRequestUpdate: rerender }), container); + const renderCase = (params: { outputInToolMessages: boolean; id: string }) => { + const props = createProps({ + autoExpandToolCalls: true, + messages: [ + { + id: `assistant-${params.id}`, + role: "assistant", + toolCallId: `call-${params.id}`, + content: [ + { + type: "toolcall", + id: `call-${params.id}`, + name: "sessions_spawn", + arguments: { mode: "session", thread: true }, + }, + ], + timestamp: Date.now(), + }, + ...(params.outputInToolMessages + ? [] + : [ + { + id: `tool-${params.id}`, + role: "tool" as const, + toolCallId: `call-${params.id}`, + toolName: "sessions_spawn", + content: JSON.stringify({ status: "error" }, null, 2), + timestamp: Date.now() + 1, + }, + ]), + ], + toolMessages: params.outputInToolMessages + ? [ + { + id: `tool-${params.id}`, + role: "tool", + toolCallId: `call-${params.id}`, + toolName: "sessions_spawn", + content: JSON.stringify({ status: "error" }, null, 2), + timestamp: Date.now() + 1, + }, + ] + : [], + }); + const rerender = () => { + render(renderChat({ ...props, onRequestUpdate: rerender }), container); + }; + rerender(); }; - rerender(); - expect(container.textContent).toContain("Tool input"); - expect(container.textContent).toContain('"thread": true'); - expect(container.textContent).toContain('"status": "error"'); + for (const outputInToolMessages of [false, true]) { + renderCase({ id: outputInToolMessages ? "tool-messages" : "split", outputInToolMessages }); + expect(container.textContent).toContain("Tool input"); + expect(container.textContent).toContain('"thread": true'); + expect(container.textContent).toContain('"status": "error"'); - const summaries = container.querySelectorAll(".chat-tool-msg-summary"); - summaries[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await flushTasks(); + const summaries = container.querySelectorAll(".chat-tool-msg-summary"); + if (outputInToolMessages) { + expect(summaries.length).toBeGreaterThan(1); + } + summaries[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushTasks(); - expect(container.textContent).not.toContain("Tool input"); - expect(container.textContent).toContain('"status": "error"'); - }); - - it("lets a tool call collapse when the matching tool output comes from toolMessages", async () => { - const container = document.createElement("div"); - const props = createProps({ - autoExpandToolCalls: true, - messages: [ - { - id: "assistant-7", - role: "assistant", - toolCallId: "call-7", - content: [ - { - type: "toolcall", - id: "call-7", - name: "sessions_spawn", - arguments: { mode: "session", thread: true }, - }, - ], - timestamp: Date.now(), - }, - ], - toolMessages: [ - { - id: "tool-7", - role: "tool", - toolCallId: "call-7", - toolName: "sessions_spawn", - content: JSON.stringify({ status: "error" }, null, 2), - timestamp: Date.now() + 1, - }, - ], - }); - - const rerender = () => { - render(renderChat({ ...props, onRequestUpdate: rerender }), container); - }; - rerender(); - - expect(container.textContent).toContain("Tool input"); - expect(container.textContent).toContain('"thread": true'); - expect(container.textContent).toContain('"status": "error"'); - - const summaries = container.querySelectorAll(".chat-tool-msg-summary"); - expect(summaries.length).toBeGreaterThan(1); - summaries[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await flushTasks(); - - expect(container.textContent).not.toContain("Tool input"); - expect(container.textContent).toContain('"status": "error"'); + expect(container.textContent).not.toContain("Tool input"); + expect(container.textContent).toContain('"status": "error"'); + } }); });