mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
test: split grouped chat rendering coverage
This commit is contained in:
647
ui/src/ui/chat/grouped-render.test.ts
Normal file
647
ui/src/ui/chat/grouped-render.test.ts
Normal file
@@ -0,0 +1,647 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MessageGroup } from "../types/chat-types.ts";
|
||||
import {
|
||||
renderMessageGroup,
|
||||
resetAssistantAttachmentAvailabilityCacheForTest,
|
||||
} from "./grouped-render.ts";
|
||||
import { normalizeMessage } from "./message-normalizer.ts";
|
||||
|
||||
vi.mock("../markdown.ts", () => ({
|
||||
toSanitizedMarkdownHtml: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("../views/agents-utils.ts", () => ({
|
||||
agentLogoUrl: () => "/openclaw-logo.svg",
|
||||
}));
|
||||
|
||||
vi.mock("./speech.ts", () => ({
|
||||
isTtsSpeaking: () => false,
|
||||
isTtsSupported: () => false,
|
||||
speakText: () => false,
|
||||
stopTts: () => undefined,
|
||||
}));
|
||||
|
||||
type RenderMessageGroupOptions = Parameters<typeof renderMessageGroup>[1];
|
||||
|
||||
function renderAssistantMessage(
|
||||
container: HTMLElement,
|
||||
message: unknown,
|
||||
opts: Partial<RenderMessageGroupOptions> = {},
|
||||
) {
|
||||
renderGroupedMessage(container, message, "assistant", opts);
|
||||
}
|
||||
|
||||
function renderGroupedMessage(
|
||||
container: HTMLElement,
|
||||
message: unknown,
|
||||
role: string,
|
||||
opts: Partial<RenderMessageGroupOptions> = {},
|
||||
) {
|
||||
const timestamp =
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
typeof (message as { timestamp?: unknown }).timestamp === "number"
|
||||
? (message as { timestamp: number }).timestamp
|
||||
: Date.now();
|
||||
const group: MessageGroup = {
|
||||
kind: "group",
|
||||
key: `${role}-group`,
|
||||
role,
|
||||
messages: [{ key: `${role}-message`, message }],
|
||||
timestamp,
|
||||
isStreaming: false,
|
||||
};
|
||||
render(
|
||||
renderMessageGroup(group, {
|
||||
showReasoning: true,
|
||||
showToolCalls: true,
|
||||
assistantName: "OpenClaw",
|
||||
assistantAvatar: null,
|
||||
...opts,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
}
|
||||
|
||||
async function flushAssistantAttachmentAvailabilityChecks() {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("grouped chat rendering", () => {
|
||||
it("renders assistant MEDIA attachments, voice-note badge, and reply pill", () => {
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-media-inline",
|
||||
role: "assistant",
|
||||
content:
|
||||
"[[reply_to_current]]Here is the image.\nMEDIA:https://example.com/photo.png\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ showToolCalls: false },
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-reply-pill")?.textContent).toContain(
|
||||
"Replying to current message",
|
||||
);
|
||||
expect(container.querySelector(".chat-message-image")).not.toBeNull();
|
||||
expect(container.querySelector("audio")).not.toBeNull();
|
||||
expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent).toContain(
|
||||
"Voice note",
|
||||
);
|
||||
expect(container.textContent).toContain("Here is the image.");
|
||||
expect(container.textContent).not.toContain("[[reply_to_current]]");
|
||||
expect(container.textContent).not.toContain("[[audio_as_voice]]");
|
||||
expect(container.textContent).not.toContain("MEDIA:https://example.com/photo.png");
|
||||
});
|
||||
|
||||
it("renders allowed transcript images and skips blocked/non-image media", () => {
|
||||
const renderUserMedia = (message: unknown) => {
|
||||
const container = document.createElement("div");
|
||||
renderGroupedMessage(container, message, "user", {
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
assistantAttachmentAuthToken: "session-token",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
let container = renderUserMedia({
|
||||
id: "user-history-image",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPath: "/tmp/openclaw/user-upload.png",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(
|
||||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||||
).toBe(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fuser-upload.png&token=session-token",
|
||||
);
|
||||
|
||||
container = renderUserMedia({
|
||||
id: "user-history-image-octet-stream",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPath: "/tmp/openclaw/user-upload.png",
|
||||
MediaType: "application/octet-stream",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(
|
||||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||||
).toBe(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fuser-upload.png&token=session-token",
|
||||
);
|
||||
|
||||
container = renderUserMedia({
|
||||
id: "user-history-images",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPaths: ["/tmp/openclaw/first.png", "/tmp/openclaw/second.jpg"],
|
||||
MediaTypes: ["image/png", "application/octet-stream"],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(
|
||||
[...container.querySelectorAll<HTMLImageElement>(".chat-message-image")].map((image) =>
|
||||
image.getAttribute("src"),
|
||||
),
|
||||
).toEqual([
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ffirst.png&token=session-token",
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fsecond.jpg&token=session-token",
|
||||
]);
|
||||
|
||||
container = renderUserMedia({
|
||||
id: "user-history-image-blocked",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPath: "/Users/test/Documents/private.png",
|
||||
MediaType: "image/png",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(container.querySelector(".chat-message-image")).toBeNull();
|
||||
expect(container.querySelector(".chat-bubble")).toBeNull();
|
||||
|
||||
container = renderUserMedia({
|
||||
id: "user-history-document",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPath: "/tmp/openclaw/user-upload.pdf",
|
||||
MediaType: "application/pdf",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(container.querySelector(".chat-message-image")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders legacy input_image image_url blocks", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "input_image", image_url: "data:image/png;base64,cG5n" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ showToolCalls: false },
|
||||
);
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
expect(image?.getAttribute("src")).toBe("data:image/png;base64,cG5n");
|
||||
});
|
||||
|
||||
it("renders canvas-only [embed] shortcodes inside the assistant bubble", () => {
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-canvas-only",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[embed ref="cv_tictactoe" title="Tic-Tac-Toe" /]',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ showToolCalls: false },
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-bubble")).not.toBeNull();
|
||||
expect(container.querySelector(".chat-tool-card__preview-frame")).not.toBeNull();
|
||||
expect(container.textContent).toContain("Tic-Tac-Toe");
|
||||
});
|
||||
|
||||
it("opens only safe assistant image URLs in a hardened new tab", () => {
|
||||
const container = document.createElement("div");
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const renderAssistantImage = (url: string) =>
|
||||
renderAssistantMessage(container, {
|
||||
role: "assistant",
|
||||
content: [{ type: "image_url", image_url: { url } }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
renderAssistantImage("https://example.com/cat.png");
|
||||
let image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
expect(image).not.toBeNull();
|
||||
image?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
"https://example.com/cat.png",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
|
||||
openSpy.mockClear();
|
||||
renderAssistantImage("javascript:alert(1)");
|
||||
image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
expect(image).not.toBeNull();
|
||||
image?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
renderAssistantImage("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' />");
|
||||
image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
expect(image).not.toBeNull();
|
||||
image?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders verified local assistant attachments through the Control UI media route", async () => {
|
||||
resetAssistantAttachmentAvailabilityCacheForTest();
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.includes("meta=1")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ available: true }),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const container = document.createElement("div");
|
||||
const renderMessage = () =>
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-local-media-inline",
|
||||
role: "assistant",
|
||||
content:
|
||||
"Local image\nMEDIA:/tmp/openclaw/test image.png\nMEDIA:/tmp/openclaw/test-doc.pdf",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
assistantAttachmentAuthToken: "session-token",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
onRequestUpdate: renderMessage,
|
||||
},
|
||||
);
|
||||
|
||||
renderMessage();
|
||||
expect(container.textContent).toContain("Checking...");
|
||||
await flushAssistantAttachmentAvailabilityChecks();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&token=session-token&meta=1",
|
||||
expect.objectContaining({ credentials: "same-origin", method: "GET" }),
|
||||
);
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
const docLink = container.querySelector<HTMLAnchorElement>(
|
||||
".chat-assistant-attachment-card__link",
|
||||
);
|
||||
expect(image?.getAttribute("src")).toBe(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&token=session-token",
|
||||
);
|
||||
expect(docLink?.getAttribute("href")).toBe(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest-doc.pdf&token=session-token",
|
||||
);
|
||||
expect(container.textContent).not.toContain("test image.png");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rechecks local assistant attachment availability when the auth token changes", 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: url.includes("token=fresh-token") }),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const container = document.createElement("div");
|
||||
|
||||
const renderWithToken = (token: string | null) =>
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-local-media-auth-refresh",
|
||||
role: "assistant",
|
||||
content: "Local image\nMEDIA:/tmp/openclaw/test image.png",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
assistantAttachmentAuthToken: token,
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
onRequestUpdate: () => renderWithToken(token),
|
||||
},
|
||||
);
|
||||
|
||||
renderWithToken(null);
|
||||
await flushAssistantAttachmentAvailabilityChecks();
|
||||
expect(container.textContent).toContain("Unavailable");
|
||||
|
||||
renderWithToken("fresh-token");
|
||||
await flushAssistantAttachmentAvailabilityChecks();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&meta=1",
|
||||
expect.objectContaining({ credentials: "same-origin", method: "GET" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&token=fresh-token&meta=1",
|
||||
expect.objectContaining({ credentials: "same-origin", method: "GET" }),
|
||||
);
|
||||
expect(container.querySelector(".chat-message-image")).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Unavailable");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("preserves same-origin assistant attachments without local preview rewriting", () => {
|
||||
resetAssistantAttachmentAvailabilityCacheForTest();
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-same-origin-media-inline",
|
||||
role: "assistant",
|
||||
content:
|
||||
"Inline\nMEDIA:/media/inbound/test-image.png\nMEDIA:/__openclaw__/media/test-doc.pdf",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
},
|
||||
);
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
const docLink = container.querySelector<HTMLAnchorElement>(
|
||||
".chat-assistant-attachment-card__link",
|
||||
);
|
||||
expect(image?.getAttribute("src")).toBe("/media/inbound/test-image.png");
|
||||
expect(docLink?.getAttribute("href")).toBe("/__openclaw__/media/test-doc.pdf");
|
||||
expect(container.textContent).not.toContain("Unavailable");
|
||||
});
|
||||
|
||||
it("renders blocked local assistant files as unavailable with a reason", () => {
|
||||
resetAssistantAttachmentAvailabilityCacheForTest();
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-blocked-local-media",
|
||||
role: "assistant",
|
||||
content: "Blocked\nMEDIA:/Users/test/Documents/private.pdf\nDone",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-assistant-attachment-card__link")).toBeNull();
|
||||
expect(container.textContent).toContain("private.pdf");
|
||||
expect(container.textContent).toContain("Unavailable");
|
||||
expect(container.textContent).toContain("Outside allowed folders");
|
||||
expect(container.textContent).toContain("Blocked");
|
||||
expect(container.textContent).toContain("Done");
|
||||
});
|
||||
|
||||
it("allows platform-specific local assistant attachments inside 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");
|
||||
|
||||
const renderCase = (params: { expectedUrl: string; message: unknown; roots: string[] }) => {
|
||||
renderAssistantMessage(container, params.message, {
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
localMediaPreviewRoots: params.roots,
|
||||
onRequestUpdate: () => undefined,
|
||||
});
|
||||
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" },
|
||||
{
|
||||
type: "attachment",
|
||||
attachment: {
|
||||
url: "~/Pictures/test image.png",
|
||||
kind: "image",
|
||||
label: "test image.png",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
expectedUrl:
|
||||
"/openclaw/__openclaw__/assistant-media?source=%7E%2FPictures%2Ftest+image.png&meta=1",
|
||||
}),
|
||||
];
|
||||
|
||||
await flushAssistantAttachmentAvailabilityChecks();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("revalidates cached unavailable local assistant attachments after retry window", async () => {
|
||||
resetAssistantAttachmentAvailabilityCacheForTest();
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi
|
||||
.fn<(url: string) => Promise<{ ok: true; json: () => Promise<{ available: boolean }> }>>()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ available: false }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ available: true }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const container = document.createElement("div");
|
||||
|
||||
const renderMessage = () =>
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-local-media-retry-after-unavailable",
|
||||
role: "assistant",
|
||||
content: "Local image\nMEDIA:/tmp/openclaw/test image.png",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
onRequestUpdate: renderMessage,
|
||||
},
|
||||
);
|
||||
|
||||
renderMessage();
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Unavailable");
|
||||
|
||||
vi.advanceTimersByTime(5_001);
|
||||
renderMessage();
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(container.querySelector(".chat-message-image")).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Unavailable");
|
||||
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("routes inline canvas blocks through the scoped canvas host when available", () => {
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-scoped-canvas",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Rendered inline." },
|
||||
{
|
||||
type: "canvas",
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
viewId: "cv_inline_scoped",
|
||||
title: "Scoped preview",
|
||||
url: "/__openclaw__/canvas/documents/cv_inline_scoped/index.html",
|
||||
preferredHeight: 320,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
canvasHostUrl: "http://127.0.0.1:19003/__openclaw__/cap/cap_123",
|
||||
},
|
||||
);
|
||||
|
||||
const iframe = container.querySelector(".chat-tool-card__preview-frame");
|
||||
expect(iframe?.getAttribute("src")).toBe(
|
||||
"http://127.0.0.1:19003/__openclaw__/cap/cap_123/__openclaw__/canvas/documents/cv_inline_scoped/index.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders server-history canvas blocks for the live toolResult sequence after history reload", () => {
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-final-live-shape",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "", thinkingSignature: "sig-2" },
|
||||
{ type: "text", text: "This item is ready." },
|
||||
{
|
||||
type: "canvas",
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
viewId: "cv_canvas_live_history",
|
||||
title: "Live history preview",
|
||||
url: "/__openclaw__/canvas/documents/cv_canvas_live_history/index.html",
|
||||
preferredHeight: 420,
|
||||
},
|
||||
rawText: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_canvas_live_history",
|
||||
url: "/__openclaw__/canvas/documents/cv_canvas_live_history/index.html",
|
||||
},
|
||||
presentation: {
|
||||
target: "assistant_message",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now() + 2,
|
||||
},
|
||||
{ showToolCalls: true },
|
||||
);
|
||||
|
||||
const assistantBubble = container.querySelector(".chat-group.assistant .chat-bubble");
|
||||
const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame");
|
||||
expect(allPreviews).toHaveLength(1);
|
||||
expect(assistantBubble?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull();
|
||||
expect(assistantBubble?.textContent).toContain("This item is ready.");
|
||||
expect(assistantBubble?.textContent).toContain("Live history preview");
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,7 @@
|
||||
import { html, render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
import {
|
||||
renderMessageGroup,
|
||||
resetAssistantAttachmentAvailabilityCacheForTest,
|
||||
} from "../chat/grouped-render.ts";
|
||||
import { normalizeMessage } from "../chat/message-normalizer.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import type { MessageGroup } from "../types/chat-types.ts";
|
||||
import { __testing as chatTesting, renderChat, type ChatProps } from "./chat.ts";
|
||||
|
||||
vi.mock("../markdown.ts", () => ({
|
||||
@@ -57,12 +51,6 @@ function flushTasks() {
|
||||
return new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
}
|
||||
|
||||
async function flushAssistantAttachmentAvailabilityChecks() {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||
return {
|
||||
sessionKey: "main",
|
||||
@@ -107,48 +95,6 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||
};
|
||||
}
|
||||
|
||||
type RenderMessageGroupOptions = Parameters<typeof renderMessageGroup>[1];
|
||||
|
||||
function renderAssistantMessage(
|
||||
container: HTMLElement,
|
||||
message: unknown,
|
||||
opts: Partial<RenderMessageGroupOptions> = {},
|
||||
) {
|
||||
renderGroupedMessage(container, message, "assistant", opts);
|
||||
}
|
||||
|
||||
function renderGroupedMessage(
|
||||
container: HTMLElement,
|
||||
message: unknown,
|
||||
role: string,
|
||||
opts: Partial<RenderMessageGroupOptions> = {},
|
||||
) {
|
||||
const timestamp =
|
||||
typeof message === "object" &&
|
||||
message !== null &&
|
||||
typeof (message as { timestamp?: unknown }).timestamp === "number"
|
||||
? (message as { timestamp: number }).timestamp
|
||||
: Date.now();
|
||||
const group: MessageGroup = {
|
||||
kind: "group",
|
||||
key: `${role}-group`,
|
||||
role,
|
||||
messages: [{ key: `${role}-message`, message }],
|
||||
timestamp,
|
||||
isStreaming: false,
|
||||
};
|
||||
render(
|
||||
renderMessageGroup(group, {
|
||||
showReasoning: true,
|
||||
showToolCalls: true,
|
||||
assistantName: "OpenClaw",
|
||||
assistantAvatar: null,
|
||||
...opts,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
}
|
||||
|
||||
function clearDeleteConfirmSkip() {
|
||||
try {
|
||||
getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm");
|
||||
@@ -556,35 +502,6 @@ describe("chat view", () => {
|
||||
expect(container.textContent).toContain('"childSessionKey": "agent:test:subagent:abc123"');
|
||||
});
|
||||
|
||||
it("renders canvas-only [embed] shortcodes inside the assistant bubble", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
showToolCalls: false,
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-canvas-only",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[embed ref="cv_tictactoe" title="Tic-Tac-Toe" /]',
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-bubble")).not.toBeNull();
|
||||
expect(container.querySelector(".chat-tool-card__preview-frame")).not.toBeNull();
|
||||
expect(container.textContent).toContain("Tic-Tac-Toe");
|
||||
});
|
||||
|
||||
it("renders hidden assistant_message canvas results with the configured sandbox", () => {
|
||||
const container = document.createElement("div");
|
||||
const renderCanvas = (params: { embedSandboxMode?: "trusted"; suffix: string }) =>
|
||||
@@ -806,602 +723,6 @@ describe("chat view", () => {
|
||||
expect(container.textContent).not.toContain("Inline generic preview");
|
||||
});
|
||||
|
||||
it("renders assistant MEDIA attachments, voice-note badge, and reply pill", () => {
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-media-inline",
|
||||
role: "assistant",
|
||||
content:
|
||||
"[[reply_to_current]]Here is the image.\nMEDIA:https://example.com/photo.png\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ showToolCalls: false },
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-reply-pill")?.textContent).toContain(
|
||||
"Replying to current message",
|
||||
);
|
||||
expect(container.querySelector(".chat-message-image")).not.toBeNull();
|
||||
expect(container.querySelector("audio")).not.toBeNull();
|
||||
expect(container.querySelector(".chat-assistant-attachment-badge")?.textContent).toContain(
|
||||
"Voice note",
|
||||
);
|
||||
expect(container.textContent).toContain("Here is the image.");
|
||||
expect(container.textContent).not.toContain("[[reply_to_current]]");
|
||||
expect(container.textContent).not.toContain("[[audio_as_voice]]");
|
||||
expect(container.textContent).not.toContain("MEDIA:https://example.com/photo.png");
|
||||
});
|
||||
|
||||
it("renders allowed transcript images and skips blocked/non-image media", () => {
|
||||
const renderUserMedia = (message: unknown) => {
|
||||
const container = document.createElement("div");
|
||||
renderGroupedMessage(container, message, "user", {
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
assistantAttachmentAuthToken: "session-token",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
});
|
||||
return container;
|
||||
};
|
||||
|
||||
let container = renderUserMedia({
|
||||
id: "user-history-image",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPath: "/tmp/openclaw/user-upload.png",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(
|
||||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||||
).toBe(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fuser-upload.png&token=session-token",
|
||||
);
|
||||
|
||||
container = renderUserMedia({
|
||||
id: "user-history-image-octet-stream",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPath: "/tmp/openclaw/user-upload.png",
|
||||
MediaType: "application/octet-stream",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(
|
||||
container.querySelector<HTMLImageElement>(".chat-message-image")?.getAttribute("src"),
|
||||
).toBe(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fuser-upload.png&token=session-token",
|
||||
);
|
||||
|
||||
container = renderUserMedia({
|
||||
id: "user-history-images",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPaths: ["/tmp/openclaw/first.png", "/tmp/openclaw/second.jpg"],
|
||||
MediaTypes: ["image/png", "application/octet-stream"],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(
|
||||
[...container.querySelectorAll<HTMLImageElement>(".chat-message-image")].map((image) =>
|
||||
image.getAttribute("src"),
|
||||
),
|
||||
).toEqual([
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ffirst.png&token=session-token",
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Fsecond.jpg&token=session-token",
|
||||
]);
|
||||
|
||||
container = renderUserMedia({
|
||||
id: "user-history-image-blocked",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPath: "/Users/test/Documents/private.png",
|
||||
MediaType: "image/png",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(container.querySelector(".chat-message-image")).toBeNull();
|
||||
expect(container.querySelector(".chat-bubble")).toBeNull();
|
||||
|
||||
container = renderUserMedia({
|
||||
id: "user-history-document",
|
||||
role: "user",
|
||||
content: "",
|
||||
MediaPath: "/tmp/openclaw/user-upload.pdf",
|
||||
MediaType: "application/pdf",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(container.querySelector(".chat-message-image")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders legacy input_image image_url blocks", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "input_image", image_url: "data:image/png;base64,cG5n" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ showToolCalls: false },
|
||||
);
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
expect(image?.getAttribute("src")).toBe("data:image/png;base64,cG5n");
|
||||
});
|
||||
|
||||
it("opens only safe assistant image URLs in a hardened new tab", () => {
|
||||
const container = document.createElement("div");
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const renderAssistantImage = (url: string) =>
|
||||
renderAssistantMessage(container, {
|
||||
role: "assistant",
|
||||
content: [{ type: "image_url", image_url: { url } }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
try {
|
||||
renderAssistantImage("https://example.com/cat.png");
|
||||
let image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
expect(image).not.toBeNull();
|
||||
image?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
"https://example.com/cat.png",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
|
||||
openSpy.mockClear();
|
||||
renderAssistantImage("javascript:alert(1)");
|
||||
image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
expect(image).not.toBeNull();
|
||||
image?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
renderAssistantImage("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' />");
|
||||
image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
expect(image).not.toBeNull();
|
||||
image?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders verified local assistant attachments through the Control UI media route", async () => {
|
||||
resetAssistantAttachmentAvailabilityCacheForTest();
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.includes("meta=1")) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ available: true }),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const container = document.createElement("div");
|
||||
const renderMessage = () =>
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-local-media-inline",
|
||||
role: "assistant",
|
||||
content:
|
||||
"Local image\nMEDIA:/tmp/openclaw/test image.png\nMEDIA:/tmp/openclaw/test-doc.pdf",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
assistantAttachmentAuthToken: "session-token",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
onRequestUpdate: renderMessage,
|
||||
},
|
||||
);
|
||||
|
||||
renderMessage();
|
||||
expect(container.textContent).toContain("Checking...");
|
||||
await flushAssistantAttachmentAvailabilityChecks();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&token=session-token&meta=1",
|
||||
expect.objectContaining({ credentials: "same-origin", method: "GET" }),
|
||||
);
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
const docLink = container.querySelector<HTMLAnchorElement>(
|
||||
".chat-assistant-attachment-card__link",
|
||||
);
|
||||
expect(image?.getAttribute("src")).toBe(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&token=session-token",
|
||||
);
|
||||
expect(docLink?.getAttribute("href")).toBe(
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest-doc.pdf&token=session-token",
|
||||
);
|
||||
expect(container.textContent).not.toContain("test image.png");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rechecks local assistant attachment availability when the auth token changes", 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: url.includes("token=fresh-token") }),
|
||||
};
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const container = document.createElement("div");
|
||||
|
||||
const renderWithToken = (token: string | null) =>
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-local-media-auth-refresh",
|
||||
role: "assistant",
|
||||
content: "Local image\nMEDIA:/tmp/openclaw/test image.png",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
assistantAttachmentAuthToken: token,
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
onRequestUpdate: () => renderWithToken(token),
|
||||
},
|
||||
);
|
||||
|
||||
renderWithToken(null);
|
||||
await flushAssistantAttachmentAvailabilityChecks();
|
||||
expect(container.textContent).toContain("Unavailable");
|
||||
|
||||
renderWithToken("fresh-token");
|
||||
await flushAssistantAttachmentAvailabilityChecks();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&meta=1",
|
||||
expect.objectContaining({ credentials: "same-origin", method: "GET" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fopenclaw%2Ftest+image.png&token=fresh-token&meta=1",
|
||||
expect.objectContaining({ credentials: "same-origin", method: "GET" }),
|
||||
);
|
||||
expect(container.querySelector(".chat-message-image")).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Unavailable");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("preserves same-origin assistant attachments without local preview rewriting", () => {
|
||||
resetAssistantAttachmentAvailabilityCacheForTest();
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-same-origin-media-inline",
|
||||
role: "assistant",
|
||||
content:
|
||||
"Inline\nMEDIA:/media/inbound/test-image.png\nMEDIA:/__openclaw__/media/test-doc.pdf",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
},
|
||||
);
|
||||
|
||||
const image = container.querySelector<HTMLImageElement>(".chat-message-image");
|
||||
const docLink = container.querySelector<HTMLAnchorElement>(
|
||||
".chat-assistant-attachment-card__link",
|
||||
);
|
||||
expect(image?.getAttribute("src")).toBe("/media/inbound/test-image.png");
|
||||
expect(docLink?.getAttribute("href")).toBe("/__openclaw__/media/test-doc.pdf");
|
||||
expect(container.textContent).not.toContain("Unavailable");
|
||||
});
|
||||
|
||||
it("renders blocked local assistant files as unavailable with a reason", () => {
|
||||
resetAssistantAttachmentAvailabilityCacheForTest();
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-blocked-local-media",
|
||||
role: "assistant",
|
||||
content: "Blocked\nMEDIA:/Users/test/Documents/private.pdf\nDone",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(container.querySelector(".chat-assistant-attachment-card__link")).toBeNull();
|
||||
expect(container.textContent).toContain("private.pdf");
|
||||
expect(container.textContent).toContain("Unavailable");
|
||||
expect(container.textContent).toContain("Outside allowed folders");
|
||||
expect(container.textContent).toContain("Blocked");
|
||||
expect(container.textContent).toContain("Done");
|
||||
});
|
||||
|
||||
it("allows platform-specific local assistant attachments inside 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");
|
||||
|
||||
const renderCase = (params: {
|
||||
expectedUrl: string;
|
||||
message: ChatProps["messages"][number];
|
||||
roots: string[];
|
||||
}) => {
|
||||
renderAssistantMessage(container, params.message, {
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
localMediaPreviewRoots: params.roots,
|
||||
onRequestUpdate: () => undefined,
|
||||
});
|
||||
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" },
|
||||
{
|
||||
type: "attachment",
|
||||
attachment: {
|
||||
url: "~/Pictures/test image.png",
|
||||
kind: "image",
|
||||
label: "test image.png",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
expectedUrl:
|
||||
"/openclaw/__openclaw__/assistant-media?source=%7E%2FPictures%2Ftest+image.png&meta=1",
|
||||
}),
|
||||
];
|
||||
|
||||
await flushAssistantAttachmentAvailabilityChecks();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("revalidates cached unavailable local assistant attachments after retry window", async () => {
|
||||
resetAssistantAttachmentAvailabilityCacheForTest();
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi
|
||||
.fn<(url: string) => Promise<{ ok: true; json: () => Promise<{ available: boolean }> }>>()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ available: false }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ available: true }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const container = document.createElement("div");
|
||||
|
||||
const renderMessage = () =>
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-local-media-retry-after-unavailable",
|
||||
role: "assistant",
|
||||
content: "Local image\nMEDIA:/tmp/openclaw/test image.png",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
showToolCalls: false,
|
||||
basePath: "/openclaw",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
onRequestUpdate: renderMessage,
|
||||
},
|
||||
);
|
||||
|
||||
renderMessage();
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain("Unavailable");
|
||||
|
||||
vi.advanceTimersByTime(5_001);
|
||||
renderMessage();
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(container.querySelector(".chat-message-image")).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Unavailable");
|
||||
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("routes inline canvas blocks through the scoped canvas host when available", () => {
|
||||
const container = document.createElement("div");
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
id: "assistant-scoped-canvas",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Rendered inline." },
|
||||
{
|
||||
type: "canvas",
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
viewId: "cv_inline_scoped",
|
||||
title: "Scoped preview",
|
||||
url: "/__openclaw__/canvas/documents/cv_inline_scoped/index.html",
|
||||
preferredHeight: 320,
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
canvasHostUrl: "http://127.0.0.1:19003/__openclaw__/cap/cap_123",
|
||||
},
|
||||
);
|
||||
|
||||
const iframe = container.querySelector(".chat-tool-card__preview-frame");
|
||||
expect(iframe?.getAttribute("src")).toBe(
|
||||
"http://127.0.0.1:19003/__openclaw__/cap/cap_123/__openclaw__/canvas/documents/cv_inline_scoped/index.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders server-history canvas blocks for the live toolResult sequence after history reload", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
showToolCalls: true,
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-toolcall-live-shape",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "", thinkingSignature: "sig-1" },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_live_canvas",
|
||||
name: "canvas_tool_result",
|
||||
arguments: {},
|
||||
partialJson: "{}",
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
id: "toolresult-live-shape",
|
||||
role: "toolResult",
|
||||
toolCallId: "call_live_canvas",
|
||||
toolName: "canvas_tool_result",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_canvas_live_history",
|
||||
url: "/__openclaw__/canvas/documents/cv_canvas_live_history/index.html",
|
||||
title: "Live history preview",
|
||||
preferred_height: 420,
|
||||
},
|
||||
presentation: {
|
||||
target: "assistant_message",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now() + 1,
|
||||
},
|
||||
{
|
||||
id: "assistant-final-live-shape",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "", thinkingSignature: "sig-2" },
|
||||
{ type: "text", text: "This item is ready." },
|
||||
{
|
||||
type: "canvas",
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
viewId: "cv_canvas_live_history",
|
||||
title: "Live history preview",
|
||||
url: "/__openclaw__/canvas/documents/cv_canvas_live_history/index.html",
|
||||
preferredHeight: 420,
|
||||
},
|
||||
rawText: JSON.stringify({
|
||||
kind: "canvas",
|
||||
view: {
|
||||
backend: "canvas",
|
||||
id: "cv_canvas_live_history",
|
||||
url: "/__openclaw__/canvas/documents/cv_canvas_live_history/index.html",
|
||||
},
|
||||
presentation: {
|
||||
target: "assistant_message",
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
timestamp: Date.now() + 2,
|
||||
},
|
||||
],
|
||||
toolMessages: [],
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const assistantBubbles = container.querySelectorAll(".chat-group.assistant .chat-bubble");
|
||||
const finalAssistantBubble = assistantBubbles[assistantBubbles.length - 1];
|
||||
const allPreviews = container.querySelectorAll(".chat-tool-card__preview-frame");
|
||||
expect(allPreviews).toHaveLength(1);
|
||||
expect(finalAssistantBubble?.querySelector(".chat-tool-card__preview-frame")).not.toBeNull();
|
||||
expect(finalAssistantBubble?.textContent).toContain("This item is ready.");
|
||||
expect(finalAssistantBubble?.textContent).toContain("Live history preview");
|
||||
});
|
||||
|
||||
it("lifts streamed canvas tool messages with toolresult blocks into the assistant bubble", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
|
||||
Reference in New Issue
Block a user