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(); if (process.env.FREECAD_MANUAL_IDS?.split(',').includes('FIX-IFC-REOPEN')) await test('FIX-IFC-REOPEN','IFC 原生文档保存重开回归',remainingCases['FIX-IFC-REOPEN']); 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; }