Stabilize write artifact previews (#3172)

This commit is contained in:
AochenShen99
2026-05-23 16:56:14 +08:00
committed by GitHub
parent a64a39dbc0
commit 604fcbb9d2
7 changed files with 1022 additions and 46 deletions
@@ -0,0 +1,310 @@
import { expect, test } from "vitest";
import {
appendHtmlPreviewBaseHref,
appendHtmlPreviewScrollRestoration,
buildWriteFileDraftContent,
createHtmlPreviewScrollKey,
getArtifactViewState,
} 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("preserves existing head elements when injecting scroll restoration", () => {
const html =
'<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="script-src \'none\'"></head><body><main>content</main></body></html>';
const result = appendHtmlPreviewScrollRestoration(
appendHtmlPreviewBaseHref(
html,
"/demo/threads/thread-1/user-data/outputs/report.html?download=true",
"http://localhost/workspace/chats/thread-1",
),
ARTIFACT_PATH,
);
expect(result).toContain(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
);
expect(
result.indexOf("data-deerflow-artifact-scroll-restoration"),
).toBeLessThan(
result.indexOf(
'<base href="http://localhost/demo/threads/thread-1/user-data/outputs/">',
),
);
});
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");
});