74 lines
8.4 KiB
JavaScript
74 lines
8.4 KiB
JavaScript
// Real WebGL regression with a synthetic GLB; no production data or generation calls.
|
|
// node scripts/verify-claw-3d-loading.mjs [--source-ref=<revision>] [--model-file=<local GLB>]
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import http from 'node:http';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createRequire } from 'node:module';
|
|
import { BoxGeometry } from 'three';
|
|
import { chromium } from '@playwright/test';
|
|
const require = createRequire(import.meta.url);
|
|
const esbuild = require(require.resolve('esbuild', { paths: [require.resolve('tsx')] }));
|
|
const sharp = require('sharp');
|
|
const root = process.cwd();
|
|
const ref = process.argv.find(x => x.startsWith('--source-ref='))?.slice(13);
|
|
const modelFile = process.argv.find(x => x.startsWith('--model-file='))?.slice(13);
|
|
const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'claw-3d-loading-'));
|
|
const geometry = new BoxGeometry(1, 1, 1);
|
|
const arrays = [geometry.attributes.position.array, geometry.attributes.normal.array, geometry.index.array];
|
|
let offset = 0;
|
|
const views = arrays.map(a => { const v = {buffer: 0, byteOffset: offset, byteLength: a.byteLength}; offset += a.byteLength; return v; });
|
|
const bin = Buffer.concat(arrays.map(a => Buffer.from(a.buffer, a.byteOffset, a.byteLength)));
|
|
const json = { asset: {version: '2.0'}, scene: 0, scenes: [{nodes: [0]}], nodes: [{mesh: 0, name: 'panel'}], meshes: [{primitives: [{attributes: {POSITION: 0, NORMAL: 1}, indices: 2, material: 0}]}], materials: [{pbrMetallicRoughness: {baseColorFactor: [0.65,0.72,0.8,1], metallicFactor: 0.4, roughnessFactor: 0.35}}], buffers: [{byteLength: bin.length}], bufferViews: views, accessors: [{bufferView: 0, componentType: 5126, count: 24, type: 'VEC3', min: [-0.5,-0.5,-0.5], max: [0.5,0.5,0.5]}, {bufferView: 1, componentType: 5126, count: 24, type: 'VEC3'}, {bufferView: 2, componentType: 5123, count: 36, type: 'SCALAR'}] };
|
|
const raw = Buffer.from(JSON.stringify(json));
|
|
const padded = Buffer.concat([raw, Buffer.alloc((4 - raw.length % 4) % 4, 32)]);
|
|
const header = Buffer.alloc(20); header.writeUInt32LE(0x46546c67, 0); header.writeUInt32LE(2,4); header.writeUInt32LE(28 + padded.length + bin.length,8); header.writeUInt32LE(padded.length,12); header.writeUInt32LE(0x4e4f534a,16);
|
|
const binHeader = Buffer.alloc(8); binHeader.writeUInt32LE(bin.length,0); binHeader.writeUInt32LE(0x004e4942,4);
|
|
const model = modelFile ? await fs.readFile(modelFile) : Buffer.concat([header,padded,binHeader,bin]);
|
|
const panel = await fs.readFile('components/canvas/chat-panel/OpenClawModel3DPreview.tsx', 'utf8');
|
|
const effectStart = panel.lastIndexOf(' useEffect(() => {', panel.indexOf('if (!isActive || manifest !== undefined) return;'));
|
|
const effectEnd = panel.indexOf(' }, [interactionManifestIndex, chatId, isActive, manifest, messageId]);', effectStart);
|
|
const manifestEffect = panel.slice(effectStart, effectEnd + ' }, [interactionManifestIndex, chatId, isActive, manifest, messageId]);'.length);
|
|
const entry = `import React,{useEffect,useState} from 'react';import {createRoot} from 'react-dom/client';import {OpenClawInteractiveModelViewer} from './components/canvas/chat-panel/OpenClawInteractiveModelViewer';import {Model3DViewer} from './components/canvas/model3d/Model3DViewer';
|
|
const buildOpenClawArtifactProxyUrl=()=>'/pending-manifest';function ManifestProbe(){const [manifest,setManifest]=useState();const [manifestError,setManifestError]=useState(null);const interactionManifestIndex=1,chatId='test',messageId='test',isActive=true;const normalizeOpenClawModelInteractionManifest=x=>x;${manifestEffect};return <div role="status">{manifest===null?'基础预览': '等待清单'} {manifestError}</div>};const noop=()=>{};const manifest={parts:[{nodeName:'panel',partId:'panel',partName:'测试面板',explodedOffsetMm:{x:500,y:0,z:0}}],scaleMmToGlb:0.001,actions:[],drag:{axes:['x','y','z'],maxDistanceMm:2000,snapDistanceMm:10}};const params=new URLSearchParams(location.search);const url='/model.glb?case='+params.get('case');
|
|
createRoot(document.getElementById('root')).render(params.get('viewer')==='manifest'?<ManifestProbe/>:params.get('viewer')==='basic'?<Model3DViewer autoFitModel localStudioEnvironment modelUrl={url} modelFormat="glb" settings={{cameraPreset:'iso',envPreset:'studio',lightIntensity:1,autoRotate:false,showGrid:false,wireframe:false}} onStatsChange={stats=>window.loaded=stats.meshCount>0}/>:<OpenClawInteractiveModelViewer allowDragging={false} background="#09090b" manifest={manifest} modelUrl={url} onMatchChange={(matched,total)=>window.loaded=total>0} onSelectedPartChange={noop} resetAllToken={0} resetSelectedToken={0} resetViewToken={0} selectedNodeName={null} sourceVariant="assembled" transformRequest={{actionId:null,amount:0,animate:false,durationMs:0,nonce:0}}/>);`;
|
|
await esbuild.build({stdin:{contents:entry,resolveDir:root,loader:'tsx'},bundle:true,outfile:path.join(temp,'app.js'),jsx:'automatic',define:{'process.env.NODE_ENV':'"production"'},plugins: ref ? [{name:'old-viewers',setup(build){build.onLoad({filter:/(OpenClawInteractiveModelViewer|Model3DViewer)\.tsx$/},args=>({contents:execFileSync('git',['show',`${ref}:${path.relative(root,args.path)}`],{encoding:'utf8'}),loader:'tsx'}));}}] : []});
|
|
let requests = 0;
|
|
const server = http.createServer(async (req,res)=>{
|
|
const url=new URL(req.url,'http://localhost');
|
|
if(url.pathname==='/model.glb') {requests++;if(url.searchParams.get('case')==='slow') await new Promise(r=>setTimeout(r,1800));if(url.searchParams.get('case')==='error'){res.writeHead(500);res.end();return;}res.setHeader('Content-Type','model/gltf-binary');res.end(model);return;}
|
|
if(url.pathname==='/app.js'){res.setHeader('Content-Type','text/javascript');res.end(await fs.readFile(path.join(temp,'app.js')));return;}
|
|
res.setHeader('Content-Type','text/html');res.end('<style>body{margin:0;background:#09090b;color:white}#root{width:720px;height:500px}svg{width:24px;height:24px}</style><div id="root"></div><script src="/app.js"></script>');
|
|
});
|
|
await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
const origin=`http://127.0.0.1:${server.address().port}`;
|
|
const browser=await chromium.launch({channel:'chrome',headless:true,args:['--enable-unsafe-swiftshader']});
|
|
try {
|
|
for(const viewer of ['interactive','basic']){
|
|
const page=await browser.newPage({viewport:{width:720,height:500}});const external=[];
|
|
// Simulate an overseas CDN connection that never completes, rather than instantly failing.
|
|
await page.route('**/*',route=>{if(route.request().url().startsWith(origin))return route.continue();external.push(new URL(route.request().url()).hostname);});
|
|
await page.goto(`${origin}/?viewer=${viewer}&case=slow`);
|
|
if(!ref) await page.getByRole('status').waitFor({timeout:10000});
|
|
await page.waitForTimeout(5000);
|
|
const screenshot=await page.locator('canvas').screenshot();
|
|
const {data,info}=await sharp(screenshot).removeAlpha().raw().toBuffer({resolveWithObject:true});
|
|
let visiblePixels=0;for(let i=0;i<data.length;i+=info.channels)if(Math.max(data[i],data[i+1],data[i+2])>80)visiblePixels++;
|
|
const loaded=await page.evaluate(()=>!!window.loaded);
|
|
await fs.writeFile(path.join(temp,`${viewer}.png`),screenshot);
|
|
console.log(JSON.stringify({viewer,loaded,visiblePixels,external,requests,screenshot:path.join(temp,`${viewer}.png`)}));
|
|
if(ref){assert(external.includes('raw.githack.com'));assert(!loaded,'old viewer stays suspended while environment request hangs');assert(visiblePixels<2000,'old viewer renders no model');}
|
|
else {assert(loaded);assert.equal(external.length,0,'Claw viewer must not need an external environment');assert(visiblePixels>5000,'the model must be visible, not just loaded');assert.equal(await page.getByRole('status').count(),0);}
|
|
await page.close();
|
|
}
|
|
if(!ref){
|
|
const probe=await browser.newPage();await probe.clock.install();
|
|
await probe.route('**/pending-manifest',()=>{});
|
|
await probe.goto(`${origin}/?viewer=manifest`);await probe.getByText('等待清单').waitFor();
|
|
await probe.clock.fastForward(16000);await probe.getByText('基础预览 交互清单加载超时').waitFor();
|
|
await probe.close();console.log('Manifest timeout switches to basic viewer after 15 seconds');
|
|
const page=await browser.newPage();await page.goto(`${origin}/?viewer=interactive&case=error`);await page.getByText('3D 交互预览加载失败', {exact:false}).waitFor();await page.close();console.log('HTTP failure displays an explicit fallback');}
|
|
} finally {await browser.close();server.close();}
|