import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {execFileSync} from 'node:child_process'; import assert from 'node:assert/strict'; import {chromium,expect} from '@playwright/test'; const report=path.resolve('reports/animation-capabilities-20260912'),root=path.join(report,'offline'); execFileSync('python',['-c',`import zipfile,pathlib root=pathlib.Path(${JSON.stringify(root)}).resolve() with zipfile.ZipFile(${JSON.stringify(path.join(report,'animation-guide.zip'))}) as archive: for entry in archive.infolist(): target=(root/entry.filename).resolve() if not target.is_relative_to(root): raise ValueError('Invalid archive path') archive.extract(entry,root) `]); const mime={'.html':'text/html','.js':'text/javascript','.wasm':'application/wasm','.glb':'model/gltf-binary','.css':'text/css','.png':'image/png','.jpg':'image/jpeg'}; const server=http.createServer((req,res)=>{const file=path.resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://localhost').pathname).replace(/\/$/,'/index.html'));if(!file.startsWith(root+path.sep)||!fs.existsSync(file)){res.writeHead(404).end();return;}res.setHeader('Content-Type',mime[path.extname(file)]||'application/octet-stream');fs.createReadStream(file).pipe(res);}); await new Promise(resolve=>server.listen(18779,'127.0.0.1',resolve)); const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); const page=await browser.newPage({viewport:{width:1440,height:1000}}),checks=[],errors=[],external=[]; page.on('pageerror',e=>errors.push(e.message)); await page.route('**/*',route=>{const url=new URL(route.request().url());if(url.origin!=='http://127.0.0.1:18779'&&!['data:','blob:'].includes(url.protocol)){external.push(url.origin);return route.abort();}return route.continue();}); try{ await page.goto('http://127.0.0.1:18779/'); await page.locator('[data-jump="1"]').first().click(); await page.locator('[data-action="open-model"]').first().click(); await expect(page.locator('[data-model-status]')).not.toContainText(/正在加载|失败|未加载/,{timeout:60000}); await expect(page.locator('[data-model-stage] canvas')).toBeVisible({timeout:60000}); await page.locator('[data-model-stage] canvas').scrollIntoViewIfNeeded(); const canvas=page.locator('[data-model-stage] canvas'); const before=await canvas.screenshot();await page.screenshot({path:path.join(report,'07-offline-before.png')}); await page.locator('[data-model-action="play-animation"]').click(); await expect(page.locator('[data-model-status]')).toContainText('旋出与清洁演示'); await page.waitForTimeout(1800);const after=await canvas.screenshot(); assert.notDeepEqual(before,after,'Offline canvas must change while the authored action plays'); await page.locator('[data-model-action="pause-animation"]').click(); await page.screenshot({path:path.join(report,'08-offline-animation.png')}); checks.push({name:'下载的离线包可播放命名组合动作,画布发生变化并可暂停',pass:true}); assert.equal(external.length,0);assert.equal(errors.length,0,errors.join('\n')); checks.push({name:'断开业务服务与公网后仍可加载模型、引擎和动画',pass:true}); }catch(error){checks.push({name:'离线动画端到端',pass:false,error:error.message});process.exitCode=1;console.error(error.message);await page.screenshot({path:path.join(report,'offline-failure.png')}).catch(()=>{});} finally{fs.writeFileSync(path.join(report,'offline-checks.json'),JSON.stringify({checks,errors,external},null,2));await browser.close();await new Promise(resolve=>server.close(resolve));}