373 lines
36 KiB
JavaScript
373 lines
36 KiB
JavaScript
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;
|
||
}
|