import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { spawnSync } from 'node:child_process' import { resolve } from 'node:path' import { unzipSync } from 'fflate' import { createWebCadFacade } from '../src/facade/mockFacade' const root = resolve(new URL('..', import.meta.url).pathname) const executable = process.env.FREECAD_CMD || resolve(root, '.cache/freecad/install-desktop/bin/FreeCADCmd') const sysroot = resolve(root, '.cache/freecad/sysroot') const scriptPath = resolve(root, 'scripts/freecad-property-heatflux-roundtrip.py') const outputDirectory = resolve(root, '.cache/freecad/property-heatflux-roundtrip') const nativePath = resolve(outputDirectory, 'native-initial.FCStd') const webPath = resolve(outputDirectory, 'web-edited.FCStd') const resavedPath = resolve(outputDirectory, 'native-resaved.FCStd') const reportPath = resolve(root, 'config/freecad-property-heatflux-roundtrip.json') if (!existsSync(executable)) throw new Error(`FreeCAD PropertyHeatFlux roundtrip executable is missing: ${executable}`) await mkdir(outputDirectory, { recursive: true }) const runNative = (mode: 'create' | 'verify', path: string, resaved = '') => { const execution = spawnSync(executable, ['--python-path', resolve(sysroot, 'usr/lib/python3/dist-packages'), scriptPath], { cwd: root, encoding: 'utf8', timeout: 120_000, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, FREECAD_PROPERTY_HEATFLUX_ROUNDTRIP_MODE: mode, FREECAD_PROPERTY_HEATFLUX_ROUNDTRIP_PATH: path, FREECAD_PROPERTY_HEATFLUX_ROUNDTRIP_RESAVED_PATH: resaved, PYTHONPATH: `${resolve(sysroot, 'usr/lib/python3/dist-packages')}${process.env.PYTHONPATH ? `:${process.env.PYTHONPATH}` : ''}`, LD_LIBRARY_PATH: `${resolve(sysroot, 'usr/lib/x86_64-linux-gnu')}${process.env.LD_LIBRARY_PATH ? `:${process.env.LD_LIBRARY_PATH}` : ''}`, }, }) const output = `${execution.stdout || ''}\n${execution.stderr || ''}` const marker = 'FREECAD_PROPERTY_HEATFLUX_ROUNDTRIP_RESULT=' const line = output.split(/\r?\n/).find((candidate) => candidate.includes(marker)) if (execution.error || execution.status !== 0 || !line) throw new Error(`FreeCAD PropertyHeatFlux ${mode} probe failed with status ${execution.status}: ${execution.error?.message || output.trim()}`) return JSON.parse(line.slice(line.indexOf(marker) + marker.length)) } const native = runNative('create', nativePath) const nativeBytes = new Uint8Array(await readFile(nativePath)) const facade = createWebCadFacade({ runtimeMode: 'mock' }) const inspectedBefore = facade.project.fcstd.inspect(nativeBytes) const editedBytes = facade.project.fcstd.rewriteNumeric(nativeBytes, { objectName: 'HeatFluxProbe', propertyName: 'DFlux', typeId: 'App::PropertyHeatFlux', value: 500, expectedValue: 0 }) await writeFile(webPath, editedBytes) const inspectedAfter = facade.project.fcstd.inspect(editedBytes) const initialFiles = unzipSync(nativeBytes) const editedFiles = unzipSync(editedBytes) const opaquePaths = Object.keys(initialFiles).filter((path) => path.toLowerCase() !== 'document.xml') const opaqueEntriesPreserved = opaquePaths.every((path) => Buffer.from(initialFiles[path]).equals(Buffer.from(editedFiles[path] ?? new Uint8Array()))) const normalizeInspection = (inspection: typeof inspectedBefore) => inspection.objects.map((object) => ({ ...object, properties: object.properties.map((property) => object.name === 'HeatFluxProbe' && property.name === 'DFlux' ? { ...property, value: '' } : property) })) const semanticObjectsPreserved = JSON.stringify(normalizeInspection(inspectedBefore)) === JSON.stringify(normalizeInspection(inspectedAfter)) const nativeAfter = runNative('verify', webPath, resavedPath) const resavedBytes = new Uint8Array(await readFile(resavedPath)) const nativeValueMatches = [nativeAfter.result.reopened, nativeAfter.result.resaved].every((snapshot) => snapshot.heatFlux.property.value.numeric === 500) const report = { schemaVersion: 1, status: 'pass', baselineId: 'freecad-1.1.1-property-heatflux-roundtrip', freecadVersion: native.freecadVersion, gitCommit: native.gitCommit, archives: { nativeInitial: { path: '.cache/freecad/property-heatflux-roundtrip/native-initial.FCStd', bytes: nativeBytes.byteLength, sha256: createHash('sha256').update(nativeBytes).digest('hex') }, webEdited: { path: '.cache/freecad/property-heatflux-roundtrip/web-edited.FCStd', bytes: editedBytes.byteLength, sha256: createHash('sha256').update(editedBytes).digest('hex') }, nativeResaved: { path: '.cache/freecad/property-heatflux-roundtrip/native-resaved.FCStd', bytes: resavedBytes.byteLength, sha256: createHash('sha256').update(resavedBytes).digest('hex') }, }, nativeInitial: native.result, web: { before: inspectedBefore.objects.find(({ name }) => name === 'HeatFluxProbe')?.properties.find(({ name }) => name === 'DFlux'), after: inspectedAfter.objects.find(({ name }) => name === 'HeatFluxProbe')?.properties.find(({ name }) => name === 'DFlux'), objectSetPreserved: inspectedBefore.objects.map(({ name, typeId }) => `${name}:${typeId}`).join('|') === inspectedAfter.objects.map(({ name, typeId }) => `${name}:${typeId}`).join('|'), semanticObjectsPreserved, opaquePathsPreserved: opaquePaths.length, opaqueEntriesPreserved, }, nativeAfter: nativeAfter.result, classification: { webValue: Number(inspectedAfter.objects.find(({ name }) => name === 'HeatFluxProbe')?.properties.find(({ name }) => name === 'DFlux')?.value), reopenedValue: nativeAfter.result.reopened.heatFlux.property.value.numeric, resavedValue: nativeAfter.result.resaved.heatFlux.property.value.numeric, nativeValueMatches, unknownSemanticDrift: !(opaqueEntriesPreserved && semanticObjectsPreserved && nativeValueMatches), zeroUnknownDrift: opaqueEntriesPreserved && semanticObjectsPreserved && nativeValueMatches, }, } await facade.project.dispose() await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`) console.log(JSON.stringify({ status: report.status, output: 'config/freecad-property-heatflux-roundtrip.json', classification: report.classification, opaqueEntriesPreserved: report.web.opaqueEntriesPreserved }, null, 2))