unsloth/studio/frontend/tests/artifact-source-key.test.ts
Daniel Han a0a3a7b24a
fix(studio): show the current artifact's source after switching artifacts (#7565)
* fix(studio): show the current artifact's source after switching artifacts

The canvas source view feeds one Streamdown a fence built from the selected
artifact's code, but never keys it. Streamdown does not revise a block it has
already committed, so the panel keeps rendering the previous artifact's source.
Key the source view on the artifact ID plus a hash of its code: tool artifact
IDs are derived from the tool call, not the code, so the ID alone does not
change when a tool artifact is updated in place.

* Name the real root cause and make the source-key test load-bearing

The remount is needed because Streamdown memoizes a fenced code block on its
hast node's line/column span, which ignores the text inside the fence, so two
canvases of equal line count compare equal and the old source stays on screen.
Verified in Chromium against streamdown 2.5.0: unkeyed, 70 lines -> 70 lines
renders the previous artifact, 70 -> 71 and 70 -> 90 render correctly.

Move the key expression into the source branch so it costs nothing while the
artifact is streaming and the view is unmounted, and export the helper from
types.ts so the test exercises the shipped code instead of a local copy of the
formula (it passed before even with the key removed from the component).

* Assert the source view's Streamdown key wiring, not just the helper

The suite exercised buildArtifactSourceKey but never the component, so deleting
key={buildArtifactSourceKey(artifact)} from the Streamdown left every test
green. There is no DOM renderer available to these tests, so parse
artifact-surface.tsx with the TypeScript compiler API (already a devDependency)
and assert the source view's Streamdown carries that key.

Mutation-checked: removing the key fails 1 test, swapping it for artifact.id
fails 1, and making the helper ignore code fails 2.

* Tighten the comments added by this PR
2026-07-28 18:19:39 -07:00

130 lines
4.3 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import {
buildArtifactSourceKey,
createArtifactId,
createChatArtifact,
hashArtifactCode,
} from "../src/features/chat/artifacts/types.ts";
// The shipped helper the component keys on, not a copy of it.
const sourceKey = buildArtifactSourceKey;
const toolInput = (code: string) => ({
code,
source: "tool" as const,
threadId: "thread-1",
sourceMessageId: "msg-1",
sourceToolCallId: "call_0",
});
const fenceInput = (code: string) => ({
code,
source: "fence" as const,
threadId: "thread-1",
sourceMessageId: "msg-1",
});
test("tool artifact IDs are stable across code changes, so the ID alone is not enough", () => {
const first = createArtifactId(toolInput("<p>first</p>"));
const second = createArtifactId(toolInput("<p>second</p>"));
assert.equal(first, second);
});
test("the source key changes when a tool artifact's code changes", () => {
const first = createChatArtifact(toolInput("<p>first</p>"));
const second = createChatArtifact(toolInput("<p>second</p>"));
assert.notEqual(sourceKey(first), sourceKey(second));
});
test("the source key changes when switching between fence artifacts", () => {
const first = createChatArtifact(fenceInput("<p>alpha</p>"));
const second = createChatArtifact(fenceInput("<p>bravo</p>"));
assert.notEqual(sourceKey(first), sourceKey(second));
});
test("the source key is stable for an unchanged artifact, so no needless remount", () => {
const code = "<p>same</p>";
assert.equal(
sourceKey(createChatArtifact(toolInput(code))),
sourceKey(createChatArtifact(toolInput(code))),
);
});
// Equal line count, the shape where Streamdown's comparator sees no change.
test("the source key changes for two canvases with the same shape", () => {
const first = createChatArtifact(
toolInput("<html>\n<body>\n<h1>Alpha</h1>\n</body>\n</html>"),
);
const second = createChatArtifact(
toolInput("<html>\n<body>\n<h1>Bravo</h1>\n</body>\n</html>"),
);
assert.equal(first.code.length, second.code.length);
assert.equal(first.code.split("\n").length, second.code.split("\n").length);
assert.notEqual(sourceKey(first), sourceKey(second));
});
test("hashArtifactCode separates same-length codes and empty from whitespace", () => {
assert.notEqual(hashArtifactCode("<p>ab</p>"), hashArtifactCode("<p>ba</p>"));
assert.notEqual(hashArtifactCode(""), hashArtifactCode(" "));
});
const KEYED_BY_HELPER = /^\{buildArtifactSourceKey\(\s*artifact\s*\)\}$/;
const SURFACE_PATH = fileURLToPath(
new URL(
"../src/features/chat/artifacts/artifact-surface.tsx",
import.meta.url,
),
);
/** The opening tag of `node`, for both `<x>` and `<x />`. */
const openingTag = (node: ts.Node): ts.JsxOpeningLikeElement | null => {
if (ts.isJsxSelfClosingElement(node)) return node;
if (ts.isJsxElement(node)) return node.openingElement;
return null;
};
/** The `key` expression on the source view's Streamdown, or null if unkeyed. */
function readStreamdownKey(): string | null {
const source = ts.createSourceFile(
SURFACE_PATH,
readFileSync(SURFACE_PATH, "utf8"),
ts.ScriptTarget.ESNext,
true,
ts.ScriptKind.TSX,
);
let key: string | null = null;
const visit = (node: ts.Node): void => {
const opening = openingTag(node);
if (opening?.tagName.getText() === "Streamdown") {
for (const attribute of opening.attributes.properties) {
if (
ts.isJsxAttribute(attribute) &&
attribute.name.getText() === "key"
) {
key = attribute.initializer?.getText() ?? "";
}
}
}
node.forEachChild(visit);
};
source.forEachChild(visit);
return key;
}
// Without this the suite passes with the key deleted, which is the regression.
// No DOM renderer is available here, so assert the wiring in the source.
test("the source view's Streamdown is keyed by the shipped helper", () => {
const key = readStreamdownKey();
assert.ok(key, "source view <Streamdown> has no key prop");
assert.match(key, KEYED_BY_HELPER);
});