Files
workinf_Blender_Wasm/tools/web/run-workflow-case.mjs
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

123 lines
11 KiB
JavaScript

#!/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 <json> --candidate <directory> [--out <directory>] [--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());
}