mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
test: merge chat attachment cases
This commit is contained in:
@@ -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"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user