一片一片 f0ba280365
fix(artifacts): parse write-file URLs through the guarded helper (#7609)
`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.
2026-10-28 07:35:01 +08:00

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();
},
);