test: merge chat attachment cases

This commit is contained in:
Peter Steinberger
2026-04-17 18:34:03 +01:00
parent 9bcf8f8243
commit f93b7da4c4

View File

@@ -22,6 +22,12 @@ function flushTasks() {
return new Promise<void>((resolve) => queueMicrotask(resolve));
}
async function flushAssistantAttachmentAvailabilityChecks() {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function createProps(overrides: Partial<ChatProps> = {}): 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<HTMLIFrameElement>(".chat-tool-card__preview-frame");
renderCanvas({ suffix: "default" });
let iframe = container.querySelector<HTMLIFrameElement>(".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<HTMLIFrameElement>(".chat-tool-card__preview-frame");
renderCanvas({ embedSandboxMode: "trusted", suffix: "trusted" });
iframe = container.querySelector<HTMLIFrameElement>(".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<HTMLElement>(".chat-tool-msg-summary");
summaries[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flushTasks();
const summaries = container.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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"');
}
});
});