582 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|