#!/usr/bin/env node /* =========================================================================== * protocol-check.js —— 两侧协议模块的一致性 / 自洽性检查(PC 侧) * * 为什么需要它:`tiktok-app/src/protocol.js` 与 `tiktok-auto/src/protocol.js` * 是**两份实体文件**(两个独立产物,做不了共享模块)。任何一边被单独改动, * 就会出现"界面认为 stopped 是终态、引擎认为不是"这种零报错的悬挂。 * 这里在 PC 上把它们分别加载进**两个独立沙箱**,逐项比对。 * * 用法: * $NODE tools/protocol-check.js # 期望最后一行:PROTOCOL_CLEAN * =========================================================================== */ 'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const ROOT = path.join(__dirname, '..', '..'); const APP_FILE = path.join(ROOT, 'tiktok-app', 'src', 'protocol.js'); const AUTO_FILE = path.join(ROOT, 'tiktok-auto', 'src', 'protocol.js'); /* 两侧唯一允许的差异行(挂载点) */ const APP_MOUNT = 'var Protocol = (function () {'; const AUTO_MOUNT = 'TikTokAuto.protocol = (function () {'; let passed = 0; const failures = []; function check(name, condition, detail) { if (condition) { passed += 1; return; } failures.push(name + (detail ? ' → ' + detail : '')); } function eq(name, actual, expected) { check(name, actual === expected, `期望 ${expected},实际 ${actual}`); } /* ------------------------------------------------------------ 加载两侧 */ function load(source) { const sandbox = { JSON: JSON, String: String, Number: Number, Object: Object, Array: Array, Boolean: Boolean, Error: Error, TikTokAuto: {} }; vm.createContext(sandbox); vm.runInContext(source, sandbox, { filename: 'protocol.js' }); return sandbox; } const appSource = fs.readFileSync(APP_FILE, 'utf8'); const autoSource = fs.readFileSync(AUTO_FILE, 'utf8'); const appBox = load(appSource); const autoBox = load(autoSource); const A = appBox.Protocol; const B = autoBox.TikTokAuto.protocol; check('界面侧 protocol.js 挂载出了 Protocol', !!A); check('引擎侧 protocol.js 挂载出了 TikTokAuto.protocol', !!B); if (!A || !B) { console.log(''); console.log(`通过 ${passed} 项`); failures.forEach((f) => console.log(' ✗ ' + f)); console.log(''); console.log('PROTOCOL_FAILED'); process.exit(1); } /* ------------------------------------------------- 1. 两份内容必须一致 */ /* 只允许「挂载点那一行」不同;其余逐字节相同。 */ const appBody = appSource.split(APP_MOUNT).join(''); const autoBody = autoSource.split(AUTO_MOUNT).join(''); check('两侧文件除挂载点外逐字节相同', appBody === autoBody, firstDiff(appBody, autoBody)); eq('两侧挂载点各出现一次', appSource.split(APP_MOUNT).length, 2); eq('引擎侧不含界面侧的挂载点', autoSource.indexOf(APP_MOUNT), -1); eq('界面侧不含引擎侧的挂载点', appSource.indexOf(AUTO_MOUNT), -1); eq('两侧 VERSION 相同', A.VERSION, B.VERSION); check('两侧 SPEC 序列化后相同', JSON.stringify(A.SPEC) === JSON.stringify(B.SPEC), 'SPEC 不一致'); /* ------------------------------------------------ 2. 枚举与迁移表自洽 */ /* STATUS 是「常量名 → 状态值」的映射,这里要的是**状态值**。 */ const STATUS_VALUES = Object.keys(A.SPEC.status).map((k) => A.SPEC.status[k]); const TERMINAL = A.SPEC.terminal; const EDGES = A.SPEC.edges; eq('状态枚举是 6 档', STATUS_VALUES.length, 6); /* 迁移表里出现的每个 from(空串除外)都必须是已知状态 */ const unknownFroms = Object.keys(EDGES).filter( (k) => k !== '' && STATUS_VALUES.indexOf(k) < 0 ); check('迁移表的 from 都在枚举内', unknownFroms.length === 0, unknownFroms.join(',')); /* 迁移表里出现的每个 to 都必须是已知状态 */ const badTos = []; Object.keys(EDGES).forEach((from) => { Object.keys(EDGES[from]).forEach((to) => { if (STATUS_VALUES.indexOf(to) < 0) { badTos.push(from + '→' + to); } }); }); check('迁移表的 to 都在枚举内', badTos.length === 0, badTos.join(',')); /* 终态必须没有出边 —— 这是"终态"的定义 */ const leaky = Object.keys(TERMINAL).filter((t) => Object.keys(EDGES[t] || {}).length > 0); check('终态没有出边', leaky.length === 0, leaky.join(',')); /* 终态往外走必须判 regress,不能判 unknown */ const regressMiss = Object.keys(TERMINAL).filter( (t) => A.legality(t, 'running') !== 'regress' ); check('终态 → running 判 regress', regressMiss.length === 0, regressMiss.join(',')); eq('legality(stopped, running)', A.legality('stopped', 'running'), 'regress'); eq('legality(running, running) 是幂等', A.legality('running', 'running'), 'idempotent'); eq('legality(running, stopping) 合法', A.legality('running', 'stopping'), 'ok'); eq('legality(空, starting) 合法', A.legality('', 'starting'), 'ok'); eq('legality(空, running) 非法(第一跳必须 starting)', A.legality('', 'running'), 'unknown'); /* 每个非终态 ≤2 步到终态(BFS),防止新增状态后出现"永远收不了尾" */ const unreachable = STATUS_VALUES.filter( (s) => !TERMINAL[s] && stepsToTerminal(EDGES, TERMINAL, s) > 2 ); check('每个非终态 ≤2 步到终态', unreachable.length === 0, unreachable.map((s) => s + '=' + stepsToTerminal(EDGES, TERMINAL, s)).join(',')); /* ------------------------------------------------ 3. 判定函数与数据一致 */ const witness = []; STATUS_VALUES.concat(['', 'weird', 'FINISHED', null, undefined]).forEach((s) => { const expectTerminal = STATUS_VALUES.indexOf(s) >= 0 && TERMINAL[s] === true; if (A.isTerminal(s) !== expectTerminal) { witness.push('isTerminal(' + String(s) + ')'); } }); check('isTerminal 与终态表逐项一致', witness.length === 0, witness.join(',')); const activeMiss = STATUS_VALUES.filter( (s) => A.isActive(s) !== (TERMINAL[s] !== true) ); check('isActive 恰好是"非终态的已知状态"', activeMiss.length === 0, activeMiss.join(',')); /* 前向兼容第一条:未知状态绝不能被视为终态 */ const unknown = ['weird', 'paused', 'unknown', '', 'Starting', 'RUNNING']; const badUnknown = unknown.filter((s) => A.isTerminal(s) === true); check('未知/大小写不符的 status 都不判终态', badUnknown.length === 0, badUnknown.join(',')); check('未知 status 的 levelOf 是 unknown', A.levelOf('weird') === 'unknown' && A.levelOf('') === 'unknown'); eq('levelOf(running)', A.levelOf('running'), 'active'); eq('levelOf(failed)', A.levelOf('failed'), 'terminal'); /* ------------------------------------------------ 4. 阈值关系 */ const T = A.SPEC.thresholds; check('心跳间隔为正', T.beatIntervalMs > 0); check('beatFreshMs ≥ 2 个心跳', T.beatFreshMs >= T.beatIntervalMs * 2, `${T.beatFreshMs} vs ${T.beatIntervalMs}`); check('beatDeadMs > beatFreshMs', T.beatDeadMs > T.beatFreshMs, `${T.beatDeadMs} vs ${T.beatFreshMs}`); check('startAckSoftMs < startAckMaxMs', T.startAckSoftMs < T.startAckMaxMs, `${T.startAckSoftMs} vs ${T.startAckMaxMs}`); check('stopAckMs > 一个心跳(否则慢心跳永远等不到 ack)', T.stopAckMs > T.beatIntervalMs, `${T.stopAckMs} vs ${T.beatIntervalMs}`); check('stopFinishMs > stopAckMs', T.stopFinishMs > T.stopAckMs); check('v1 回退的判死比 v2 更宽松', T.legacyNoBeatDeadMs >= T.beatDeadMs, `${T.legacyNoBeatDeadMs} vs ${T.beatDeadMs}`); check('v1 回退的停止超时是改造前的 8s', T.legacyStopTimeoutMs === 8000, String(T.legacyStopTimeoutMs)); check('v1 回退的子引擎宽限期是改造前的 4s', T.legacyChildGraceMs === 4000, String(T.legacyChildGraceMs)); /* ------------------------------------------------ 5. 阈值键集合一致 */ const appKeys = Object.keys(T).sort().join(','); const autoKeys = Object.keys(B.SPEC.thresholds).sort().join(','); eq('两侧阈值键集合相同', appKeys, autoKeys); /* ------------------------------------------------------------ 汇总 */ console.log(''); console.log(`通过 ${passed} 项`); if (failures.length > 0) { console.log(`失败 ${failures.length} 项:`); failures.forEach((f) => console.log(' ✗ ' + f)); console.log(''); console.log('PROTOCOL_FAILED'); process.exit(1); } console.log('PROTOCOL_CLEAN'); /* ------------------------------------------------------------ 辅助 */ function stepsToTerminal(edges, terminal, start) { let frontier = [start]; const seen = {}; seen[start] = true; for (let step = 1; step <= 4; step += 1) { const next = []; for (let i = 0; i < frontier.length; i += 1) { const edgesOfNode = edges[frontier[i]] || {}; const targets = Object.keys(edgesOfNode); for (let j = 0; j < targets.length; j += 1) { const to = targets[j]; if (terminal[to] === true) { return step; } if (seen[to] !== true) { seen[to] = true; next.push(to); } } } frontier = next; } return 99; } function firstDiff(a, b) { const len = Math.min(a.length, b.length); for (let i = 0; i < len; i += 1) { if (a[i] !== b[i]) { return `首个差异在第 ${i} 字符:${JSON.stringify(a.slice(i, i + 40))} vs ${JSON.stringify(b.slice(i, i + 40))}`; } } return `长度不同:${a.length} vs ${b.length}`; }