// Real WebGL regression with a synthetic GLB; no production data or generation calls. // node scripts/verify-claw-3d-loading.mjs [--source-ref=] [--model-file=] 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
{manifest===null?'基础预览': '等待清单'} {manifestError}
};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'?:params.get('viewer')==='basic'?window.loaded=stats.meshCount>0}/>: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('
'); }); 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;i80)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();}