272 lines
8.1 KiB
TypeScript
272 lines
8.1 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import {
|
|
buildOpenClawArtifactProxyUrl,
|
|
extractOpenClawArtifacts,
|
|
getOpenClaw3DArtifactBundle,
|
|
isOpenClawInteractive3DArtifact,
|
|
normalizeOpenClawArtifacts,
|
|
} from "@/lib/openclaw-chat/artifacts";
|
|
import {
|
|
getOpenClawExplodedOffsetMm,
|
|
mapOpenClawCadOffsetToGlb,
|
|
normalizeOpenClawModelInteractionManifest,
|
|
} from "@/lib/openclaw-chat/model-interaction";
|
|
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
|
|
|
|
const modelFile = {
|
|
type: "file",
|
|
filename: "wood-demo-interactive.glb",
|
|
display_name: "可旋转3D拆件模型",
|
|
artifact_role: "interactive_3d",
|
|
display_mode: "interactive",
|
|
mime_type: "model/gltf-binary",
|
|
download_url: "/files/wood-demo-interactive.glb",
|
|
viewer: {
|
|
type: "model_3d",
|
|
format: "glb",
|
|
camera_controls: true,
|
|
auto_rotate: false,
|
|
background: "#F7F9FC",
|
|
fallback_artifact_role: "cad_preview",
|
|
},
|
|
};
|
|
|
|
const previewFile = {
|
|
type: "image",
|
|
filename: "wood-demo-preview.png",
|
|
artifact_role: "cad_preview",
|
|
display_mode: "inline",
|
|
mime_type: "image/png",
|
|
download_url: "https://files.example.com/wood-demo-preview.png",
|
|
};
|
|
|
|
const assembledModelFile = {
|
|
...modelFile,
|
|
filename: "wood-demo-assembled.glb",
|
|
display_name: "可旋转3D整装成品模型",
|
|
artifact_role: "assembled_3d",
|
|
viewer: {
|
|
...modelFile.viewer,
|
|
variant: "assembled",
|
|
default: true,
|
|
fallback_artifact_role: "assembled_preview",
|
|
alternate_artifact_role: "interactive_3d",
|
|
interaction_manifest_artifact_role: "model_interaction_manifest",
|
|
part_selection: true,
|
|
part_dragging: true,
|
|
reset_parts: true,
|
|
one_click_explode: true,
|
|
one_click_assemble: true,
|
|
explode_action_id: "explode_all",
|
|
assemble_action_id: "assemble_all",
|
|
transition_duration_ms: 850,
|
|
explode_slider: true,
|
|
},
|
|
};
|
|
|
|
const manifestFile = {
|
|
type: "file",
|
|
filename: "wood-demo-model-interaction.json",
|
|
artifact_role: "model_interaction_manifest",
|
|
display_mode: "inline_only",
|
|
mime_type: "application/json",
|
|
download_url: "https://files.example.com/wood-demo-model-interaction.json",
|
|
};
|
|
|
|
test.describe("OpenClaw 3D artifacts", () => {
|
|
test("normalizes the documented GLB viewer contract", () => {
|
|
const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], {
|
|
baseUrl: "https://claw.example.com",
|
|
});
|
|
|
|
expect(artifacts).toHaveLength(2);
|
|
expect(artifacts[0]).toMatchObject({
|
|
artifactRole: "interactive_3d",
|
|
displayMode: "interactive",
|
|
displayName: "可旋转3D拆件模型",
|
|
downloadUrl: "https://claw.example.com/files/wood-demo-interactive.glb",
|
|
mimeType: "model/gltf-binary",
|
|
viewer: {
|
|
type: "model_3d",
|
|
format: "glb",
|
|
cameraControls: true,
|
|
autoRotate: false,
|
|
background: "#F7F9FC",
|
|
fallbackArtifactRole: "cad_preview",
|
|
},
|
|
});
|
|
expect(isOpenClawInteractive3DArtifact(artifacts[0])).toBe(true);
|
|
expect(isOpenClawInteractive3DArtifact(artifacts[1])).toBe(false);
|
|
});
|
|
|
|
test("keeps the new viewer controls and selects the default assembled bundle", () => {
|
|
const artifacts = normalizeOpenClawArtifacts(
|
|
[
|
|
modelFile,
|
|
manifestFile,
|
|
assembledModelFile,
|
|
{ ...previewFile, artifact_role: "assembled_preview" },
|
|
],
|
|
{ baseUrl: "https://claw.example.com" }
|
|
);
|
|
const bundle = getOpenClaw3DArtifactBundle(artifacts);
|
|
|
|
expect(bundle?.model.artifactRole).toBe("assembled_3d");
|
|
expect(bundle?.model.viewer).toMatchObject({
|
|
variant: "assembled",
|
|
isDefault: true,
|
|
alternateArtifactRole: "interactive_3d",
|
|
interactionManifestArtifactRole: "model_interaction_manifest",
|
|
partSelection: true,
|
|
partDragging: true,
|
|
resetParts: true,
|
|
oneClickExplode: true,
|
|
oneClickAssemble: true,
|
|
explodeActionId: "explode_all",
|
|
assembleActionId: "assemble_all",
|
|
transitionDurationMs: 850,
|
|
explodeSlider: true,
|
|
});
|
|
expect(bundle?.manifestIndex).toBe(1);
|
|
expect(bundle?.alternateModelIndex).toBe(0);
|
|
expect(bundle?.previewIndex).toBe(3);
|
|
});
|
|
|
|
test("normalizes v2 interaction transforms and CAD-to-GLB coordinates", () => {
|
|
const manifest = normalizeOpenClawModelInteractionManifest({
|
|
schema: "dalongxia.furniture-model-interaction.v2",
|
|
coordinates: { scale_mm_to_glb: 0.001 },
|
|
capabilities: {
|
|
one_click_explode: true,
|
|
one_click_assemble: true,
|
|
animated_transition: true,
|
|
explode_slider: true,
|
|
},
|
|
actions: [
|
|
{
|
|
id: "explode_all",
|
|
label: "一键炸开",
|
|
to_state: "exploded",
|
|
offset_field: "exploded_offset_mm",
|
|
duration_ms: 700,
|
|
easing: "easeInOutCubic",
|
|
interruptible: true,
|
|
},
|
|
],
|
|
drag: {
|
|
axes: ["x", "y", "z"],
|
|
snap_distance_mm: 25,
|
|
max_distance_mm: 1800,
|
|
},
|
|
parts: [
|
|
{
|
|
node_name: "DOOR-001:1",
|
|
part_id: "DOOR-001",
|
|
part_name: "门板",
|
|
dimensions_mm: { x: 578, y: 20, z: 720 },
|
|
exploded_offset_mm: { x: 2726, y: 120, z: -40 },
|
|
draggable: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(manifest).not.toBeNull();
|
|
expect(manifest?.parts[0]).toMatchObject({
|
|
nodeName: "DOOR-001:1",
|
|
partId: "DOOR-001",
|
|
partName: "门板",
|
|
draggable: true,
|
|
});
|
|
const offset = getOpenClawExplodedOffsetMm(manifest!.parts[0]);
|
|
expect(offset).toEqual({ x: 2726, y: 120, z: -40 });
|
|
expect(mapOpenClawCadOffsetToGlb(offset!, manifest!.scaleMmToGlb)).toEqual({
|
|
x: 2.726,
|
|
y: -0.04,
|
|
z: -0.12,
|
|
});
|
|
});
|
|
|
|
test("derives exploded offsets and default actions for historical v1 manifests", () => {
|
|
const manifest = normalizeOpenClawModelInteractionManifest({
|
|
schema: "dalongxia.furniture-model-interaction.v1",
|
|
parts: [
|
|
{
|
|
node_name: "TOP-001:1",
|
|
part_id: "TOP-001",
|
|
assembled_position_mm: { x: 20, y: 10, z: 40 },
|
|
exploded_position_mm: { x: 220, y: -40, z: 140 },
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(manifest?.actions.map((action) => action.id)).toEqual([
|
|
"explode_all",
|
|
"assemble_all",
|
|
]);
|
|
expect(getOpenClawExplodedOffsetMm(manifest!.parts[0])).toEqual({
|
|
x: 200,
|
|
y: -50,
|
|
z: 100,
|
|
});
|
|
expect(manifest?.capabilities.explodeSlider).toBe(true);
|
|
});
|
|
|
|
test("extracts files from durable task results without duplicating them", () => {
|
|
const artifacts = extractOpenClawArtifacts(
|
|
{
|
|
output: { files: [modelFile, previewFile] },
|
|
task: { result: { files: [modelFile] } },
|
|
},
|
|
{ baseUrl: "https://claw.example.com" }
|
|
);
|
|
|
|
expect(artifacts).toHaveLength(2);
|
|
expect(artifacts.map((artifact) => artifact.artifactRole)).toEqual([
|
|
"interactive_3d",
|
|
"cad_preview",
|
|
]);
|
|
});
|
|
|
|
test("rejects private schemes and keeps the original URL out of proxy links", () => {
|
|
expect(
|
|
normalizeOpenClawArtifacts([
|
|
{ ...modelFile, download_url: "file:///tmp/private.glb" },
|
|
{ ...modelFile, download_url: "javascript:alert(1)" },
|
|
])
|
|
).toEqual([]);
|
|
|
|
const proxyUrl = buildOpenClawArtifactProxyUrl({
|
|
chatId: "11111111-1111-4111-8111-111111111111",
|
|
messageId: "22222222-2222-4222-8222-222222222222",
|
|
artifactIndex: 0,
|
|
download: true,
|
|
});
|
|
expect(proxyUrl).toContain("download=1");
|
|
expect(proxyUrl).not.toContain("files.example.com");
|
|
});
|
|
|
|
test("restores persisted artifacts with Claw task metadata", () => {
|
|
const task = normalizeClawdTaskMetadata({
|
|
composerMode: "clawd",
|
|
source: "openclaw",
|
|
taskKind: "clawd_chat",
|
|
status: "completed",
|
|
userMessageId: "user-message-id",
|
|
assistantMessageId: "assistant-message-id",
|
|
processingHint: "",
|
|
progressEvents: [],
|
|
artifacts: [
|
|
{
|
|
...modelFile,
|
|
download_url: "https://files.example.com/model.glb",
|
|
},
|
|
],
|
|
createdAt: 100,
|
|
updatedAt: 200,
|
|
});
|
|
|
|
expect(task?.artifacts).toHaveLength(1);
|
|
expect(task?.artifacts?.[0].viewer?.format).toBe("glb");
|
|
});
|
|
});
|