import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { writeBusinessReport } from './business-report.mjs'; import { dependencyGraphId, runDependencyGraphCases } from './web-dependency-graph-business-cases.mjs'; import { graphExportId, runGraphExportCases } from './web-graph-export-business-cases.mjs'; import { crossWorkbenchId, runCrossWorkbenchCases } from './web-cross-workbench-business-cases.mjs'; import { taskInteractionId, runTaskInteractionCases } from './web-task-interaction-business-cases.mjs'; import assert from 'node:assert/strict'; import { playwright } from './part-primitive-business-cases.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const mapping = JSON.parse(fs.readFileSync(path.join(root, 'tools/manual-business-mapping.json'))).records; const ids = JSON.parse(process.env.FREECAD_BUSINESS_IDS ?? '[]'); const manualIds = ids.filter(id => Object.hasOwn(mapping, id)); const projectInfoId = 'Web:desktop:Std_ProjectInfo'; const workspaceId = 'product:desktop-workspace-parity'; const legacyIds = ids.filter(id => id !== taskInteractionId && id !== crossWorkbenchId && id !== workspaceId && id !== dependencyGraphId && id !== graphExportId && id !== projectInfoId && !Object.hasOwn(mapping, id)); const results = [...await runDependencyGraphCases(ids), ...await runGraphExportCases(ids), ...await runCrossWorkbenchCases(ids), ...await runTaskInteractionCases(ids)]; const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (ids.includes(projectInfoId)) results.push(await runProjectInfoCases()); if (ids.includes(workspaceId)) results.push(await runWorkspaceCases()); async function runWorkspaceCases() { const result = { capabilityId: workspaceId, status: 'failed', cases: [], evidenceFiles: {} }; const directory = path.join(root, 'docs/current/web-workspace-business', new Date().toISOString().replace(/[:.]/g, '-')); fs.mkdirSync(directory, { recursive: true }); let browser, server, page; const record = (area, input, actual, name) => result.cases.push({ covers: [area], input, actual, assertions: [{ name, passed: true }] }); const artifact = (file, value) => { const target = path.join(directory, file); if (value !== undefined) fs.writeFileSync(target, JSON.stringify(value, null, 2)); result.evidenceFiles[path.relative(root, target)] = sha256(target); }; try { let origin = process.env.FREECAD_BROWSER_ORIGIN; if (!origin) { server = spawn(path.join(root, 'web/node_modules/.bin/vite'), ['preview', '--host', '127.0.0.1', '--port', '0'], { cwd: path.join(root, 'web'), stdio: ['ignore', 'pipe', 'pipe'] }); origin = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(Error('Workspace preview startup timeout')), 20000); let output = ''; server.on('error', error => { clearTimeout(timer); reject(error); }); server.stdout.on('data', chunk => { output += chunk; const match = output.match(/http:\/\/127\.0\.0\.1:\d+/); if (match) { clearTimeout(timer); resolve(match[0]); } }); }); } browser = await playwright().chromium.launch({ executablePath: process.env.FREECAD_CHROME ?? '/usr/bin/google-chrome', args: ['--no-sandbox', '--disable-dev-shm-usage'] }); page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); page.setDefaultTimeout(45000); await page.addInitScript(() => { window.workspaceNativeMessages = []; const OriginalWorker = window.Worker; window.Worker = class extends OriginalWorker { constructor(...args) { super(...args); this.addEventListener('message', event => { if (event.data?.kind === 'result') window.workspaceNativeMessages.push(event.data); }); } }; }); await page.goto(origin, { waitUntil: 'domcontentloaded' }); await page.getByRole('toolbar', { name: 'Part workbench toolbar' }).waitFor(); assert.equal(await page.locator('iframe').count(), 0); assert.equal(await page.getByLabel('Developer diagnostics').count(), 0); assert.equal(await page.locator('[data-queue-recovery]').count(), 0); assert.equal(await page.locator('.properties fieldset:visible').count(), 0); record('primary_entry', { route: '/' }, { title: await page.title(), developerPanels: 0 }, 'Primary React workspace opens with diagnostics hidden and no unrelated task forms'); await page.getByText('File', { exact: true }).click(); await page.getByRole('button', { name: '新建 Document', exact: true }).click(); const activeTab = page.getByRole('tab', { selected: true }); const document = await activeTab.innerText(); assert.match(document, /project-document-3/); await page.getByText('File', { exact: true }).click(); await page.getByRole('button', { name: '创建长方体', exact: true }).click(); await page.getByRole('treeitem').first().waitFor(); await page.waitForFunction(() => document.querySelector('[aria-label="Object Label"]')?.value === 'Box1'); const native = await page.evaluate(() => window.workspaceNativeMessages); artifact('native-create.json', native); const box = native.at(-1).result.objects[0]; assert.equal(box.type, 'Part::Box'); assert.ok(Math.abs(box.volume - 1000) < 1e-8); assert.equal(box.mesh.faceCount, 6); assert.equal(box.mesh.triangleCount, 12); record('menus_toolbars', { menu: 'File > New', toolbar: 'Part > Create Box' }, { document, native }, 'Menu creates active document and toolbar creates real six-face native box of volume 1000'); await page.getByRole('treeitem').first().focus(); await page.keyboard.press('Enter'); assert.equal(await page.getByRole('treeitem').first().getAttribute('aria-selected'), 'true'); assert.equal(await page.getByLabel('Object Label', { exact: true }).inputValue(), 'Box1'); await page.getByLabel('Object Label', { exact: true }).fill('Workspace box'); await page.getByLabel('Object Label', { exact: true }).press('Tab'); await page.waitForFunction(() => document.querySelector('[role=treeitem]')?.textContent.includes('Workspace box')); const triangles = await page.locator('.properties dl').innerText(); assert.match(triangles, /Triangles\s+[1-9]\d*/); record('tree_properties_selection', { keyboardSelection: 'Enter', label: 'Workspace box' }, { tree: await page.getByRole('treeitem').first().innerText(), properties: triangles }, 'Keyboard tree selection synchronizes editable properties and native tessellation is present'); await page.getByRole('button', { name: 'Position', exact: true }).click(); assert.equal(await page.locator('.properties fieldset:visible').count(), 1); await page.getByLabel('Position X', { exact: true }).fill('7'); await page.getByRole('button', { name: '应用位置', exact: true }).click(); await page.waitForFunction(() => window.workspaceNativeMessages.some(message => JSON.stringify(message).includes('"position":[7,0,0]'))); await page.waitForFunction(() => document.querySelector('.workspace-task-controls button')?.disabled === false); await page.keyboard.press('Escape'); assert.equal(await page.locator('.properties fieldset:visible').count(), 0); await page.getByRole('button', { name: 'Primitive', exact: true }).click(); assert.equal(await page.locator('.properties fieldset:visible').count(), 1); await page.getByRole('button', { name: 'Close task / Cancel', exact: true }).click(); assert.equal(await page.locator('.properties fieldset:visible').count(), 0); await page.getByText('Edit', { exact: true }).click(); await page.getByRole('button', { name: 'Undo', exact: true }).click(); await page.waitForFunction(() => document.querySelector('[aria-label="重做"]')?.disabled === false); assert.deepEqual(await page.evaluate(() => window.workspaceNativeMessages.at(-1).result.objects[0].position), [0, 0, 0]); await page.getByRole('button', { name: 'Redo', exact: true }).click(); await page.waitForFunction(() => window.workspaceNativeMessages.at(-1)?.result?.objects?.[0]?.position?.[0] === 7 && document.querySelector('.workbench-toolbar button')?.disabled === false); assert.deepEqual(await page.evaluate(() => window.workspaceNativeMessages.at(-1).result.objects[0].position), [7, 0, 0]); await page.getByText('Edit', { exact: true }).click(); record('task_layout', { task: 'Position', x: 7, history: ['Undo', 'Redo'], cancel: ['Escape', 'Close task / Cancel'] }, { native: await page.evaluate(() => window.workspaceNativeMessages), visibleForms: await page.locator('.properties fieldset:visible').count() }, 'Only the selected native task opens; position commits and native history remain operable after closing tasks'); const separator = page.getByRole('separator', { name: 'Resize tree panel' }); const before = Number(await separator.getAttribute('aria-valuenow')); await separator.focus(); await page.keyboard.press('ArrowRight'); assert.equal(Number(await separator.getAttribute('aria-valuenow')), before + 20); const bounds = await separator.boundingBox(); await page.mouse.move(bounds.x + 3, bounds.y + 30); await page.mouse.down(); await page.mouse.move(bounds.x + 43, bounds.y + 30); await page.mouse.up(); assert.equal(Number(await separator.getAttribute('aria-valuenow')), before + 60); await page.getByRole('tab').nth(1).click(); assert.equal(await page.getByRole('treeitem').count(), 0); await page.getByRole('tab').nth(2).click(); assert.equal(await page.getByRole('treeitem').count(), 1); assert.equal(await page.getByLabel('Object Label', { exact: true }).inputValue(), 'Workspace box'); assert.equal(await page.locator('.viewport canvas').count(), 1); await page.getByRole('button', { name: 'Position', exact: true }).click(); await page.getByLabel('Position X', { exact: true }).fill('99'); await page.getByRole('button', { name: 'Close task / Cancel', exact: true }).click(); await page.getByRole('button', { name: 'Position', exact: true }).click(); assert.equal(await page.getByLabel('Position X', { exact: true }).inputValue(), '7'); assert.deepEqual(await page.evaluate(() => window.workspaceNativeMessages.at(-1).result.objects[0].position), [7, 0, 0]); await page.getByRole('button', { name: 'Close task / Cancel', exact: true }).click(); await page.getByText('File', { exact: true }).click(); await page.getByRole('button', { name: 'Save document', exact: true }).click(); await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent.includes('OPFS')); await page.getByText('File', { exact: true }).click(); await page.getByText('Files', { exact: true }).click(); await page.getByRole('button', { name: '打开 OPFS 文档', exact: true }).click(); await page.waitForFunction(() => document.querySelector('[role=status]')?.textContent.startsWith('Opened ')); const reopened = await page.evaluate(() => window.workspaceNativeMessages.at(-1).result); assert.equal(reopened.objects[0].label, 'Workspace box'); assert.deepEqual(reopened.objects[0].position, [7, 0, 0]); assert.ok(Math.abs(reopened.objects[0].volume - 1000) < 1e-8); record('menus_toolbars', { menu: 'File > Save document', reopen: 'Files > Open OPFS', cancelledPosition: 99 }, { reopened }, 'Native FCStd saved and reopened through workspace controls preserves edited label, position and volume; cancelled draft never enters document'); await page.getByText('Files', { exact: true }).click(); await page.screenshot({ path: path.join(directory, 'desktop.png'), fullPage: true }); artifact('desktop.png'); await page.setViewportSize({ width: 390, height: 844 }); await page.getByRole('button', { name: 'Position', exact: true }).click(); assert.equal(await page.getByLabel('Position X', { exact: true }).inputValue(), '7'); const layout = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth })); assert.ok(layout.scrollWidth <= layout.width, JSON.stringify(layout)); await page.screenshot({ path: path.join(directory, 'compact.png'), fullPage: true }); artifact('compact.png'); record('resize_keyboard', { keyboard: 'ArrowRight', drag: 40, compact: [390, 844] }, { before, after: before + 60, layout, activeDocument: await activeTab.innerText() }, 'Panels resize with keyboard and pointer; document selection survives tab switches; compact task controls stay within viewport'); artifact('browser-state.json', { cases: result.cases, native: await page.evaluate(() => window.workspaceNativeMessages) }); result.status = 'passed'; } catch (error) { result.error = String(error.stack ?? error); if (page) { artifact('failure-state.json', { body: await page.locator('body').innerText().catch(() => ''), native: await page.evaluate(() => window.workspaceNativeMessages).catch(() => []) }); await page.screenshot({ path: path.join(directory, 'failure.png'), fullPage: true }).then(() => artifact('failure.png')).catch(() => {}); } } finally { await browser?.close(); server?.kill('SIGTERM'); } return result; } async function runProjectInfoCases() { const result = { capabilityId: projectInfoId, status: 'failed', cases: [], evidenceFiles: {} }; const directory = path.join(root, 'docs/current/web-project-info-business', new Date().toISOString().replace(/[:.]/g, '-')); fs.mkdirSync(directory, { recursive: true }); const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] }); const editable = ['Label', 'CreatedBy', 'LastModifiedBy', 'Company', 'Comment', 'Id', 'License', 'LicenseURL']; let browser; try { browser = await playwright().chromium.launch({ executablePath: process.env.FREECAD_CHROME ?? '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] }); const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); page.setDefaultTimeout(60000); const errors = [], wasm = []; page.on('pageerror', error => errors.push(String(error))); page.on('request', request => { if (/FreeCADWeb(DocumentProbe|PartBoxProbe)\.wasm(?:\?|$)/.test(request.url())) wasm.push(request.url()); }); await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://127.0.0.1:5180'}/workbench.html?module=Part&command=part.documentViewCommand&document=ProjectInfoBusiness`); const selectModule = module => page.locator('button.module').filter({ has: page.locator('strong', { hasText: new RegExp(`^${module}$`) }) }).click(); const wait = async () => { await page.waitForFunction(() => !!window.__freecadWorkbenchState?.response && !document.querySelector('button.run')?.disabled); return page.evaluate(() => window.__freecadWorkbenchState.response); }; const clear = () => page.evaluate(() => { if (window.__freecadWorkbenchState) window.__freecadWorkbenchState.response = null; }); const run = async payload => { await page.locator('#payload-editor').fill(JSON.stringify(payload)); await clear(); await page.getByRole('button', { name: '执行选中命令', exact: true }).click(); return wait(); }; const python = async (owner, source) => { await selectModule('Part'); const endpoint = owner === 'DocumentProbe' ? 'part.documentViewCommand' : 'part.viewCommand'; await page.locator('aside:nth-child(2) > button.command').filter({ has: page.getByText(endpoint, { exact: true }) }).click(); const reply = await run({ schema: 'freecad-web/document-view/v1', owner, document: 'ProjectInfoBusiness', command: 'Std_CommandLine', parameters: { operation: 'submit', source: `exec(${JSON.stringify(source)})` } }); assert.equal(reply.kind, 'result', JSON.stringify(reply)); assert.equal(reply.result.stderr, '', JSON.stringify(reply)); return reply.result.stdout.trim() ? JSON.parse(reply.result.stdout.trim()) : null; }; const openTask = async owner => { await selectModule('Web'); await page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: /^Std_ProjectInfo$/ }) }).click(); await page.locator('#payload-editor').fill(JSON.stringify({ documentId: 'ProjectInfoBusiness', runtimeOwner: owner })); }; const click = async name => { await clear(); await page.getByRole('region', { name: 'Project information', exact: true }).getByRole('button', { name, exact: true }).click(); const reply = await wait(); assert.equal(reply.kind, 'result', JSON.stringify(reply)); const value = reply.result.records[0].result; assert.equal(value.documentId, 'ProjectInfoBusiness'); assert.equal(value.command, 'Std_ProjectInfo'); return value; }; const snapshot = ` import json,hashlib d=App.getDocument('ProjectInfoBusiness') print(json.dumps({'properties':{k:str(getattr(d,k)) for k in ${JSON.stringify([...editable, 'FileName', 'CreationDate', 'LastModifiedDate', 'Uid'])}}, 'undo':d.UndoCount,'redo':d.RedoCount,'geometry':hashlib.sha256(d.Box.Shape.exportBrepToString().encode()).hexdigest(), 'other':App.getDocument('ProjectInfoOther').Label})) `; for (const owner of ['DocumentProbe', 'PartBox']) { const before = await python(owner, ` import FreeCAD as App, Part d=App.newDocument('ProjectInfoBusiness'); d.UndoMode=1 b=d.addObject('Part::Box','Box'); b.Length=2; b.Width=3; b.Height=4; d.recompute() other=App.newDocument('ProjectInfoOther'); other.Label='Other project' App.setActiveDocument(d.Name) ${snapshot}`); await openTask(owner); const read = await click('Read project information'); assert.equal(read.changed, false); assert.deepEqual(read.properties, before.properties); await page.getByLabel('Project Label', { exact: true }).fill('Discard this edit'); const cancel = await click('Cancel'); assert.equal(cancel.changed, false); assert.deepEqual(cancel.properties, before.properties); assert.equal(await page.getByLabel('Project Label', { exact: true }).inputValue(), before.properties.Label); assert.deepEqual(await python(owner, snapshot), before); record({ owner, operations: ['read', 'edit-draft', 'cancel'] }, { read, cancel, native: before }, 'Read and cancel preserve native metadata, geometry, other document and undo history'); await openTask(owner); await click('Read project information'); const properties = { Label: 'Project \u5efa\u7b51', CreatedBy: 'Ada', LastModifiedBy: 'Grace', Company: 'Example & Co', Comment: 'First line\nSecond line ', Id: 'project-42', License: 'CC-BY-4.0', LicenseURL: 'https://creativecommons.org/licenses/by/4.0/' }; for (const [key, text] of Object.entries(properties)) await page.getByLabel(`Project ${key}`, { exact: true }).fill(text); const applied = await click('Apply project information'); assert.equal(applied.changed, true); for (const [key, text] of Object.entries(properties)) assert.equal(applied.properties[key], text); const after = await python(owner, snapshot); for (const [key, text] of Object.entries(properties)) assert.equal(after.properties[key], text); assert.equal(after.geometry, before.geometry); assert.equal(after.other, before.other); assert.equal(after.properties.Uid, before.properties.Uid); assert.equal(after.properties.FileName, before.properties.FileName); await openTask(owner); await click('Read project information'); const unchanged = await click('Apply project information'); assert.equal(unchanged.changed, false); assert.deepEqual(await python(owner, snapshot), after); record({ owner, operation: 'apply', properties }, { applied, native: after, unchanged }, 'Editor writes actual document metadata including Unicode and multiline text; repeated apply is a no-op and geometry and other document are preserved'); for (const payload of [ { operation: 'apply', properties: { Label: 'Must not apply', FileName: '/tmp/invalid.FCStd' } }, { operation: 'apply', properties: { Label: 'Must not apply', Comment: 42 } }, { operation: 'apply', properties: { Label: 'bad\u0000value' } }, { operation: 'unknown' }, { operation: 'read', documentId: 'MissingProject' }, ]) { await openTask(owner); const reply = await run({ documentId: 'ProjectInfoBusiness', runtimeOwner: owner, ...payload }); assert.equal(reply.kind, 'error', JSON.stringify(reply)); assert.deepEqual(await python(owner, snapshot), after); record({ owner, ...payload }, reply, 'Invalid metadata and missing documents fail without partial writes or geometry changes'); } const serialized = await python(owner, ` import zipfile,xml.etree.ElementTree as ET d=App.getDocument('ProjectInfoBusiness'); d.saveCopy('/tmp/ProjectInfoBusiness.FCStd') with zipfile.ZipFile('/tmp/ProjectInfoBusiness.FCStd') as archive: xml=ET.fromstring(archive.read('Document.xml')) props=xml.find('Properties') print(json.dumps({p.attrib['name']:list(p)[0].attrib.get('value') for p in props if p.attrib['name'] in ${JSON.stringify(editable)}})) `); for (const key of editable.filter(key => key !== 'LastModifiedBy')) assert.equal(serialized[key], properties[key]); await python(owner, "App.closeDocument('ProjectInfoBusiness'); App.openDocument('/tmp/ProjectInfoBusiness.FCStd')"); const restored = await python(owner, snapshot); // The native file opener derives Label from the basename, while FCStd stores the edited label. assert.equal(restored.properties.Label, 'ProjectInfoBusiness'); for (const key of editable.filter(key => !['Label', 'LastModifiedBy'].includes(key))) assert.equal(restored.properties[key], properties[key]); assert.equal(restored.geometry, before.geometry); assert.equal(restored.other, before.other); await openTask(owner); const reopened = await click('Read project information'); assert.deepEqual(reopened.properties, restored.properties); const task = page.getByRole('region', { name: 'Project information', exact: true }); await page.screenshot({ path: path.join(directory, `${owner}-desktop.png`), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: path.join(directory, `${owner}-mobile.png`), fullPage: true }); const layout = await task.evaluate(e => ({ width: e.clientWidth, scroll: e.scrollWidth })); assert.ok(layout.scroll <= layout.width + 1, JSON.stringify(layout)); await page.setViewportSize({ width: 1440, height: 900 }); for (const mode of ['desktop', 'mobile']) { const file = path.join(directory, `${owner}-${mode}.png`); result.evidenceFiles[path.relative(root, file)] = sha256(file); } record({ owner, operations: ['save-copy', 'inspect-FCStd-XML', 'close', 'reopen', 'read'] }, { serialized, restored, layout }, 'FCStd XML stores edited metadata; native reopening restores metadata with the file basename label and exact BREP; controls fit desktop and mobile'); } assert.deepEqual(errors, []); for (const stem of ['DocumentProbe', 'PartBoxProbe']) assert.equal(wasm.filter(url => url.includes(`FreeCADWeb${stem}.wasm`)).length, 1, JSON.stringify(wasm)); result.status = 'passed'; } catch (error) { result.error = String(error.stack ?? error); } finally { await browser?.close(); fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify(result, null, 2)); } return result; } if (legacyIds.length) { const run = spawnSync(process.execPath, ['tools/web-business-cases.mjs'], { cwd: root, env: { ...process.env, FREECAD_BUSINESS_IDS: JSON.stringify(legacyIds) }, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 180000, }); try { const report = JSON.parse(run.stdout); if (report.schema !== 'freecad-web-business-cases/v1' || !Array.isArray(report.results)) throw Error('Invalid product report'); if (run.status !== 0 && report.results.every(row => row.status === 'passed')) throw Error('Product process failed'); results.push(...report.results); } catch (error) { results.push(...legacyIds.map(capabilityId => ({ capabilityId, status: 'failed', cases: [], error: String(run.error ?? error) + '\n' + run.stderr?.slice(-3000) }))); } } if (manualIds.length) { const selected = [...new Set(manualIds.flatMap(id => mapping[id]))]; const run = spawnSync(process.execPath, ['tools/run_user_manual_tests.mjs'], { cwd: root, env: { ...process.env, FREECAD_MANUAL_IDS: selected.join(',') }, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 600000, }); try { if (run.error || run.signal) throw run.error ?? Error(`Manual process received ${run.signal}`); const summary = JSON.parse(run.stdout.trim().split('\n').at(-1)); const directory = path.resolve(summary.output); if (!directory.startsWith(path.join(root, 'docs/current/user-tests-20260915') + path.sep)) throw Error('Unexpected evidence directory'); const resultFile = path.join(directory, 'results.json'); const report = JSON.parse(fs.readFileSync(resultFile)); if (run.status !== 0 && report.results.every(row => row.status === 'passed')) throw Error('Manual cleanup or setup failed'); const records = new Map(report.results.map(row => [row.id, row])); const files = Object.fromEntries(['results.json', 'environment.json', 'manual-snapshot.md'].map(name => [path.relative(root, path.join(directory, name)), sha256(path.join(directory, name))])); for (const capabilityId of manualIds) { const cases = mapping[capabilityId].map(id => { const record = records.get(id); if (record?.status !== 'passed' || !record.steps?.length) return { input: { manualId: id, command: capabilityId }, actual: record ?? null, assertions: [{ name: 'Required browser workflow completed', passed: false }], }; const evidencePresent = record.steps.every(step => step.evidence && !step.captureError && ['state', 'screenshot'].every(key => { const file = path.join(directory, step.evidence[key] ?? 'missing'); if (!fs.existsSync(file)) return false; files[path.relative(root, file)] = sha256(file); return key !== 'screenshot' || files[path.relative(root, file)] === step.evidence.screenshotSha256; })); return { input: { manualId: id, command: capabilityId, environment: path.relative(root, path.join(directory, 'environment.json')) }, actual: record.steps.map(step => ({ name: step.name, actual: step.actual, evidence: step.evidence })), assertions: [...record.steps.map(step => ({ name: step.expected || step.name, passed: step.status === 'passed' })), { name: 'Screenshots and page-state evidence retained with matching hashes', passed: evidencePresent }] }; }); results.push({ capabilityId, status: cases.every(item => item.assertions.every(a => a.passed)) ? 'passed' : 'failed', cases, evidenceFiles: files, scope: 'Only the mapped sample workflows and explicit assertions; not every command option or every IFC file.' }); } } catch (error) { results.push(...manualIds.map(capabilityId => ({ capabilityId, status: 'failed', cases: [], error: String(error) + '\n' + run.stderr?.slice(-3000) }))); } } await writeBusinessReport({ schema: 'freecad-web-business-cases/v1', results }); process.exit(results.length === ids.length && results.every(row => row.status === 'passed') ? 0 : 1);