114 lines
5.8 KiB
JavaScript
Executable File
114 lines
5.8 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/* Validate the machine-readable playable report and, when requested, seal it
|
|
* with the parent process' exit/log gate. The client cannot mark a run PASS by
|
|
* itself: the final report is written only after the child has exited. */
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const VALID = new Set(['PASS', 'FAIL', 'BLOCKED', 'SKIP']);
|
|
const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/m;
|
|
|
|
function usage() {
|
|
console.error('usage: validate_playable_report.mjs --report PATH [--output PATH] [--log PATH] [--process-code N] [--run-id ID] [--require-pass]');
|
|
}
|
|
|
|
function argsOf(argv) {
|
|
const out = { require_pass: false };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === '--require-pass') out.require_pass = true;
|
|
else if (arg.startsWith('--')) {
|
|
const key = arg.slice(2).replaceAll('-', '_');
|
|
if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`);
|
|
out[key] = argv[++i];
|
|
} else throw new Error(`unknown argument: ${arg}`);
|
|
}
|
|
if (!out.report) throw new Error('--report is required');
|
|
return out;
|
|
}
|
|
|
|
function readJson(file) {
|
|
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
catch (error) { return { __read_error: `${file}: ${error.message}` }; }
|
|
}
|
|
|
|
function validate(report, options) {
|
|
const errors = [];
|
|
const readError = report?.__read_error;
|
|
if (readError) errors.push(readError);
|
|
if (!report || typeof report !== 'object' || Array.isArray(report)) {
|
|
errors.push('report root must be an object');
|
|
return errors;
|
|
}
|
|
if (report.schema_version !== 1) errors.push('schema_version must be 1');
|
|
if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.push('run_id is missing');
|
|
if (options.run_id && report.run_id !== options.run_id) errors.push('run_id does not match requested run');
|
|
if (report.suite !== 'playable') errors.push('suite must be playable');
|
|
if (!VALID.has(report.status)) errors.push(`invalid report status: ${report.status}`);
|
|
for (const key of ['engine_sha256', 'extension_sha256', 'pck_sha256', 'arch']) {
|
|
if (typeof report.build?.[key] !== 'string' || report.build[key].trim() === '') errors.push(`build.${key} is missing`);
|
|
}
|
|
if (!Array.isArray(report.cases) || report.cases.length === 0) errors.push('cases must be non-empty');
|
|
const cases = Array.isArray(report.cases) ? report.cases : [];
|
|
let passed = 0;
|
|
for (const item of cases) {
|
|
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || item.id.trim() === '') {
|
|
errors.push('case id is missing');
|
|
continue;
|
|
}
|
|
if (!VALID.has(item.status)) errors.push(`${item.id}: invalid status`);
|
|
if (item.status === 'PASS') passed += 1;
|
|
if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.push(`${item.id}: ${item.status} is not releasable`);
|
|
if (!Array.isArray(item.evidence)) errors.push(`${item.id}: evidence must be an array`);
|
|
for (const evidence of (Array.isArray(item.evidence) ? item.evidence : [])) {
|
|
if (typeof evidence !== 'string' || evidence.trim() === '') errors.push(`${item.id}: empty evidence path`);
|
|
else if (!fs.existsSync(path.resolve(path.dirname(options.report), evidence))) errors.push(`${item.id}: missing evidence ${evidence}`);
|
|
}
|
|
}
|
|
const coverage = report.coverage;
|
|
if (!coverage || coverage.required !== cases.length || coverage.passed !== passed) errors.push('coverage does not match cases');
|
|
if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.push('failures and blocked must be arrays');
|
|
if (!report.exit_gate || report.exit_gate.checked !== true) errors.push('exit_gate.checked must be true in final report');
|
|
if (options.process_code !== undefined && Number(report.exit_gate?.process_code) !== Number(options.process_code)) errors.push('exit_gate.process_code does not match child process');
|
|
if (Number(report.exit_gate?.process_code) !== 0) errors.push('child process did not exit with code 0');
|
|
if (options.log) {
|
|
if (!fs.existsSync(options.log)) errors.push(`log does not exist: ${options.log}`);
|
|
else {
|
|
const lines = fs.readFileSync(options.log, 'utf8').split(/\r?\n/);
|
|
lines.forEach((line, index) => { if (ERROR_RE.test(line)) errors.push(`log:${index + 1}: ${line}`); });
|
|
}
|
|
}
|
|
if (options.require_pass && report.status !== 'PASS') errors.push(`report status is ${report.status}, expected PASS`);
|
|
if (report.status === 'FAIL') errors.push('client report already contains failures');
|
|
return [...new Set(errors)];
|
|
}
|
|
|
|
function main() {
|
|
let options;
|
|
try { options = argsOf(process.argv.slice(2)); }
|
|
catch (error) { usage(); console.error(error.message); return 2; }
|
|
const input = readJson(options.report);
|
|
const report = input.__read_error ? {
|
|
schema_version: 1, run_id: options.run_id || 'missing-report', suite: 'playable', status: 'FAIL',
|
|
build: { engine_sha256: '', extension_sha256: '', pck_sha256: '', arch: '' }, cases: [],
|
|
failures: [input.__read_error], blocked: [], coverage: { required: 0, passed: 0 },
|
|
} : input;
|
|
const exitCode = options.process_code === undefined ? null : Number(options.process_code);
|
|
report.exit_gate = { checked: true, process_code: exitCode, errors: [] };
|
|
const errors = validate(report, options);
|
|
report.exit_gate.errors = errors;
|
|
if (errors.length > 0) report.status = 'FAIL';
|
|
const output = options.output || options.report;
|
|
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`);
|
|
if (errors.length) {
|
|
console.error(`PLAYABLE REPORT: FAIL (${errors.length} errors)`);
|
|
errors.forEach((error) => console.error(`- ${error}`));
|
|
return 1;
|
|
}
|
|
console.log(`PLAYABLE REPORT: PASS ${output}`);
|
|
return 0;
|
|
}
|
|
|
|
process.exitCode = main();
|