增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
197 lines
9.0 KiB
JavaScript
197 lines
9.0 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* run.mjs —— 门金标判例库·全量回归统一入口(零新依赖:仅 Node 内置 node:test / node:child_process)
|
||
*
|
||
* 职责:把散在各判据近旁的验收门「误杀/漏放」判例聚合成一趟可复跑回归,逐 stage 输出
|
||
* 过/挂/跳,给出总计与退出码。**门判据任何改动都必须先过这趟。**
|
||
*
|
||
* 判例不在此处重抄——各 stage 指向判例真身(单一事实源):
|
||
* · shape-gate → game-runtime/tools/amodel-gen/tests/check.test.mjs(判据 tools.mjs _shapeGateErrors)
|
||
* · check-undef → game-runtime/tools/amodel-gen/tests/check-undef.test.mjs(判据 check-undef.mjs;缺 esbuild 时该门降级、单测整体 skip)
|
||
* · latch-synonyms→ contracts/gate-fixtures/cases/latch-terminal-synonyms.test.mjs(判据 play.cdp.cjs isTerminal)
|
||
* · python-gates → tier2/cheap-worker 的门 pytest(判据 gate_judge.py / cheap_gates.py;缺 pytest 时整段 skip)
|
||
*
|
||
* 设计:能跑的必须绿,跑不动的(缺 esbuild / 缺 pytest)诚实 SKIP 而非假绿、也不算失败——
|
||
* 这样在完整 CI 里四段全跑、在裸环境里 Node 核心段仍跑,均可挂载。
|
||
* 退出码:任一 stage 有真实失败(fail>0 或 driven 段异常)→ 1;否则(全过或含 skip)→ 0。
|
||
*
|
||
* 跑法:node contracts/gate-fixtures/run.mjs
|
||
*/
|
||
|
||
import { spawnSync } from 'node:child_process';
|
||
import { existsSync } from 'node:fs';
|
||
import { delimiter, dirname, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
// gate-fixtures → contracts → 仓根
|
||
const REPO_ROOT = resolve(__dirname, '../..');
|
||
const NODE = process.execPath;
|
||
// 服务端 CI 要求四段全部真跑;本地裸环境未装可选依赖时仍保留诚实 SKIP。
|
||
const REQUIRE_ALL = process.env.GATE_FIXTURES_REQUIRE_ALL === '1';
|
||
|
||
/** pytest 是否可用(缺则 python-gates 整段 skip,保持零新依赖)。 */
|
||
function pytestAvailable() {
|
||
const r = spawnSync('python3', ['-c', 'import pytest'], { stdio: 'ignore' });
|
||
return r.status === 0;
|
||
}
|
||
|
||
/** 从 node:test 的 TAP 输出解析计数(# pass N / # fail N / # skipped N)。 */
|
||
function parseNodeTap(out) {
|
||
const grab = (k) => {
|
||
const m = out.match(new RegExp(`^#\\s*${k}\\s+(\\d+)`, 'm'));
|
||
return m ? Number(m[1]) : 0;
|
||
};
|
||
return { tests: grab('tests'), pass: grab('pass'), fail: grab('fail'), skip: grab('skipped') };
|
||
}
|
||
|
||
/** 从 pytest -q 尾行解析计数(如 "3 passed, 1 skipped in 0.1s")。 */
|
||
function parsePytest(out) {
|
||
const grab = (k) => {
|
||
const m = out.match(new RegExp(`(\\d+)\\s+${k}`));
|
||
return m ? Number(m[1]) : 0;
|
||
};
|
||
const fail = grab('failed') + grab('error') + grab('errors');
|
||
return { tests: grab('passed') + grab('skipped') + fail, pass: grab('passed'), fail, skip: grab('skipped') };
|
||
}
|
||
|
||
/** 跑一个 node:test 判例文件,返回结构化结果。 */
|
||
function runNodeStage(stage) {
|
||
const abs = resolve(REPO_ROOT, stage.file);
|
||
if (!existsSync(abs)) {
|
||
return { ...stage, status: 'MISSING', pass: 0, fail: 0, skip: 0, note: `判例文件缺失:${stage.file}` };
|
||
}
|
||
const r = spawnSync(NODE, ['--test', '--test-reporter=tap', abs], { cwd: REPO_ROOT, encoding: 'utf8' });
|
||
const out = `${r.stdout || ''}${r.stderr || ''}`;
|
||
const c = parseNodeTap(out);
|
||
// node --test:退出码 0=无失败;非 0 且解析到 fail>0=真实失败;非 0 但无 fail 计数=进程异常(算失败)。
|
||
let status;
|
||
if (r.status === 0) status = c.pass === 0 && c.skip > 0 ? 'SKIP' : 'PASS';
|
||
else status = c.fail > 0 ? 'FAIL' : 'ERROR';
|
||
const note = status === 'SKIP' ? `${c.skip} 条单测在本环境 skip(判据自带零依赖降级,如缺 esbuild;装齐依赖后转真跑)` : undefined;
|
||
return { ...stage, status, ...c, note, raw: out };
|
||
}
|
||
|
||
/**
|
||
* 跑 python-gates(pytest);strict 模式下固定判例缺少任意一个都立即判红。
|
||
* 可注入根目录与 pytest 探针,供入口自身用真实临时文件验证缺失判例分支。
|
||
*/
|
||
export function runPythonStage(stage, options = {}) {
|
||
const repoRoot = options.repoRoot ?? REPO_ROOT;
|
||
const requireAll = options.requireAll ?? REQUIRE_ALL;
|
||
const hasPytest = options.hasPytest ?? pytestAvailable;
|
||
if (!hasPytest()) {
|
||
return { ...stage, status: 'SKIP', pass: 0, fail: 0, skip: 0, note: '未装 pytest → 整段跳过(零新依赖口径);装后本段覆盖 tier2/cheap 的门' };
|
||
}
|
||
const missing = stage.files.filter((f) => !existsSync(resolve(repoRoot, f)));
|
||
if (requireAll && missing.length > 0) {
|
||
return {
|
||
...stage,
|
||
status: 'MISSING',
|
||
pass: 0,
|
||
fail: 0,
|
||
skip: 0,
|
||
note: `pytest 判例文件缺失:${missing.join(',')}`,
|
||
};
|
||
}
|
||
const files = stage.files.filter((f) => existsSync(resolve(repoRoot, f)));
|
||
if (files.length === 0) return { ...stage, status: 'MISSING', pass: 0, fail: 0, skip: 0, note: 'pytest 判例文件全缺失' };
|
||
// tier2 测试按服务部署布局导入顶层 worker 包;统一门必须复刻该模块根,不能依赖调用者碰巧设置 PYTHONPATH。
|
||
const pythonPath = [resolve(repoRoot, 'tier2/gen-worker'), process.env.PYTHONPATH]
|
||
.filter(Boolean)
|
||
.join(delimiter);
|
||
const r = spawnSync('python3', ['-m', 'pytest', '-q', ...files], {
|
||
cwd: repoRoot,
|
||
encoding: 'utf8',
|
||
env: { ...process.env, PYTHONPATH: pythonPath },
|
||
});
|
||
const out = `${r.stdout || ''}${r.stderr || ''}`;
|
||
const c = parsePytest(out);
|
||
let status;
|
||
if (r.status === 0) status = c.pass === 0 && c.skip > 0 ? 'SKIP' : 'PASS';
|
||
else if (r.status === 5) status = 'SKIP'; // pytest 退出码 5 = 未收集到用例
|
||
else status = c.fail > 0 ? 'FAIL' : 'ERROR';
|
||
return { ...stage, status, ...c, raw: out };
|
||
}
|
||
|
||
// ── stage 清单(判据→判例) ──
|
||
const NODE_STAGES = [
|
||
{
|
||
name: 'shape-gate',
|
||
judge: 'game-runtime/tools/amodel-gen/tools.mjs (_shapeGateErrors 形状静态门)',
|
||
file: 'game-runtime/tools/amodel-gen/tests/check.test.mjs',
|
||
covers: 'base2 drawButton 返 void 被消费 / base3 sceneFsm.define 批量误用 / base1·4·5 合法写法零误杀',
|
||
},
|
||
{
|
||
name: 'check-undef',
|
||
judge: 'game-runtime/tools/amodel-gen/check-undef.mjs (未定义标识符 + import 断链静态扫描)',
|
||
file: 'game-runtime/tools/amodel-gen/tests/check-undef.test.mjs',
|
||
covers: 'gen3 未定义 cy / gen6 未定义 stepUntilMs / 非遗 import 断链三形态 / 8 目录正样本零误报(缺 esbuild→SKIP)',
|
||
},
|
||
{
|
||
name: 'latch-synonyms',
|
||
judge: 'game-runtime/games/_wg1-gen/_shared/play.cdp.cjs (H 门 latch isTerminal 终态判定)',
|
||
file: 'contracts/gate-fixtures/cases/latch-terminal-synonyms.test.mjs',
|
||
covers: 'base5 终态 over/win/lose 被误杀回归 + 非终态反向漂移守卫',
|
||
},
|
||
];
|
||
const PYTHON_STAGE = {
|
||
name: 'python-gates',
|
||
judge: 'tier2/gen-worker/worker/gate_judge.py + cheap-worker/cheap_gates.py',
|
||
files: [
|
||
'tier2/gen-worker/tests/test_gate_judge.py',
|
||
'tier2/gen-worker/tests/test_gate_judge_cheap.py',
|
||
'tier2/gen-worker/tests/test_run_gates_stale_verdict.py',
|
||
'cheap-worker/tests/test_cheap_gates.py',
|
||
'cheap-worker/tests/test_golden_specs.py',
|
||
'cheap-worker/tests/test_auto_vs_golden.py',
|
||
],
|
||
covers: 'verdict→反馈映射 / stale verdict 拦截 / cheap 门 / W-GENRE 四品类金标 play-spec',
|
||
};
|
||
|
||
const ICON = { PASS: '✔', FAIL: '✗', SKIP: '⊘', ERROR: '✗', MISSING: '✗' };
|
||
|
||
function main() {
|
||
console.log('门金标判例库 · 全量回归');
|
||
console.log('='.repeat(72));
|
||
const results = [];
|
||
for (const s of NODE_STAGES) results.push(runNodeStage(s));
|
||
results.push(runPythonStage(PYTHON_STAGE));
|
||
|
||
let totPass = 0, totFail = 0, totSkip = 0, hardFail = 0, skipStages = 0;
|
||
for (const r of results) {
|
||
const icon = ICON[r.status] || '?';
|
||
console.log(`\n[${r.status}] ${r.name} ${icon}`);
|
||
console.log(` 判据:${r.judge}`);
|
||
console.log(` 覆盖:${r.covers}`);
|
||
if (r.status === 'SKIP' && (r.pass + r.fail === 0)) {
|
||
console.log(` ${r.note || '整段跳过'}`);
|
||
skipStages += 1;
|
||
if (REQUIRE_ALL) hardFail += 1;
|
||
} else if (r.status === 'MISSING' || r.status === 'ERROR') {
|
||
console.log(` ${r.note || '判例执行异常'}`);
|
||
hardFail += 1;
|
||
} else {
|
||
console.log(` ${r.pass} 过 / ${r.fail} 挂 / ${r.skip} 跳`);
|
||
}
|
||
totPass += r.pass; totFail += r.fail; totSkip += r.skip;
|
||
if (r.status === 'FAIL') hardFail += 1;
|
||
}
|
||
|
||
console.log(`\n${'='.repeat(72)}`);
|
||
const green = hardFail === 0 && totFail === 0;
|
||
console.log(`总计:${totPass} 过 / ${totFail} 挂 / ${totSkip} 跳;${skipStages} 段整段跳过 → ${green ? '绿(全量通过)' : '红(有判据回归或必跑段跳过)'}`);
|
||
if (!green) {
|
||
console.log('失败明细见上;门判据改动前必须先让本回归全绿。');
|
||
for (const r of results) if (r.status === 'FAIL' || r.status === 'ERROR' || r.status === 'MISSING') {
|
||
console.log(`\n----- ${r.name} 输出 -----\n${(r.raw || r.note || '').trim().slice(-1500)}`);
|
||
}
|
||
}
|
||
process.exit(green ? 0 : 1);
|
||
}
|
||
|
||
// 被 node:test 导入时只暴露纯入口;直接执行仍保持原 CLI 行为与退出码。
|
||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
main();
|
||
}
|