163 lines
13 KiB
JavaScript
163 lines
13 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
|
|
import { playwright } from './part-primitive-business-cases.mjs';
|
|
|
|
export const newDocumentId = 'Part:desktop:Part_NewDoc';
|
|
|
|
export async function runNewDocumentCases(ids) {
|
|
if (!ids.includes(newDocumentId)) return [];
|
|
const result = { capabilityId: newDocumentId, status: 'failed', cases: [], timing: [], evidenceFiles: {} };
|
|
const directory = path.resolve('docs/current/part-new-document-business', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'part-new-document-'));
|
|
const stage = name => { result.timing.push({ name, at: new Date().toISOString() }); process.stderr.write(`Part NewDoc: ${name}\n`); };
|
|
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
|
|
const volume = (actual, expected) => assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) <= 1e-9 * Math.max(1, Math.abs(expected)),
|
|
`Native volume ${actual} differs from expected ${expected}`);
|
|
let kernel, browser;
|
|
try {
|
|
execFileSync(path.resolve('web/node_modules/.bin/tsc'), ['--target', 'ES2022', '--module', 'ESNext', '--moduleResolution', 'Bundler',
|
|
'--strict', '--skipLibCheck', '--outDir', temporary, 'web/node_modules/vite/client.d.ts', 'web/src/part-desktop-adapter.ts'], { stdio: 'pipe' });
|
|
const { executePartFixedBoxDesktop } = await import(pathToFileURL(path.join(temporary, 'part-desktop-adapter.js')));
|
|
stage('native-load-enter');
|
|
({ kernel } = await loadWasmProbe(process.env.FREECAD_PART_DOCUMENT_BUSINESS_BUILD ?? 'web/public/kernel', 'FreeCADWebDocumentProbe'));
|
|
stage('native-load-complete');
|
|
const raw = (cmd, args) => JSON.parse(kernel.ccall('fc_web_dispatch_json', 'string', ['string'], [JSON.stringify({ cmd, args })]));
|
|
const doc = (document, action, args = {}) => {
|
|
const reply = raw('part.document', { document, action, ...args });
|
|
assert.equal(reply.ok, true, JSON.stringify(reply)); return reply.result;
|
|
};
|
|
const router = { request: async (module, cmd, args) => {
|
|
assert.equal(module, 'Part'); assert.ok(['part.document', 'part.documentViewCommand'].includes(cmd));
|
|
const reply = raw(cmd, args);
|
|
return reply.ok ? { kind: 'result', requestId: 'new-doc', result: reply.result }
|
|
: { kind: 'error', requestId: 'new-doc', error: { code: reply.code, message: reply.error } };
|
|
} };
|
|
doc('RetainedPart', 'create'); doc('RetainedPart', 'setBox', { object: 'Existing', dimensions: [2, 3, 4] });
|
|
const before = doc('RetainedPart', 'snapshot'); let selection;
|
|
const execute = (args = {}, current = 'RetainedPart') => executePartFixedBoxDesktop(router, 'Part_NewDoc', args, current,
|
|
(documentId, objects) => { selection = { documentId, objects }; });
|
|
const createdNames = [];
|
|
for (const args of [{ name: 'PartNew' }, { name: 'PartNew' }, {}, {}]) {
|
|
const reply = await execute(args); assert.equal(reply.kind, 'result', JSON.stringify(reply));
|
|
const state = reply.result;
|
|
assert.equal(state.objectCount, 0); assert.deepEqual(state.objects, []);
|
|
assert.equal(state.undoCount, 0); assert.equal(state.redoCount, 0);
|
|
assert.deepEqual(selection, { documentId: state.document, objects: [] });
|
|
assert.ok(!createdNames.includes(state.document)); createdNames.push(state.document);
|
|
assert.deepEqual(doc('RetainedPart', 'snapshot'), before);
|
|
const active = raw('part.documentViewCommand', { owner: 'DocumentProbe', document: state.document,
|
|
command: 'Std_CommandLine', parameters: { operation: 'submit', source: 'print(App.ActiveDocument.Name)' } });
|
|
assert.equal(active.ok, true, JSON.stringify(active)); assert.equal(active.result.stderr, '');
|
|
assert.equal(active.result.stdout.trim(), state.document);
|
|
record(args, { document: state.document, selection, active: active.result.stdout.trim() },
|
|
'NewDoc creates a distinct empty native active document, clears selection and preserves existing document and history');
|
|
}
|
|
const current = createdNames[0];
|
|
for (const args of [{ name: '' }, { name: '../invalid' }, { name: 12 }, { activeTask: true }, { taskActive: true },
|
|
{ action: 'cancel' }, { document: 'Other' }, { documentId: 'Other' }, { runtimeOwner: 'PartBox' }]) {
|
|
let calls = 0;
|
|
const reply = await executePartFixedBoxDesktop({ request: () => { calls++; throw Error('invalid request reached native'); } },
|
|
'Part_NewDoc', args, current, () => { throw Error('invalid request changed selection'); });
|
|
assert.equal(reply.kind, 'error'); assert.equal(calls, 0); assert.deepEqual(doc('RetainedPart', 'snapshot'), before);
|
|
record(args, reply.error, 'Invalid and cancelled requests do not dispatch mutations or change selection');
|
|
}
|
|
let state = doc(current, 'setBox', { object: 'NewBox', dimensions: [4, 5, 6] });
|
|
volume(state.objects[0].volume, 120); assert.equal(state.undoCount, 1);
|
|
assert.equal(doc(current, 'undo').objectCount, 0); volume(doc(current, 'redo').objects[0].volume, 120);
|
|
const filename = '/tmp/PartNew.FCStd'; state = doc(current, 'save', { path: filename }); assert.ok(state.size > 0);
|
|
const savedBytes = kernel.FS.readFile(filename); assert.deepEqual([...savedBytes.slice(0, 2)], [80, 75]);
|
|
doc(current, 'close'); state = doc(current, 'open', { path: filename }); volume(state.objects[0].volume, 120);
|
|
volume(doc(current, 'setBox', { object: 'NewBox', dimensions: [5, 6, 7] }).objects[0].volume, 210);
|
|
volume(doc(current, 'undo').objects[0].volume, 120); state = doc(current, 'redo'); volume(state.objects[0].volume, 210);
|
|
assert.deepEqual(doc('RetainedPart', 'snapshot'), before);
|
|
record({ document: current, operations: ['create-box', 'undo', 'redo', 'save', 'close', 'open', 'edit', 'undo', 'redo'] },
|
|
{ bytes: savedBytes.length, volume: state.objects[0].volume }, 'New native documents support parametric edits, history and editable FCStd restoration');
|
|
stage('native-workflow-complete'); kernel.PThread?.terminateAllThreads(); kernel = undefined;
|
|
stage('browser-enter');
|
|
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: 1000 } });
|
|
page.setDefaultTimeout(20000); const errors = []; page.on('pageerror', error => errors.push(String(error)));
|
|
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://127.0.0.1:5180'}/workbench.html?module=Part&command=Part_NewDoc`);
|
|
const selectDesktop = async command => page.locator('.matrix-list button').filter({ has: page.locator('span', { hasText: new RegExp(`^${command}$`) }) }).click();
|
|
const waitResult = async () => {
|
|
await page.waitForFunction(() => !!window.__freecadWorkbenchState?.response && !document.querySelector('button.run')?.disabled);
|
|
const reply = await page.evaluate(() => window.__freecadWorkbenchState.response);
|
|
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
|
|
};
|
|
const clear = () => page.evaluate(() => { 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 waitResult();
|
|
};
|
|
await selectDesktop('Part_NewDoc'); await clear();
|
|
await page.getByRole('button', { name: 'New Part document', exact: true }).click();
|
|
const first = await waitResult(); assert.equal(first.objectCount, 0);
|
|
assert.equal(JSON.parse(await page.locator('#payload-editor').inputValue()).documentId, first.document);
|
|
await selectDesktop('Part_Box'); const box = await run({ document: first.document }); volume(box.objects[0].volume, 1000);
|
|
await selectDesktop('Part_NewDoc'); await clear();
|
|
await page.getByRole('button', { name: 'New Part document', exact: true }).click();
|
|
const second = await waitResult(); assert.notEqual(second.document, first.document); assert.equal(second.objectCount, 0);
|
|
assert.equal(JSON.parse(await page.locator('#payload-editor').inputValue()).documentId, second.document);
|
|
const api = async command => page.locator('aside:nth-child(2) > button.command').filter({ has: page.getByText(command, { exact: true }) }).click();
|
|
await api('part.document');
|
|
const payload = (document, action, args = {}) => ({ schema: 'freecad-web-part/document/v1', module: 'Part', document, action, ...args });
|
|
volume((await run(payload(first.document, 'snapshot'))).objects[0].volume, 1000);
|
|
volume((await run(payload(second.document, 'setBox', { object: 'Editable', dimensions: [3, 4, 5] }))).objects[0].volume, 60);
|
|
assert.equal((await run(payload(second.document, 'undo'))).objectCount, 0);
|
|
volume((await run(payload(second.document, 'redo'))).objects[0].volume, 60);
|
|
await run(payload(second.document, 'save', { path: '/tmp/NewDocBrowser.FCStd' }));
|
|
const bytes = await page.evaluate(async () => {
|
|
const bytes = window.__freecadWorkbenchState.response.result.bytes;
|
|
if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) throw Error('Native FCStd bytes missing');
|
|
const storage = await import('/src/storage.ts'); await storage.writeDocumentFile('NewDocBrowser.FCStd', bytes);
|
|
return [...new Uint8Array(await storage.readDocumentFile('NewDocBrowser.FCStd'))];
|
|
});
|
|
assert.deepEqual(bytes.slice(0, 2), [80, 75]);
|
|
await page.screenshot({ path: path.join(directory, 'desktop.png'), fullPage: true });
|
|
await page.reload(); await api('part.document');
|
|
// ArrayBuffer cannot be expressed in the JSON editor; reopen through the public router.
|
|
const restored = await page.evaluate(async () => {
|
|
const { WebModuleRouter } = await import('/src/module-router.ts');
|
|
const { readDocumentFile } = await import('/src/storage.ts');
|
|
const router = new WebModuleRouter();
|
|
try {
|
|
const request = (action, extra = {}) => router.request('Part', 'part.document', {
|
|
schema: 'freecad-web-part/document/v1', module: 'Part', document: 'NewDocRestored', action, ...extra,
|
|
}, 'NewDocRestored');
|
|
const opened = await request('open', { path: '/tmp/Restored.FCStd', bytes: await readDocumentFile('NewDocBrowser.FCStd') });
|
|
const edited = await request('setBox', { object: 'Editable', dimensions: [4, 5, 6] });
|
|
const undo = await request('undo'); const redo = await request('redo'); return { opened, edited, undo, redo };
|
|
} finally { router.dispose(); }
|
|
});
|
|
for (const reply of Object.values(restored)) assert.equal(reply.kind, 'result', JSON.stringify(reply));
|
|
volume(restored.opened.result.objects[0].volume, 60); volume(restored.edited.result.objects[0].volume, 120);
|
|
volume(restored.undo.result.objects[0].volume, 60); volume(restored.redo.result.objects[0].volume, 120);
|
|
await selectDesktop('Part_NewDoc'); await page.setViewportSize({ width: 390, height: 844 }); await clear();
|
|
await page.getByRole('button', { name: 'New Part document', exact: true }).click();
|
|
const mobile = await waitResult(); assert.equal(mobile.objectCount, 0);
|
|
const layout = await page.getByRole('region', { name: 'New Part document', exact: true }).evaluate(e => ({ width: e.clientWidth, scroll: e.scrollWidth }));
|
|
assert.ok(layout.scroll <= layout.width + 1, JSON.stringify(layout));
|
|
await page.screenshot({ path: path.join(directory, 'mobile.png'), fullPage: true }); assert.deepEqual(errors, []);
|
|
for (const name of ['desktop.png', 'mobile.png']) {
|
|
const file = path.join(directory, name); result.evidenceFiles[path.relative(process.cwd(), file)] = createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
}
|
|
record({ operations: ['mounted-new', 'desktop-box', 'mounted-new-again', 'edit', 'undo', 'redo', 'OPFS-save', 'reload', 'open', 'edit', 'mobile-new'] },
|
|
{ documents: [first.document, second.document, mobile.document], bytes: bytes.length, restored, layout },
|
|
'Mounted desktop and mobile NewDoc controls activate independent editable documents; OPFS FCStd bytes survive reload and retain native history after reopening');
|
|
stage('browser-complete'); result.status = 'passed';
|
|
} catch (error) { result.error = [String(error.stack ?? error), error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join('\n'); }
|
|
finally {
|
|
await browser?.close(); kernel?.PThread?.terminateAllThreads(); fs.rmSync(temporary, { recursive: true, force: true });
|
|
stage('cleanup-complete'); fs.writeFileSync(path.join(directory, 'result.json'), JSON.stringify(result, null, 2));
|
|
}
|
|
return [result];
|
|
}
|