ai-chatbot/tests/routes/openclaw-3d-artifacts.test.ts

582 lines
18 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { GLTFLoader } from "three-stdlib";
import { checkRateLimitByIdentifier, rateLimiters } from "@/lib/security/rate-limiter";
import {
buildOpenClawArtifactProxyUrl,
extractOpenClawArtifacts,
getOpenClaw3DArtifactVariant,
getOpenClaw3DArtifactBundle,
isOpenClawInteractive3DArtifact,
normalizeOpenClawArtifacts,
} from "@/lib/openclaw-chat/artifacts";
import {
getOpenClawExplodedOffsetMm,
getOpenClawPartForSceneNode,
getOpenClawPackagingInteractionParts,
getOpenClawSourceNodeOffset,
mapOpenClawCadOffsetToGlb,
normalizeOpenClawModelInteractionManifest,
} from "@/lib/openclaw-chat/model-interaction";
import {
isOpenClawProductionDrawingReleased,
normalizeOpenClawProductionDrawingManifest,
} from "@/lib/openclaw-chat/production-drawing";
import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state";
import {
clearOpenClaw3DViewerSession,
readOpenClaw3DViewerSession,
writeOpenClaw3DViewerSession,
} from "@/lib/openclaw-chat/viewer-session";
function createMemoryStorage() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
};
}
test("matches original CAD instance names after GLTFLoader sanitizes them", async () => {
const gltf = await new GLTFLoader().parseAsync(
JSON.stringify({
asset: { version: "2.0" },
scene: 0,
scenes: [{ nodes: [0, 1, 2] }],
nodes: [{ name: "SOURCE-001:1" }, { name: "SOURCE-001:2" }, { name: "plain" }],
}),
""
);
const parts = new Map([
["SOURCE-001:1", "first instance"],
["SOURCE-001:2", "second instance"],
["plain", "plain node"],
]);
expect(gltf.scene.children[0].name).not.toBe("SOURCE-001:1");
expect(gltf.scene.children.map((node) => getOpenClawPartForSceneNode(node, parts)))
.toEqual(["first instance", "second instance", "plain node"]);
expect(getOpenClawPartForSceneNode({ name: "missing" }, parts)).toBeUndefined();
});
test("CAD artifact bursts cannot exhaust the conversation API budget", () => {
const identifier = `claw-artifact-test-${Date.now()}`;
for (let request = 0; request < 600; request += 1) {
expect(checkRateLimitByIdentifier(identifier, rateLimiters.clawdArtifact).allowed).toBe(true);
}
expect(checkRateLimitByIdentifier(identifier, rateLimiters.clawdArtifact).allowed).toBe(false);
expect(checkRateLimitByIdentifier(identifier, rateLimiters.api).allowed).toBe(true);
expect(checkRateLimitByIdentifier(`${identifier}-other-user`, rateLimiters.clawdArtifact).allowed).toBe(true);
});
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",
};
const packagingModelFile = {
...modelFile,
filename: "wood-demo-packaging.glb",
display_name: "可旋转3D包装叠放模型",
artifact_role: "packaging_3d",
viewer: {
...modelFile.viewer,
variant: "packaging",
default: false,
fallback_artifact_role: "packaging_preview",
interaction_manifest_artifact_role: "model_interaction_manifest",
},
};
test.describe("OpenClaw 3D artifacts", () => {
test("normalizes production drawing release safeguards", () => {
const pending = normalizeOpenClawProductionDrawingManifest({
release_status: "draft",
automated_validation_status: "production_ready",
approval_status: "awaiting_engineering_release",
release_blockers: [
"待结构工程师签批",
{ code: "HOLE_CHECK", message: "孔位仍需复核" },
],
});
expect(pending).toMatchObject({
releaseStatus: "draft",
automatedValidationStatus: "production_ready",
approvalStatus: "awaiting_engineering_release",
releaseBlockers: ["待结构工程师签批", "孔位仍需复核"],
});
expect(isOpenClawProductionDrawingReleased(pending)).toBe(false);
const released = normalizeOpenClawProductionDrawingManifest({
release: {
status: "production_released",
version: "V3",
approved_by: "结构工程师A",
sha256: "abc123",
},
});
expect(released).toMatchObject({
releaseStatus: "production_released",
version: "V3",
approvedBy: "结构工程师A",
fileSha256: "abc123",
});
expect(isOpenClawProductionDrawingReleased(released)).toBe(true);
});
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,
packagingModelFile,
{ ...previewFile, artifact_role: "assembled_preview" },
{ ...previewFile, artifact_role: "packaging_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(4);
expect(bundle?.packagingModelIndex).toBe(3);
expect(bundle?.packagingPreviewIndex).toBe(5);
expect(getOpenClaw3DArtifactVariant(artifacts[3])).toBe("packaging");
});
test("normalizes v3 packaging models, actions, layers, and carton metrics", () => {
const manifest = normalizeOpenClawModelInteractionManifest({
schema: "dalongxia.furniture-model-interaction.v3",
coordinates: { scale_mm_to_glb: 0.001 },
capabilities: {
one_click_packaging_explode: true,
one_click_packaging_pack: true,
select_packaging_layer: true,
},
actions: [
{
id: "packaging_pack",
label: "合上包装",
to_state: "packaging_packed",
target: "packaging_layers",
nodes_field: "packaging.packaging_layers",
offset_field: "packed_offset_from_exploded_mm",
duration_ms: 850,
},
{
id: "packaging_explode",
label: "展开包装",
to_state: "packaging_exploded",
target: "packaging_layers",
nodes_field: "packaging.packaging_layers",
},
],
packaging: {
shipment_mode: "拆装",
packaging_box_style_label: "中开箱",
package_count: 1,
package_dimensions_mm: { x: 1260, y: 638, z: 162 },
package_dimensions: [
{ package_index: 1, x: 1260, y: 638, z: 162 },
],
total_packaging_volume_m3: 0.13,
volume_weight_kg: 21.7,
packaging_layers: [
{
package_index: 1,
node_name: "PKG-01-top-001",
node_role: "top_cover",
name: "上盖",
dimensions_mm: { x: 1250, y: 628, z: 8 },
packed_position_mm: { x: 5, y: 5, z: 154 },
exploded_position_mm: { x: 5, y: 5, z: 420 },
packed_offset_from_exploded_mm: { x: 0, y: 0, z: -266 },
},
],
},
});
expect(manifest?.schema).toBe("dalongxia.furniture-model-interaction.v3");
expect(manifest?.actions[0]).toMatchObject({
id: "packaging_pack",
toState: "packaging_packed",
target: "packaging_layers",
nodesField: "packaging.packaging_layers",
});
expect(manifest?.packaging).toMatchObject({
shipmentMode: "拆装",
boxStyleLabel: "中开箱",
packageCount: 1,
packageDimensionsMm: { x: 1260, y: 638, z: 162 },
totalPackagingVolumeM3: 0.13,
volumeWeightKg: 21.7,
});
expect(manifest?.capabilities.oneClickPackagingPack).toBe(true);
expect(getOpenClawPackagingInteractionParts(manifest!)[0]).toMatchObject({
nodeName: "PKG-01-top-001",
partId: "top_cover",
partName: "上盖",
assembledPositionMm: { x: 5, y: 5, z: 154 },
explodedPositionMm: { x: 5, y: 5, z: 420 },
explodedOffsetMm: { x: 0, y: 0, z: 266 },
draggable: false,
});
});
test("treats legacy interactive-only GLB as exploded source and can restore assembly", () => {
const artifacts = normalizeOpenClawArtifacts([modelFile, manifestFile], {
baseUrl: "https://claw.example.com",
});
const bundle = getOpenClaw3DArtifactBundle(artifacts);
expect(bundle?.model.artifactRole).toBe("interactive_3d");
expect(getOpenClaw3DArtifactVariant(bundle!.model)).toBe("exploded");
const manifest = normalizeOpenClawModelInteractionManifest({
schema: "dalongxia.furniture-model-interaction.v2",
coordinates: { scale_mm_to_glb: 0.001 },
parts: [
{
node_name: "DOOR-001:1",
part_id: "DOOR-001",
exploded_offset_mm: { x: 2726, y: 120, z: -40 },
},
],
});
expect(
getOpenClawSourceNodeOffset(
manifest!.parts[0],
"exploded",
manifest!.scaleMmToGlb
)
).toEqual({ x: -2.726, y: 0.04, z: 0.12 });
});
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");
});
test("restores the active viewer and exploded state after a refresh", () => {
const storage = createMemoryStorage();
writeOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "message-id",
explodeAmount: 1,
selectedNodeName: "DOOR-001:1",
now: 1_000,
});
expect(
readOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
now: 2_000,
})
).toEqual({
messageId: "message-id",
viewMode: "product",
explodeAmount: 1,
packagingExplodeAmount: 1,
selectedNodeName: "DOOR-001:1",
selectedPackagingNodeName: null,
updatedAt: 1_000,
});
});
test("restores the packaging tab and packed state after a refresh", () => {
const storage = createMemoryStorage();
writeOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "message-id",
viewMode: "packaging",
explodeAmount: 0.25,
packagingExplodeAmount: 0,
selectedPackagingNodeName: "PKG-01-top-001",
now: 1_000,
});
expect(
readOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
now: 2_000,
})
).toMatchObject({
messageId: "message-id",
viewMode: "packaging",
explodeAmount: 0.25,
packagingExplodeAmount: 0,
selectedPackagingNodeName: "PKG-01-top-001",
});
});
test("only clears the viewer state owned by the matching message", () => {
const storage = createMemoryStorage();
const now = Date.now();
writeOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "new-message-id",
explodeAmount: 0,
now,
});
clearOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "old-message-id",
});
expect(
readOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
now: now + 1_000,
})?.messageId
).toBe("new-message-id");
clearOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
messageId: "new-message-id",
});
expect(
readOpenClaw3DViewerSession({
storage,
chatId: "chat-id",
now: now + 1_000,
})
).toBeNull();
});
});