mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-31 07:30:43 +00:00
`ArtifactFileDetail` and `loadArtifactContentFromToolCall` re-parsed `write-file:` URLs with `new URL` + `decodeURIComponent` after `parseWriteFileArtifact` had already rejected malformed values. The panel restores its selection from session storage and only type-checks it as a string, so a stale or hand-edited `write-file:%E0%A4%A` (or `write-file://[`) threw `URIError`/`TypeError` during render. Export the guarded parser and read `path` / `tool_call_id` from one parsed result in both places; a rejected URL degrades to the raw string or `undefined` instead of taking the panel down.
306 lines
7.7 KiB
TypeScript
306 lines
7.7 KiB
TypeScript
import { expect, test } from "@rstest/core";
|
|
|
|
import {
|
|
appendHtmlPreviewScrollRestoration,
|
|
buildWriteFileDraftContent,
|
|
createHtmlPreviewScrollKey,
|
|
getArtifactViewState,
|
|
parseWriteFileArtifact,
|
|
} from "@/core/artifacts/preview";
|
|
|
|
const ARTIFACT_PATH = "/artifact-fixtures/report.html";
|
|
const UNSUPPORTED_ARTIFACT_PATH = "/artifact-fixtures/data.csv";
|
|
|
|
test("allows in-progress write artifacts to render a throttled preview", () => {
|
|
expect(
|
|
getArtifactViewState({
|
|
filepath: `write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`,
|
|
isSupportPreview: true,
|
|
}),
|
|
).toEqual({
|
|
canPreview: true,
|
|
initialViewMode: "preview",
|
|
});
|
|
});
|
|
|
|
test("allows preview for a write artifact once the tool call has a result", () => {
|
|
expect(
|
|
getArtifactViewState({
|
|
filepath: `write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`,
|
|
isSupportPreview: true,
|
|
toolResult: "OK",
|
|
}),
|
|
).toEqual({
|
|
canPreview: true,
|
|
initialViewMode: "preview",
|
|
});
|
|
});
|
|
|
|
test("keeps failed write artifacts in code view", () => {
|
|
expect(
|
|
getArtifactViewState({
|
|
filepath: `write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`,
|
|
isSupportPreview: true,
|
|
toolResult: "Error: Failed to write file",
|
|
}),
|
|
).toEqual({
|
|
canPreview: false,
|
|
initialViewMode: "code",
|
|
});
|
|
});
|
|
|
|
test("keeps completed artifacts on their existing preview defaults", () => {
|
|
expect(
|
|
getArtifactViewState({
|
|
filepath: ARTIFACT_PATH,
|
|
isSupportPreview: true,
|
|
}),
|
|
).toEqual({
|
|
canPreview: true,
|
|
initialViewMode: "preview",
|
|
});
|
|
});
|
|
|
|
test("keeps unsupported artifacts in code view", () => {
|
|
expect(
|
|
getArtifactViewState({
|
|
filepath: UNSUPPORTED_ARTIFACT_PATH,
|
|
isSupportPreview: false,
|
|
}),
|
|
).toEqual({
|
|
canPreview: false,
|
|
initialViewMode: "code",
|
|
});
|
|
});
|
|
|
|
test("builds a draft write-file artifact from successful writes plus the selected in-progress append", () => {
|
|
const filepath = `write-file:${ARTIFACT_PATH}?message_id=ai-2&tool_call_id=call-2`;
|
|
|
|
expect(
|
|
buildWriteFileDraftContent({
|
|
filepath,
|
|
messages: [
|
|
{
|
|
type: "ai",
|
|
id: "ai-1",
|
|
tool_calls: [
|
|
{
|
|
id: "call-1",
|
|
name: "write_file",
|
|
args: {
|
|
path: ARTIFACT_PATH,
|
|
content: "<!doctype html><html><body>",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tool",
|
|
id: "tool-1",
|
|
name: "write_file",
|
|
tool_call_id: "call-1",
|
|
content: "OK",
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: "ai-2",
|
|
tool_calls: [
|
|
{
|
|
id: "call-2",
|
|
name: "write_file",
|
|
args: {
|
|
append: true,
|
|
path: ARTIFACT_PATH,
|
|
content: "<p>追加内容</p>",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).toBe("<!doctype html><html><body><p>追加内容</p>");
|
|
});
|
|
|
|
test("does not include failed writes in a draft artifact", () => {
|
|
const filepath = `write-file:${ARTIFACT_PATH}?message_id=ai-3&tool_call_id=call-3`;
|
|
|
|
expect(
|
|
buildWriteFileDraftContent({
|
|
filepath,
|
|
messages: [
|
|
{
|
|
type: "ai",
|
|
id: "ai-1",
|
|
tool_calls: [
|
|
{
|
|
id: "call-1",
|
|
name: "write_file",
|
|
args: {
|
|
path: ARTIFACT_PATH,
|
|
content: "<html>",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tool",
|
|
id: "tool-1",
|
|
name: "write_file",
|
|
tool_call_id: "call-1",
|
|
content: "OK",
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: "ai-2",
|
|
tool_calls: [
|
|
{
|
|
id: "call-2",
|
|
name: "write_file",
|
|
args: {
|
|
append: true,
|
|
path: ARTIFACT_PATH,
|
|
content: "<p>失败内容</p>",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tool",
|
|
id: "tool-2",
|
|
name: "write_file",
|
|
tool_call_id: "call-2",
|
|
content: "Error: write failed",
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: "ai-3",
|
|
tool_calls: [
|
|
{
|
|
id: "call-3",
|
|
name: "write_file",
|
|
args: {
|
|
append: true,
|
|
path: ARTIFACT_PATH,
|
|
content: "</html>",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
).toBe("<html></html>");
|
|
});
|
|
|
|
test("returns undefined when the selected append failed so the caller can fall back", () => {
|
|
const filepath = `write-file:${ARTIFACT_PATH}?message_id=ai-2&tool_call_id=call-2`;
|
|
|
|
expect(
|
|
buildWriteFileDraftContent({
|
|
filepath,
|
|
messages: [
|
|
{
|
|
type: "ai",
|
|
id: "ai-1",
|
|
tool_calls: [
|
|
{
|
|
id: "call-1",
|
|
name: "write_file",
|
|
args: {
|
|
path: ARTIFACT_PATH,
|
|
content: "<html>",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tool",
|
|
id: "tool-1",
|
|
name: "write_file",
|
|
tool_call_id: "call-1",
|
|
content: "OK",
|
|
},
|
|
{
|
|
type: "ai",
|
|
id: "ai-2",
|
|
tool_calls: [
|
|
{
|
|
id: "call-2",
|
|
name: "write_file",
|
|
args: {
|
|
append: true,
|
|
path: ARTIFACT_PATH,
|
|
content: "<p>失败的追加内容</p>",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
type: "tool",
|
|
id: "tool-2",
|
|
name: "write_file",
|
|
tool_call_id: "call-2",
|
|
content: "Error: write failed",
|
|
},
|
|
],
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("injects scroll restoration at the start of the HTML head", () => {
|
|
const html =
|
|
'<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="script-src \'none\'"></head><body><main>content</main></body></html>';
|
|
|
|
expect(appendHtmlPreviewScrollRestoration(html, ARTIFACT_PATH)).toContain(
|
|
"<script data-deerflow-artifact-scroll-restoration>",
|
|
);
|
|
expect(appendHtmlPreviewScrollRestoration(html, ARTIFACT_PATH)).toContain(
|
|
"<head><script data-deerflow-artifact-scroll-restoration>",
|
|
);
|
|
});
|
|
|
|
test("does not duplicate HTML scroll restoration script", () => {
|
|
const html = appendHtmlPreviewScrollRestoration(
|
|
"<html><body>x</body></html>",
|
|
);
|
|
|
|
expect(
|
|
appendHtmlPreviewScrollRestoration(html).match(
|
|
/data-deerflow-artifact-scroll-restoration/g,
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
test("scopes HTML scroll restoration without exposing the artifact path", () => {
|
|
const artifactPath =
|
|
'/artifact-fixtures/a</script><script>alert("x")</script>.html';
|
|
const html = appendHtmlPreviewScrollRestoration(
|
|
"<html><body>x</body></html>",
|
|
artifactPath,
|
|
);
|
|
|
|
expect(html).toContain(createHtmlPreviewScrollKey(artifactPath));
|
|
expect(html).toContain("window.parent.postMessage");
|
|
expect(html).not.toContain("window.name");
|
|
expect(html).not.toContain("/artifact-fixtures/a");
|
|
expect(html).not.toContain("<script>alert");
|
|
});
|
|
|
|
test("parses a write-file artifact URL and its tool-call coordinates", () => {
|
|
expect(
|
|
parseWriteFileArtifact(
|
|
`write-file:${ARTIFACT_PATH}?message_id=ai-1&tool_call_id=call-1`,
|
|
),
|
|
).toEqual({
|
|
path: ARTIFACT_PATH,
|
|
messageId: "ai-1",
|
|
toolCallId: "call-1",
|
|
});
|
|
});
|
|
|
|
test.each(["write-file:%E0%A4%A", "write-file://["])(
|
|
"rejects a malformed write-file URL: %s",
|
|
(filepath) => {
|
|
expect(parseWriteFileArtifact(filepath)).toBeUndefined();
|
|
},
|
|
);
|