Files
FreeCADWasmDev/tools/run_user_manual_tests.mjs
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

373 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createRemainingCases } from './user-manual-remaining-cases.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const output = path.resolve(root, 'docs/current/user-tests-20260915', `manual-${stamp}`);
fs.mkdirSync(output, { recursive: true });
const manualPath = path.join(root, 'docs/current/web-freecad-user-validation-manual.md');
fs.copyFileSync(manualPath, path.join(output, 'manual-snapshot.md'));
const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const registry = JSON.parse(fs.readFileSync(path.join(root, 'docs/current/feature-capability-registry.json')));
const fixture = path.join(root, 'web/public/samples/freecad-sample.step');
const processes = [], results = [], pending = new Map(), events = [];
let socket, sequence = 0, current, origin, server, browserPid;
const startedAt = new Date().toISOString();
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'freecad-user-manual-'));
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const json = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
class Blocked extends Error {}
function check(condition, message) { if (!condition) throw new Error(message); }
function launch(command, args, cwd = root) {
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
const closed = new Promise(resolve => { child.once('close', resolve); child.once('error', resolve); });
processes.push({ child, closed });
return child;
}
function send(method, params = {}) {
return new Promise((resolve, reject) => {
const id = ++sequence;
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${method} timeout`)); }, 20000);
pending.set(id, { resolve, reject, timer });
socket.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression) {
const value = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (value.exceptionDetails) throw new Error(JSON.stringify(value.exceptionDetails));
return value.result?.value;
}
async function until(label, expression, predicate = Boolean, timeout = 15000) {
const start = performance.now();
let actual;
while (performance.now() - start < timeout) {
actual = await evaluate(expression);
if (predicate(actual)) return actual;
const exception = events.find(e => e.test === current?.id && e.method === 'Runtime.exceptionThrown');
if (exception) throw new Error(`${label}: ${JSON.stringify(exception.params)}`);
await wait(150);
}
throw new Error(`${label} timeout (${timeout} ms): ${JSON.stringify(actual)}`);
}
async function snapshot(stem) {
const state = await evaluate(`({ url:location.href, isolated:crossOriginIsolated,
sharedArrayBuffer:typeof SharedArrayBuffer, ready:document.readyState,
text:document.body.innerText.slice(0,10000),
tabs:[...document.querySelectorAll('button.tab')].map(e=>({text:e.innerText,active:e.classList.contains('active')})),
objects:[...document.querySelectorAll('[role=treeitem]')].map(e=>({id:e.dataset.objectId,text:e.innerText,selected:e.getAttribute('aria-selected')})),
status:[...document.querySelectorAll('[role=status],[role=alert]')].map(e=>e.textContent.slice(0,2000)),
controls:[...document.querySelectorAll('button,input,select')].map(e=>({tag:e.tagName,label:e.getAttribute('aria-label')||e.textContent?.slice(0,120),value:e.value,disabled:e.disabled})),
volume:document.querySelector('[data-native-volume]')?.textContent,
workbench:window.__freecadWorkbenchState ? {stage:window.__freecadWorkbenchState.stage,status:window.__freecadWorkbenchState.status,response:window.__freecadWorkbenchState.response} : null,
canvases:[...document.querySelectorAll('canvas')].map(e=>({width:e.width,height:e.height})) })`);
json(path.join(output, `${stem}.json`), state);
const layout = await send('Page.getLayoutMetrics');
const bounds = layout.cssContentSize;
const screenshot = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true,
clip: { x: 0, y: 0, width: Math.min(bounds.width, 2000), height: Math.min(bounds.height, 6000), scale: 1 } });
fs.writeFileSync(path.join(output, `${stem}.png`), Buffer.from(screenshot.data, 'base64'));
let viewportScreenshot;
if (state.canvases.some(c=>c.width>0&&c.height>0)) {
const clip = await evaluate("(()=>{const r=document.querySelector('canvas').getBoundingClientRect();return {x:r.x+scrollX,y:r.y+scrollY,width:r.width,height:r.height,scale:1};})()");
const capture = await send('Page.captureScreenshot', { format:'png', clip, captureBeyondViewport:true });
viewportScreenshot = `${stem}-viewport.png`;
fs.writeFileSync(path.join(output, viewportScreenshot), Buffer.from(capture.data,'base64'));
}
return { state: `${stem}.json`, screenshot: `${stem}.png`, viewportScreenshot,
screenshotSha256: hash(path.join(output, `${stem}.png`)) };
}
function persist() {
json(path.join(output, 'results.json'), { startedAt, updatedAt: new Date().toISOString(), current: current?.id ?? null, origin, profile,
scope: 'Manual browser tests, not proof of all 1452 registered commands', results });
json(path.join(output, 'checkpoint.json'), { current: current?.id ?? null, completed: results.filter(r=>r.status !== 'running').map(r=>r.id),
nextAction: current ? `Inspect ${current.id} steps and events; do not repeat passed unrelated cases` : 'Read report.md; repair failed prerequisites before dependent retests' });
}
async function step(name, action, expected) {
const item = { number: current.steps.length + 1, name, expected, status: 'running', startedAt: new Date().toISOString() };
current.steps.push(item); persist();
const begin = performance.now();
try { item.actual = await action(); item.status = 'passed'; }
catch (error) { item.status = error instanceof Blocked ? 'blocked' : 'failed'; item.error = String(error); throw error; }
finally {
item.seconds = Number(((performance.now() - begin) / 1000).toFixed(3));
try { item.evidence = await snapshot(`${current.id}-${String(item.number).padStart(2, '0')}`); }
catch (error) { item.captureError = String(error); }
persist();
}
}
async function test(id, title, run) {
if (process.env.FREECAD_MANUAL_IDS && !process.env.FREECAD_MANUAL_IDS.split(',').includes(id)) return;
current = { id, title, status: 'running', steps: [], startedAt: new Date().toISOString() };
results.push(current); persist();
const begin = performance.now();
try { await (remainingCases[id] ?? run)(); current.status = 'passed'; }
catch (error) { current.status = error instanceof Blocked ? 'blocked' : 'failed'; current.error = String(error); }
current.seconds = Number(((performance.now() - begin) / 1000).toFixed(3));
console.log(`${current.id}: ${current.status} (${current.seconds}s)`);
current = null; persist();
}
async function open(route = '/') {
await send('Page.navigate', { url: origin + route });
await until('React application mounts', "document.readyState==='complete' && document.querySelectorAll('#app button').length>0");
check(await evaluate('crossOriginIsolated && typeof SharedArrayBuffer === "function"'), 'COOP/COEP isolation missing');
return { route, isolated: true };
}
async function click(selector) {
return evaluate(`(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!e)throw Error('Missing control: '+${JSON.stringify(selector)}); if(e.matches(':disabled'))throw Error('Disabled control: '+${JSON.stringify(selector)}); e.click(); return true; })()`);
}
async function button(label) {
return evaluate(`(() => {const e=[...document.querySelectorAll('button')].find(e=>(e.getAttribute('aria-label')||e.textContent.trim())===${JSON.stringify(label)}); if(!e)throw Error('Missing button: '+${JSON.stringify(label)}); if(e.matches(':disabled'))throw Error('Disabled button: '+${JSON.stringify(label)}); e.click(); return true;})()`);
}
async function input(label, value) {
const selector = `[aria-label=${JSON.stringify(label)}]`;
await evaluate(`(() => {const e=document.querySelector(${JSON.stringify(selector)}); if(!e||e.matches(':disabled'))throw Error('Input unavailable: '+${JSON.stringify(label)}); e.focus(); e.select();})()`);
await send('Input.insertText', { text: String(value) });
}
async function select(label, value) {
await evaluate(`(() => {const e=document.querySelector('select[aria-label='+JSON.stringify(${JSON.stringify(label)})+']'); if(!e)throw Error('Select missing'); e.value=${JSON.stringify(value)}; e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
}
const countExpression = "document.querySelectorAll('[role=treeitem]').length";
const volumeExpression = "Number(document.querySelector('[data-native-volume]')?.textContent)";
const count = () => evaluate(countExpression);
const idle = () => until('Native operation finishes', "!document.querySelector('[aria-label=\"Task: Box dimensions\"]')?.disabled", Boolean, 30000);
async function dimensions(values) { for (const [i, label] of ['Length', 'Width', 'Height'].entries()) await input(label, values[i]); }
async function volume(expected) { return until('Native volume', volumeExpression, n => Math.abs(n-expected)<1e-6, 30000); }
async function box(values = [2, 3, 4]) {
const before = await count(); await dimensions(values); await button('创建长方体');
await until('Box object in tree', countExpression, n=>n===before+1, 30000); await idle();
return { count: await count(), volume: await volume(values.reduce((a,b)=>a*b)) };
}
async function upload(file) {
const dom = await send('DOM.getDocument');
const node = await send('DOM.querySelector', { nodeId: dom.root.nodeId, selector: 'input[type=file]' });
check(node.nodeId, 'File input is missing');
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [file] });
}
let savedFile;
async function save() {
await button('保存 FCStd 到 OPFS');
await until('FCStd saved', "document.querySelector('.stage > [role=status]')?.textContent", s=>s?.startsWith('Saved '), 30000); await idle();
savedFile = await evaluate("document.querySelector('[aria-label=\"OPFS document\"]').value");
return evaluate(`(async()=>{const d=await(await navigator.storage.getDirectory()).getDirectoryHandle('freecad-documents');const f=await(await d.getFileHandle(${JSON.stringify(savedFile)})).getFile();const bytes=new Uint8Array(await f.arrayBuffer());if(bytes.length<1000||bytes[0]!==80||bytes[1]!==75)throw Error('Invalid FCStd ZIP');return {name:f.name,size:f.size,header:[...bytes.slice(0,4)]};})()`);
}
async function reopen() {
await idle();
await until('OPFS listing contains saved file', `[...document.querySelector('[aria-label="OPFS document"]').options].some(o=>o.value===${JSON.stringify(savedFile)})`);
await select('OPFS document', savedFile);
await until('OPFS open button enabled', "[...document.querySelectorAll('button')].some(b=>b.textContent==='打开 OPFS 文档'&&!b.matches(':disabled'))");
await button('打开 OPFS 文档');
await until('FCStd opened', "document.querySelector('.stage > [role=status]')?.textContent", s=>s?.startsWith('Opened '), 30000);
await idle(); return { objects: await count(), volume: await evaluate(volumeExpression) };
}
async function canvasHash() {
const clip = await evaluate("(()=>{const r=document.querySelector('canvas').getBoundingClientRect();return {x:r.x+scrollX,y:r.y+scrollY,width:r.width,height:r.height,scale:1};})()");
const { data } = await send('Page.captureScreenshot', { format: 'png', clip });
return createHash('sha256').update(Buffer.from(data, 'base64')).digest('hex');
}
async function setup() {
server = launch(path.join(root, 'web/node_modules/.bin/vite'), ['preview','--host','127.0.0.1','--port','0'], path.join(root,'web'));
origin = await new Promise((resolve,reject)=>{
let text=''; const timer=setTimeout(()=>reject(new Error('Preview startup timeout')),15000);
server.once('error',reject);
server.stdout.on('data',chunk=>{text+=chunk; const match=text.match(/http:\/\/127\.0\.0\.1:\d+/); if(match){clearTimeout(timer);resolve(match[0]);}});
});
const chrome = launch('/usr/bin/google-chrome', ['--headless=new','--no-sandbox','--use-angle=swiftshader','--enable-unsafe-swiftshader','--remote-debugging-port=0',`--user-data-dir=${profile}`,'about:blank']);
browserPid = chrome.pid;
const log=fs.createWriteStream(path.join(output,'chrome.log')); chrome.stderr.pipe(log);
for(let i=0;i<100&&!fs.existsSync(path.join(profile,'DevToolsActivePort'));i++)await wait(100);
const port=fs.readFileSync(path.join(profile,'DevToolsActivePort'),'utf8').split('\n')[0];
const pages=await fetch(`http://127.0.0.1:${port}/json/list`).then(r=>r.json());
socket=new WebSocket(pages.find(t=>t.type==='page').webSocketDebuggerUrl);
socket.addEventListener('message',({data})=>{
const message=JSON.parse(data), request=pending.get(message.id);
if(request){clearTimeout(request.timer);pending.delete(message.id);message.error?request.reject(new Error(JSON.stringify(message.error))):request.resolve(message.result);}
else if(['Runtime.exceptionThrown','Log.entryAdded','Network.loadingFailed','Network.responseReceived','Browser.downloadProgress','Page.fileChooserOpened'].includes(message.method)) {
const params = message.method==='Network.responseReceived' ? {url:message.params.response.url,status:message.params.response.status,mimeType:message.params.response.mimeType} : message.params;
const event = {test:current?.id,at:new Date().toISOString(),method:message.method,params};
events.push(event);
fs.appendFileSync(path.join(output,'events.ndjson'),JSON.stringify(event)+'\n');
}
});
await new Promise((resolve,reject)=>{socket.addEventListener('open',resolve,{once:true});socket.addEventListener('error',reject,{once:true});});
for(const domain of ['Page','Runtime','Network','Log'])await send(`${domain}.enable`);
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
fs.mkdirSync(path.join(output,'downloads'));
await send('Browser.setDownloadBehavior',{behavior:'allow',downloadPath:path.join(output,'downloads'),eventsEnabled:true});
json(path.join(output,'environment.json'),{startedAt,origin,profile,viewport:{width:1440,height:1000},browser:await send('Browser.getVersion'),
renderer:'headless Chrome software WebGL; not a user GPU performance baseline',
fingerprints:Object.fromEntries(['web/dist/index.html','web/dist/workbench.html','web/dist/modules/manifest.json','docs/current/web-freecad-user-validation-manual.md'].map(p=>[p,hash(path.join(root,p))])),
fixture:{path:fixture,sha256:hash(fixture)},registryCounts:registry.counts});
}
const remainingCases = createRemainingCases({ root, output, fixture, step, open, click, button, input, select,
evaluate, send, until, wait, check, box, dimensions, volume, count, upload, save, reopen, idle, hash,
getBrowserPid: () => browserPid, getEvents: () => events });
try {
await setup();
await test('UI-01','首次启动与刷新',async()=>{
await step('打开隔离测试站点',()=>open(),'React 已挂载,跨源隔离生效');
await step('检查树、属性、视口和 OPFS',async()=>{const s=await evaluate("({tree:!!document.querySelector('[role=tree]'),properties:!!document.querySelector('[aria-label=\"Object Properties\"]'),canvas:!!document.querySelector('canvas'),opfs:typeof navigator.storage.getDirectory==='function'})");check(Object.values(s).every(Boolean),'Missing main workspace region');return s;},'主工作区完整,不以路由登记代替运行结果');
await step('刷新并重新挂载',()=>open('/'),'刷新后仍可使用');
});
await test('UI-02','双文档数据隔离',async()=>{
await step('文档 A 创建体积 24 的 Box',async()=>{await open();return box();},'A 有一个 Box');
await step('切换 B 创建体积 60 的 Box',async()=>{await click('button.tab:nth-of-type(2)');await until('Empty B',countExpression,n=>n===0);return box([3,4,5]);},'B 初始为空,创建后体积 60');
await step('切回 A 核对体积',async()=>{await click('button.tab');return {count:await count(),volume:await volume(24)};},'A 仍为体积 24');
});
await test('GEO-01','Box 参数及几何',async()=>{
await step('创建 2 x 3 x 4 Box',async()=>{await open();return box();},'1 个对象,原生体积约 24');
await step('读取尺寸输入与网格',async()=>{const v=await evaluate("({dimensions:['Length','Width','Height'].map(s=>Number(document.querySelector('[aria-label='+JSON.stringify(s)+']').value)),properties:document.querySelector('.properties dl')?.innerText})");check(JSON.stringify(v.dimensions)==='[2,3,4]','Dimensions differ');return v;},'尺寸为 2、3、4');
await step('核对包围盒数值',async()=>{throw new Blocked('主工作区未展示包围盒数值;本次不注入内核旁路冒充用户读取。体积和尺寸已有独立证据。');},'数值包围盒约 [0,0,0]-[2,3,4]');
});
await test('GEO-02','编辑取消与撤销重做',async()=>{
await step('准备 Box',async()=>{await open();return box();},'体积 24');
await step('长度改 5 并应用',async()=>{await dimensions([5,3,4]);await button('应用尺寸并重算');await idle();return volume(60);},'体积 60');
await step('长度改 7 后取消',async()=>{await dimensions([7,3,4]);await button('取消尺寸编辑');check(await evaluate("Number(document.querySelector('[aria-label=Length]').value)")===5,'Cancel failed');return volume(60);},'取消后长度 5,体积 60');
await step('撤销已提交尺寸',async()=>{await button('撤销');await idle();return volume(24);},'回到长度 2,体积 24');
await step('重做已提交尺寸',async()=>{await button('重做');await idle();return volume(60);},'长度 5,体积 60');
});
await test('GEO-03','多对象相交与布尔并集',async()=>{
await step('创建两个 Box',async()=>{await open();await box();return box();},'两个独立对象');
await step('第二个 Box 沿 X 移动 1',async()=>{await input('Position X',1);await button('应用位置');await idle();return volume(24);},'移动不改变体积');
await step('并集并检查几何结果',async()=>{const names=await evaluate("[...document.querySelector('[aria-label=\"Boolean Base\"]').options].map(e=>e.value).filter(Boolean)");await select('Boolean Base',names[0]);await select('Boolean Tool',names[1]);await click('[data-boolean=Fuse]');await click('[data-create-boolean]');await idle();return volume(36);},'并集体积 36,两个 2x3x4 Box 相交体积 12');
await step('保存布尔文档',()=>save(),'保存真实 FCStd ZIP');
});
await test('GEO-04','删除与撤销恢复',async()=>{
await step('创建测试对象',async()=>{await open();return box();},'对象存在');
await step('删除选中对象',async()=>{await button('移除对象');await until('Deleted',countExpression,n=>n===0);return {count:await count()};},'树中无对象');
await step('撤销删除',async()=>{await button('撤销');await until('Restored',countExpression,n=>n===1);await click('[role=treeitem]');return volume(24);},'恢复同一几何');
await step('再次删除',async()=>{await button('移除对象');return until('Deleted again',countExpression,n=>n===0);},'无残留树节点');
});
await test('FILE-01','STEP 导入与保存',async()=>{
await step('选择本机 STEP 样本',async()=>{await open();await upload(fixture);await until('STEP object',countExpression,n=>n===1,30000);await idle();const v=await evaluate(volumeExpression);check(v>0,'Imported volume not positive');return {volume:v,sha256:hash(fixture)};},'原生对象、正体积、非空网格');
await step('保存 STEP 文档到 FCStd',()=>save(),'可读 ZIP 数据');
});
await test('FILE-03','FCStd 保存关闭与重开',async()=>{
await step('准备并编辑 Label',async()=>{await open();await box();await input('Object Label','User acceptance box');await evaluate("document.querySelector('[aria-label=\"Object Label\"]').blur()");await idle();return true;},'标签提交到原生对象');
await step('保存并下载 FCStd',async()=>{const saved=await save();await button('下载 native FCStd');return saved;},'OPFS 文件和下载均存在');
await step('关闭后打开 OPFS 文件',async()=>{await button('关闭活动 Document');const state=await reopen();check(state.objects===1,'Object count differs');await volume(24);return state;},'对象数与体积恢复');
await step('通过本地 FCStd 文件选择器重开',async()=>{throw new Blocked('主页面选择器明确仅支持 STEP/STP;本地 FCStd 打开需要另行通过 Web/Std_Open 工作台测试,不能用 OPFS 代替。');},'本地下载 FCStd 可重开');
});
await test('FILE-04','OPFS 刷新恢复与清理',async()=>{
await step('创建和保存',async()=>{await open();await box();return save();},'文件已写入隔离测试 OPFS');
await step('刷新后打开',async()=>{await open();const state=await reopen();check(state.objects===1,'Object count differs');await volume(24);return state;},'刷新不丢失已保存对象');
await step('清理测试站点存储后刷新',async()=>{await send('Storage.clearDataForOrigin',{origin,storageTypes:'all'});await open();return until('OPFS empty',"document.querySelector('[aria-label=\"OPFS document\"]').options.length",n=>n===1);},'测试站点文件消失,真实用户数据不受影响');
});
await test('DOC-01','标签颜色可见性与持久化',async()=>{
await step('创建对象并提交标签',async()=>{await open();await box();await input('Object Label','Validation label');await evaluate("document.querySelector('[aria-label=\"Object Label\"]').blur()");await idle();return until('Label changed',"document.querySelector('[role=treeitem]')?.innerText",s=>s.includes('Validation label'));},'树中标签更新');
await step('颜色编辑',async()=>{await evaluate("(()=>{const e=document.querySelector('[aria-label=\"Object Color\"]');Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(e,'#cc3344');e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()");return evaluate("document.querySelector('[aria-label=\"Object Color\"]').value");},'颜色输入与渲染可观察');
await step('隐藏显示改变视口像素',async()=>{const before=await canvasHash();await click('[aria-label="Object Visibility"]');await wait(300);const hidden=await canvasHash();check(before!==hidden,'Visibility does not affect canvas');await click('[aria-label="Object Visibility"]');return {before,hidden};},'隐藏与显示图片不同');
await step('保存重开检查标签及颜色',async()=>{await save();await open();await reopen();const actual=await evaluate("({label:document.querySelector('[aria-label=\"Object Label\"]').value,color:document.querySelector('[aria-label=\"Object Color\"]').value})");check(actual.label==='Validation label','Label not persisted');check(actual.color==='#cc3344','Color not persisted');return actual;},'已修改标签和颜色按手册预期持久化');
await step('隐藏状态保存重开',async()=>{await click('[aria-label="Object Visibility"]');await save();await open();await reopen();const visible=await evaluate("document.querySelector('[aria-label=\"Object Visibility\"]').checked");check(visible===false,'Hidden visibility not persisted');await click('[aria-label="Object Visibility"]');return {visible,volume:await volume(24)};},'可见性随 FCStd 保留,几何体积不变');
});
await test('FIX-GROUP','分组创建、事务与 FCStd 持久化回归',async()=>{
await step('创建原生分组',async()=>{await open();await box();await click('details summary');await button('Create group');return until('Group created',"({count:document.querySelectorAll('[role=treeitem]').length,error:document.querySelector('[aria-label=\"New group\"] [role=alert]')?.textContent})",s=>{if(s.error)throw new Error(s.error);return s.count===2;},30000);},'Box 和 Group 同属一个原生文档');
await step('保存分组并刷新重开',async()=>{await save();await open();const state=await reopen();check(state.objects===2,'Group not restored');const tree=await evaluate("[...document.querySelectorAll('[role=treeitem]')].map(e=>e.innerText)");check(tree.some(s=>s.includes('Group')),'Restored group missing');return tree;},'两个原生对象和 Group 标签恢复');
});
await test('UI-04','视口旋转缩放平移与布局',async()=>{
await step('准备几何并适合视图',async()=>{await open();await box();await button('适合全部对象');return volume(24);},'模型可显示');
for(const mode of ['rotate','pan','zoom']) await step(mode,async()=>{
const before=await canvasHash();const p=await evaluate("(()=>{const r=document.querySelector('canvas').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};})()");
if(mode==='zoom')await send('Input.dispatchMouseEvent',{type:'mouseWheel',...p,deltaX:0,deltaY:-160});
else {const button=mode==='rotate'?'left':'right';await send('Input.dispatchMouseEvent',{type:'mousePressed',...p,button,clickCount:1});for(let i=1;i<=8;i++)await send('Input.dispatchMouseEvent',{type:'mouseMoved',x:p.x+i*9,y:p.y+i*4,button,buttons:mode==='rotate'?1:2});await send('Input.dispatchMouseEvent',{type:'mouseReleased',x:p.x+72,y:p.y+32,button,clickCount:1});}
await wait(500);const after=await canvasHash();check(before!==after,`${mode} did not change viewport`);return {before,after,volume:await volume(24)};
},'视口像素改变,原生体积不变');
await step('手机尺寸布局',async()=>{await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:false});await wait(300);return evaluate("({width:innerWidth,scrollWidth:document.documentElement.scrollWidth,canvas:document.querySelector('canvas').getBoundingClientRect().toJSON()})");},'记录移动布局截图,不据此宣称移动浏览器兼容');
await send('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false});
});
await test('ERR-02','非法文件与尺寸后恢复',async()=>{
await step('零和负尺寸拒绝',async()=>{await open();await box();for(const n of [0,-1]){await dimensions([n,3,4]);await button('应用尺寸并重算');await idle();await volume(24);}return {count:await count(),volume:await volume(24)};},'合法模型未被污染');
await step('空 STEP 拒绝',async()=>{const file=path.join(output,'empty.step');fs.writeFileSync(file,'');await upload(file);await idle();check(await count()===1,'Empty file changed object count');return evaluate("document.querySelector('.stage > [role=status]').textContent");},'错误可见,已有对象保留');
await step('正常请求恢复',()=>box([3,4,5]),'仍可创建体积 60 对象');
});
await test('ERR-03','重复点击不产生重复对象',async()=>{
await step('同一事件循环连续点击创建',async()=>{await open();await dimensions([2,3,4]);await evaluate("(()=>{const b=[...document.querySelectorAll('button')].find(e=>e.textContent==='创建长方体');b.click();b.click();})()");await idle();await until('At least one object',countExpression,n=>n>0,30000);await wait(500);const n=await count();check(n===1,`Duplicate objects: ${n}`);return n;},'仅创建一个对象');
});
await test('DOC-02','结构分组入口',async()=>{
await step('创建原生分组',async()=>{await open();await box();await click('details summary');await button('Create group');return until('Group created',"({count:document.querySelectorAll('[role=treeitem]').length,error:document.querySelector('[aria-label=\"New group\"] [role=alert]')?.textContent})",s=>{if(s.error)throw new Error(s.error);return s.count===2;},30000);},'Group 和 Box 出现在树中');
await step('Part/VarSet/Tip 完整流程',async()=>{await open('/workbench.html?module=Web&command=Std_Part');throw new Blocked('尚未建立 Part/VarSet/Tip 逐控件自动化,入口检查不能代替完整结构业务。');},'加入移出、Tip、保存和重开全部检查');
});
await test('DOC-03','链接替换解除',async()=>{
await step('进入链接命令页面',()=>open('/workbench.html?module=Web&command=Std_LinkMake'),'链接任务面板已挂载');
await step('替换、解除和事务',async()=>{throw new Blocked('需要链接目标选择及事务自动化;未执行,不计通过。');},'链接关系可撤销并持久化');
});
await test('FILE-02','多格式导入',async()=>{
await step('进入通用文件打开面板',()=>open('/workbench.html?module=Web&command=Std_Open'),'通用文件打开任务可操作');
await step('IGES/BREP/STL/OBJ/DXF/IFC',async()=>{throw new Blocked('多格式样本尚未逐一绑定手册用例;未使用不匹配入口批量模拟通过。');},'每种格式有单独输入、期望与回执');
});
await test('FILE-05','STL 导出与回读',async()=>{
await step('进入导出流程',()=>open('/workbench.html?module=Web&command=Std_Export'),'导出入口可操作');
await step('导出 STL 并核对回读包围盒',async()=>{throw new Blocked('尚需绑定导出文档和比较基准;不能以 STEP 成功代替 STL。');},'STL 可读且几何误差可量化');
});
await test('ERR-01','运行中取消',async()=>{
await step('检查主界面运行中取消入口',async()=>{await open();const controls=await evaluate("[...document.querySelectorAll('button')].map(e=>({label:e.getAttribute('aria-label')||e.textContent,disabled:e.disabled})).filter(e=>/cancel|取消/i.test(e.label))");throw new Blocked(`主页面只有尺寸编辑取消,尚未确认运行中导入/重算取消入口;不得将取消编辑当作 Worker 取消:${JSON.stringify(controls)}`);},'真实任务取消,随后正常请求恢复');
});
await test('PERF-01','真实大小文件容量',async()=>{
await step('确认数据集与测量基准',async()=>{await open();throw new Blocked('尚无按小/中/大分级的真实业务数据集及用户硬件基准;软件 WebGL 环境不产生上线容量结论。');},'三组文件的首帧耗时、峰值内存、帧率');
});
await test('PERF-02','20 轮保存重开稳定性',async()=>{
await step('初始化测试文档',()=>open(),'隔离测试数据');
for(let n=1;n<=20;n++) await step(`第 ${n}/20 轮创建、保存、刷新、重开`,async()=>{await open();await box([2,3,4]);await save();await open();const state=await reopen();check(state.objects===1,'Object accumulation');await volume(24);return {iteration:n,...state};},'每轮一个对象,体积 24,无累计污染');
await step('手册规定的完整循环范围核对',async()=>{throw new Blocked('已完成的循环仅覆盖 Box/FCStd/OPFS;每轮真实文件导入、内存采样及用户硬件稳定性尚未覆盖,不将局部循环标作整项通过。');},'按手册完成导入、修改、保存、重开及内存趋势');
});
await test('FIX-WINDOW','面板菜单和工具栏原生交互回归',async()=>{
await step('窗口宿主初始化',async()=>{await open('/workbench.html?module=Part');return until('Window host ready',"document.querySelector('[aria-label=\"Window toolbar\"]')?.dataset.windowReady",v=>v==='true',30000);},'面板和工具栏原生读取完成');
await step('工具栏锁定切换再恢复',async()=>{
const selector='[aria-label="Window toolbar"] > label input';
const before=await evaluate(`document.querySelector(${JSON.stringify(selector)}).checked`);
await click(selector);
await until('Lock changed',`document.querySelector(${JSON.stringify(selector)}).checked`,v=>v!==before);
await until('Lock idle',`!document.querySelector(${JSON.stringify(selector)}).disabled`);
await click(selector);
await until('Lock restored',`document.querySelector(${JSON.stringify(selector)}).checked`,v=>v===before);
return {before,after:await evaluate(`document.querySelector(${JSON.stringify(selector)}).checked`)};
},'状态由原生命令更改,第二次切换恢复');
await step('面板隐藏与显示',async()=>{
await click('[aria-label="Window toolbar"] details summary');
const selector='[aria-label="Window toolbar"] details input';
await until('Dock menu loaded',`document.querySelectorAll(${JSON.stringify(selector)}).length`,n=>n===2);
await until('Menu idle',`!document.querySelector(${JSON.stringify(selector)}).disabled`);
await click(selector);
await until('Panel hidden',"document.querySelector('[data-dock-panel=modules]').hidden");
await until('Menu idle',`!document.querySelector(${JSON.stringify(selector)}).disabled`);
await click(selector);
await until('Panel restored',"!document.querySelector('[data-dock-panel=modules]').hidden");
const errors=await evaluate("[...document.querySelectorAll('[role=alert]')].map(e=>e.textContent).filter(Boolean)");
check(errors.length===0,'Window interaction produced an error');return {errors};
},'模块面板真实隐藏、恢复,无错误提示');
});
for(const entry of registry.modules) await test(`UI-03-${entry.module}`,`${entry.module} 工作台入口`,async()=>{
await step('导航到模块实际工作台',()=>open(`/workbench.html?module=${encodeURIComponent(entry.module)}`),'实际工作台可挂载');
await step('核对选中模块',async()=>{const selected=await until('Selected module',"document.querySelector('button.module.selected strong')?.textContent",s=>s===entry.module);return {selected};},'不能用 HTTP 200 代替模块可操作');
await step('窗口宿主初始化及启动错误',async()=>{
return until('Window host ready',"({ready:document.querySelector('[aria-label=\"Window toolbar\"]')?.dataset.windowReady,errors:[...document.querySelectorAll('[role=alert]')].map(e=>e.textContent).filter(Boolean)})",value=>{if(value.errors.length)throw new Error(value.errors.join('; '));return value.ready==='true';},30000);
},'原生面板与工具栏状态已读取,无启动错误提示');
});
} catch(error) {
json(path.join(output,'fatal.json'),{error:String(error)});process.exitCode=1;
} finally {
json(path.join(output,'events.json'),events);
for(const request of pending.values())clearTimeout(request.timer);
socket?.close();
for(const {child,closed} of processes.reverse()) { if(child.exitCode===null)child.kill('SIGTERM'); await Promise.race([closed,wait(3000)]);if(child.exitCode===null)child.kill('SIGKILL');await closed; }
const counts=Object.fromEntries(['passed','failed','blocked','running'].map(s=>[s,results.filter(r=>r.status===s).length]));
const lines=['# 用户手册执行报告','',`开始:${startedAt}`,`结束:${new Date().toISOString()}`,'',`结果:${JSON.stringify(counts)}`,'',
'本轮测试不是全功能验收。通过仅表示本项已执行的断言成立;阻塞、未覆盖内容不能计入通过。32 个模块入口结果不代表 1452 个命令已经验证。',
'', '| 编号 | 项目 | 状态 | 秒 | 证据 / 原因 |','|---|---|---|---|---|',
...results.map(r=>`| ${r.id} | ${r.title} | ${r.status} | ${r.seconds} | ${r.steps.map(s=>s.evidence?`[${s.number}](${s.evidence.screenshot})`:'').join(' ')} ${(r.error??'').replaceAll('|','/').replaceAll('\n',' ').slice(0,300)} |`),'',
'## 按步骤执行过程','',...results.flatMap(r=>[`### ${r.id} ${r.title}`,'',...r.steps.flatMap(s=>[`${s.number}. ${s.name} (${s.status}, ${s.seconds}s)`,` 预期:${s.expected}`,` 实际:${JSON.stringify(s.actual??s.error)}`,s.evidence?` [截图](${s.evidence.screenshot}) / [状态](${s.evidence.state})`:' 未能截图',''])]),
'## 管理与后续','', '输入手册快照:manual-snapshot.md;结构化结果:results.json;断点:checkpoint.json;浏览器与网络:events.json;环境指纹:environment.json。',
'',`隔离 Chrome 配置保留在 ${profile},仅含本次测试数据。没有清理真实用户站点。`,
'', '运行同一脚本会创建新的时间戳目录,不覆盖历史证据。FREECAD_MANUAL_IDS 可按逗号指定需要复测的编号。测试中没有更改应用实现或验收登记。'];
fs.writeFileSync(path.join(output,'report.md'),lines.join('\n')+'\n');
persist();
console.log(JSON.stringify({output,counts}));
if(results.some(r=>r.status!=='passed'))process.exitCode=1;
}