#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn, spawnSync } from 'node:child_process'; import { atomicJSON, blendFormat, candidateInputs, digest, filesUnder, identity, readJSON, records, reportOutcome, reusable, sameInputs, validatePlan, validateSourceReview } from './workflow-case-lib.mjs'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const args = process.argv.slice(2), option = name => args.includes(name) ? args[args.indexOf(name) + 1] : undefined; if (args.includes('--help') || !option('--plan') || !option('--candidate')) { console.log('Usage: node tools/web/run-workflow-case.mjs --plan --candidate [--out ] [--retry-failed] [--check]'); process.exit(args.includes('--help') ? 0 : 2); } const output = path.resolve(root, option('--out') ?? 'tests/golden/WBV2-P1424-001/workflow-cases'); fs.mkdirSync(output, { recursive: true }); const lock = path.join(output, '.owner.lock'); if (process.env.WBV2_CASE_LOCK !== lock) { const child = spawn('flock', ['--nonblock', '--conflict-exit-code', '75', lock, process.execPath, fileURLToPath(import.meta.url), ...args], { cwd: root, detached: true, stdio: 'inherit', env: { ...process.env, WBV2_CASE_LOCK: lock } }); for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { try { process.kill(-child.pid, signal); } catch {} }); const status = await new Promise(resolve => { child.once('error', error => { console.error(error.message); resolve(1); }); child.once('close', code => resolve(code ?? 1)); }); if (status === 75) console.error('Case output is owned by another live runner.'); process.exit(status); } let activeChild = null, cancelled = false; const batch = { schemaVersion: 1, status: 'NOT_RUN', acceptance: 'NOT_CLAIMED', cases: [], continuationNotes: 'Prepare candidate, runtime, then fixtures.' }; async function runCase(plan, candidate, fixture, blender, port) { blendFormat(fixture.path); const bound = bindInputs(plan, candidate, fixture, blender), directory = path.join(output, plan.batchId, fixture.kind); fs.mkdirSync(directory, { recursive: true }); const latestPath = path.join(directory, 'latest.json'); const previous = fs.existsSync(latestPath) ? readJSON(latestPath) : null; if (previous?.status === 'RUNNING') { const old = readJSON(path.join(previous.attempt, 'state.json')); for (const [pid, start] of [[old.pid, old.identity], [old.childPid, old.childIdentity]]) if (start && identity(pid) === start) throw new Error(`Previous case process ${pid} is still live; no duplicate execution`); } if (previous && reusable(previous, bound.inputHash)) { batch.cases.push({ ...previous, reused: true }); return; } if (args.includes('--check')) { batch.cases.push({ kind: fixture.kind, status: 'PREREQUISITES_READY', ...bound }); return; } if (previous?.status === 'FAILED' && previous.inputHash === bound.inputHash && !args.includes('--retry-failed')) throw new Error('Unchanged failed case: inspect its report; use --retry-failed for an explicit retry'); if (cancelled) throw new Error('CANCELLED'); const attempt = fs.mkdtempSync(path.join(directory, 'attempt-')); const state = { kind: fixture.kind, scope: fixture.scope, status: 'RUNNING', pid: process.pid, identity: identity(process.pid), attempt, ...bound }; atomicJSON(path.join(attempt, 'state.json'), state); atomicJSON(latestPath, state); batch.continuationNotes = `${plan.batchId}/${fixture.kind}: running ${fixture.spec}; inspect ${attempt}/state.json after interruption.`; atomicJSON(path.join(output, 'manifest.json'), batch); const reportPath = path.join(attempt, 'playwright.json'); const env = { ...process.env, WEB_TEST_PORT: port, BLENDER_BIN: blender, WEB_DESKTOP_CANDIDATE_PUBLIC: path.join(candidate, 'public'), WEB_DESKTOP_CANDIDATE_FACTORY: path.join(candidate, 'public/vendor/blender/web_engine.js'), WBV2_CASE_FIXTURE: fixture.path, PLAYWRIGHT_JSON_OUTPUT_FILE: reportPath }; env.WBV2_CASE_ATTEMPT = attempt; env.WBV2_CASE_KIND = fixture.kind; if (args.includes('--packaged')) { env.WBV2_CASE_APP = path.join(candidate, 'app'); env.BLENDER_WEB_HTTP_PORT = port; } const command = [path.join(root, 'web/node_modules/@playwright/test/cli.js'), 'test', '--config', 'playwright.workflow-case.config.ts', fixture.spec, '--grep', fixture.grep, '--workers=1', '--retries=0', `--timeout=${plan.timeoutMs}`, '--reporter=list,json', '--output', path.join(attempt, 'artifacts')]; atomicJSON(path.join(attempt, 'command.json'), { executable: process.execPath, args: command, fixture: fixture.path, inputHash: bound.inputHash }); try { state.process = await execute(command, env, attempt, state, plan.timeoutMs * 4 + 60000); if (state.process.code !== 0 || state.process.timedOut || state.process.cancelled || cancelled) throw new Error('Playwright process failed, timed out or was cancelled'); state.result = reportOutcome(readJSON(reportPath)); if (!state.result.passed) throw new Error('Selected tests did not all pass without skips/retries'); sameInputs(bound, bindInputs(plan, candidate, fixture, blender)); state.status = 'PASS'; } catch (error) { state.status = 'FAILED'; state.error = error.message; } state.finishedAt = new Date().toISOString(); state.acceptance = 'NOT_CLAIMED'; state.artifacts = records(filesUnder(attempt).filter(file => path.basename(file) !== 'state.json')); atomicJSON(path.join(attempt, 'state.json'), state); atomicJSON(path.join(attempt, 'receipt.json'), state); atomicJSON(latestPath, state); batch.cases.push(state); if (state.status !== 'PASS') throw new Error(`${fixture.kind} failed: ${state.error}; retained ${attempt}`); } async function main() { const plan = validatePlan(readJSON(path.resolve(root, option('--plan')))); const candidate = path.resolve(root, option('--candidate')), blender = path.resolve(root, option('--blender') ?? process.env.BLENDER_BIN ?? 'build_blender_5.2.0/bin/blender'); candidateInputs(candidate); batch.batchId = plan.batchId; batch.workflow = plan.workflow; batch.remainingGates = plan.remainingGates; if (plan.nativeSession) fs.accessSync(blender, fs.constants.X_OK); const chrome = process.env.CHROME_PATH ?? spawnSync('which', ['google-chrome'], { encoding: 'utf8' }).stdout?.trim(); if (!chrome) throw new Error('CHROME_PATH or local google-chrome required; no browser download is performed'); fs.accessSync(chrome, fs.constants.X_OK); process.env.CHROME_PATH = chrome; const browserVersion = spawnSync(chrome, ['--version'], { encoding: 'utf8', timeout: 10000 }); if (browserVersion.status !== 0 || typeof browserVersion.stdout !== 'string' || !browserVersion.stdout.trim()) throw new Error('Cannot identify local Chromium version'); process.env.WBV2_CASE_BROWSER_VERSION = browserVersion.stdout.trim(); const port = option('--port') ?? '5288'; if (!/^\d+$/.test(port) || Number(port) < 1024 || Number(port) > 65535) throw new Error('port must be an integer from 1024 to 65535'); for (const fixture of plan.fixtures) await runCase(plan, candidate, { ...fixture, path: path.resolve(root, fixture.kind === 'production' ? option('--production') ?? fixture.path : fixture.path) }, blender, port); batch.status = args.includes('--check') ? 'PREREQUISITES_READY' : 'PASS_SELECTED_TESTS'; } const stop = () => { cancelled = true; if (activeChild) { const child = activeChild; try { process.kill(-child.pid, 'SIGTERM'); } catch {} setTimeout(() => { if (activeChild === child) { try { process.kill(-child.pid, 'SIGKILL'); } catch {} } }, 10000).unref(); } }; process.once('SIGINT', stop); process.once('SIGTERM', stop); function bindInputs(plan, candidate, fixture, blender) { const files = [...candidateInputs(candidate), fixture.path, path.join(root, 'web', fixture.spec), ...plan.sourcePaths.map(p => path.resolve(root, p)), ...filesUnder(path.join(root, 'web/app/src')), ...filesUnder(path.join(root, 'web/tests/fixtures')), ...['tools/web/workflow-case-lib.mjs', 'tools/web/run-workflow-case.mjs', 'web/playwright.workflow-case.config.ts', 'web/playwright.config.ts', 'web/app/vite.config.ts', 'web/package-lock.json'].map(p => path.join(root, p))]; if (plan.nativeSession) files.push(blender); if (plan.sourceReview) { const file = path.resolve(root, plan.sourceReview), hashFile = p => digest(fs.readFileSync(path.resolve(root, p))); const review = validateSourceReview(readJSON(file), hashFile); if (review.batchId !== plan.batchId) throw new Error('Source review belongs to another batch'); files.push(file, ...review.bindings.map(ref => path.resolve(root, ref.path))); batch.sourceReview = { path: file, sha256: hashFile(file), contracts: review.contracts.length, verifiedUnitDelta: 0 }; } if (args.includes('--packaged')) files.push(...filesUnder(path.join(candidate, 'app'))); const inputs = records(files); return { inputs, inputHash: digest(JSON.stringify({ plan, fixture, inputs, serving: args.includes('--packaged') ? 'packaged' : 'source', node: process.version, browser: process.env.WBV2_CASE_BROWSER_VERSION })) }; } async function execute(commandArgs, env, attempt, state, timeout) { const fd = fs.openSync(path.join(attempt, 'process.log'), 'a'); const child = spawn(process.execPath, commandArgs, { cwd: path.join(root, 'web'), env, stdio: ['ignore', fd, fd], detached: true }); activeChild = child; state.childPid = child.pid ?? null; state.childIdentity = identity(child.pid); atomicJSON(path.join(attempt, 'state.json'), state); fs.closeSync(fd); let timedOut = false; const deadline = setTimeout(() => { timedOut = true; try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, timeout); const outcome = await new Promise(resolve => { child.once('error', error => resolve({ code: null, error: error.message })); child.once('close', (code, signal) => resolve({ code, signal })); }); clearTimeout(deadline); activeChild = null; return { ...outcome, timedOut, cancelled }; } try { await main(); batch.continuationNotes = 'Only the configured checks are recorded; use the project evaluator and remaining batch gates before acceptance.'; } catch (error) { batch.status = 'BLOCKED'; batch.error = error.message; batch.continuationNotes = `Stop dependent cases. Resolve: ${error.message}`; process.exitCode = 1; } finally { atomicJSON(path.join(output, 'manifest.json'), batch); console.log(`${batch.status}: ${path.join(output, 'manifest.json')}`); } if (!args.includes('--check')) { const evaluated = spawnSync(process.execPath, [path.join(root, 'tools/web/evaluate-wbv2-completion.mjs'), '--case-manifest', path.join(output, 'manifest.json')], { cwd: root, encoding: 'utf8', maxBuffer: 1024 * 1024 }); fs.writeFileSync(path.join(output, 'evaluation.log'), `${evaluated.stdout ?? ''}\n${evaluated.stderr ?? ''}`); if (evaluated.status !== 0) { process.exitCode = 1; console.error('Case evidence retained; progress evaluation failed. See evaluation.log.'); } else console.log(evaluated.stdout.trim()); }