368 lines
27 KiB
JavaScript
368 lines
27 KiB
JavaScript
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 <metadata>', 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);
|