import { createHash } from 'node:crypto' import { execFileSync, spawnSync } from 'node:child_process' import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' const root = resolve(new URL('..', import.meta.url).pathname) const sha256 = (value) => createHash('sha256').update(value).digest('hex') const run = (command, args, options = {}) => { const result = spawnSync(command, args, { encoding: 'utf8', ...options }) if (result.error || result.status !== 0) throw new Error(`${command} failed: ${result.error?.message || result.stderr || result.stdout}`) return result } const artifactInfo = async (path) => { const bytes = await readFile(resolve(root, path)) const metadata = await stat(resolve(root, path)) return { bytes: bytes.byteLength, sha256: sha256(bytes), mode: metadata.mode.toString(8) } } const camoticsRevision = execFileSync('git', ['-C', resolve(root, 'CAMotics'), 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() const cbangRevision = execFileSync('git', ['-C', resolve(root, '.cache/camotics-native/cbang'), 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() const camoticsVersionProbe = run(resolve(root, 'CAMotics/camotics'), ['--version']) const tplVersionProbe = run(resolve(root, 'CAMotics/tplang'), ['--version']) const camoticsVersion = `${camoticsVersionProbe.stdout}${camoticsVersionProbe.stderr}`.trim() const tplVersion = `${tplVersionProbe.stdout}${tplVersionProbe.stderr}`.trim() if (camoticsVersion !== tplVersion) throw new Error(`CAMotics/TPL version mismatch: ${camoticsVersion}/${tplVersion}`) const tplTests = run(resolve(root, 'CAMotics/tests/testHarness'), ['--no-color'], { cwd: resolve(root, 'CAMotics/tests/tplTests') }) const passed = Number(tplTests.stdout.match(/^Passed\s+(\d+)/m)?.[1] ?? -1) const failed = Number(tplTests.stdout.match(/^Failed\s+(\d+)/m)?.[1] ?? -1) if (passed < 0 || failed < 0) throw new Error('Could not parse CAMotics TPL test output.') const tplExample = run(resolve(root, 'CAMotics/tplang'), [resolve(root, 'CAMotics/examples/box/box.tpl')]) const tplExampleBytes = Buffer.from(tplExample.stdout) const guiDir = await mkdtemp(join(tmpdir(), 'camotics-native-gui-')) let guiSmoke try { const gui = spawnSync('xvfb-run', ['-a', '-s', '-screen 0 1280x800x24 +extension GLX', 'bash', '-lc', ` set -u smoke_dir="$CAMOTICS_SMOKE_DIR" export XDG_CONFIG_HOME="$smoke_dir/config" XDG_CACHE_HOME="$smoke_dir/cache" XDG_DATA_HOME="$smoke_dir/data" LIBGL_ALWAYS_SOFTWARE=1 mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" "$CAMOTICS_BINARY" >"$smoke_dir/stdout" 2>"$smoke_dir/stderr" & pid=$! found=0 for _ in $(seq 1 100); do if xdotool search --onlyvisible --name "^CAMotics$" >/dev/null 2>&1; then found=1; break; fi sleep 0.1 done if [ "$found" -eq 1 ]; then wid=$(xdotool search --onlyvisible --name "^CAMotics$" | head -n1) xwininfo -id "$wid" >"$smoke_dir/xwininfo" fi alive=0 kill -0 "$pid" 2>/dev/null && alive=1 kill -TERM "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true printf '%s\n' "$found" >"$smoke_dir/found" printf '%s\n' "$alive" >"$smoke_dir/alive" `], { encoding: 'utf8', env: { ...process.env, CAMOTICS_SMOKE_DIR: guiDir, CAMOTICS_BINARY: resolve(root, 'CAMotics/camotics') }, timeout: 30_000, }) if (gui.error || gui.status !== 0) throw new Error(`Xvfb GUI smoke failed: ${gui.error?.message || gui.stderr || gui.stdout}`) const found = (await readFile(join(guiDir, 'found'), 'utf8')).trim() === '1' const alive = (await readFile(join(guiDir, 'alive'), 'utf8')).trim() === '1' const startupStdout = await readFile(join(guiDir, 'stdout'), 'utf8') const startupStderr = await readFile(join(guiDir, 'stderr'), 'utf8') const xwininfo = found ? await readFile(join(guiDir, 'xwininfo'), 'utf8') : '' const numberFrom = (label) => Number(xwininfo.match(new RegExp(`^ ${label}:\\s+(\\d+)`, 'm'))?.[1] ?? -1) const mapState = xwininfo.match(/^ Map State:\s+(.+)$/m)?.[1]?.trim() ?? '' guiSmoke = { status: found && alive && mapState === 'IsViewable' && !/Fatal error|FATAL/i.test(startupStderr) ? 'pass' : 'fail', processAliveBeforeTerminate: alive, browserRuntime: false, window: { title: xwininfo.match(/^xwininfo: Window id: \S+ "([^"]+)"$/m)?.[1] ?? '', width: numberFrom('Width'), height: numberFrom('Height'), depth: numberFrom('Depth'), mapState }, startupBannerSha256: sha256(Buffer.from(startupStdout)), startupStderrSha256: sha256(Buffer.from(startupStderr)), } } finally { await rm(guiDir, { recursive: true, force: true }) } if (guiSmoke.status !== 'pass') throw new Error(`CAMotics GUI smoke did not pass: ${JSON.stringify(guiSmoke)}`) const artifacts = {} for (const path of ['CAMotics/camotics', 'CAMotics/tplang']) artifacts[path] = await artifactInfo(path) const cliArtifacts = {} for (const path of ['CAMotics/camsim', 'CAMotics/gcodetool', 'CAMotics/planner', 'CAMotics/build/camotics.so']) cliArtifacts[path] = await artifactInfo(path) const cliSmoke = {} for (const path of ['CAMotics/camsim', 'CAMotics/gcodetool', 'CAMotics/planner']) { const probe = run(resolve(root, path), ['--help']) cliSmoke[path] = { exitCode: probe.status, containsUsage: `${probe.stdout}${probe.stderr}`.includes('Usage:') } } const config = { schemaVersion: 2, status: 'pass', source: { path: 'CAMotics', repository: 'https://github.com/CauldronDevelopmentLLC/CAMotics.git', revision: camoticsRevision, version: camoticsVersion, }, cbang: { path: '.cache/camotics-native/cbang', repository: 'https://github.com/CauldronDevelopmentLLC/cbang.git', revision: cbangRevision, version: '1.7.2', v8PointerCompression: false, }, build: { mode: 'release-native-qt5-tpl-cli', compiler: execFileSync('g++', ['--version'], { encoding: 'utf8' }).split('\n')[0], sconsVersion: execFileSync('scons', ['--version'], { encoding: 'utf8' }).split('\n')[0], nodeV8PackageVersion: execFileSync('dpkg-query', ['-W', '-f=${Version}', 'libnode-dev'], { encoding: 'utf8' }).trim(), qtVersion: execFileSync('qmake', ['-query', 'QT_VERSION'], { encoding: 'utf8' }).trim(), options: ['strict=0', 'cxxstd=c++17', 'v8_compress_pointers=0'], withGui: true, withTpl: true, browserExecutable: false, }, artifacts, cliArtifacts, cliSmoke, tplTests: { command: 'cd CAMotics/tests/tplTests && ../testHarness --no-color', passed, failed, outputSha256: sha256(Buffer.from(tplTests.stdout)) }, tplExample: { input: 'CAMotics/examples/box/box.tpl', bytes: tplExampleBytes.byteLength, lines: tplExample.stdout.trimEnd().split('\n').length, sha256: sha256(tplExampleBytes) }, guiSmoke, pipelineBoundary: { workspaceNativeGuiAvailable: true, workspaceNativeTplAvailable: true, workspaceNativeCliAvailable: true, browserNativeGuiAvailable: false, browserNativeTplAvailable: false, nativeRole: 'host-native-qt5-tpl-cli-sidecar', generationAuthority: 'camotics-source-stage', canonicalGcodeParser: 'linuxcnc-wasm', camoticsParsesCanonicalPipelineGcode: false, order: ['cad', 'opencamlib-wasm', 'camotics-source-stage', 'gcode', 'linuxcnc-wasm-parse-execute'], }, } await writeFile(resolve(root, 'config/camotics-native-artifact.json'), `${JSON.stringify(config, null, 2)}\n`) console.log(JSON.stringify(config, null, 2))