#!/usr/bin/env node /* =========================================================================== * pc-selftest-bridge.js —— 引擎侧「桥 + 心跳 + 配置绑定」的 PC 自检 * * 跑法:cd tiktok-auto && $NODE tools/pc-selftest-bridge.js * 期望最后一行:BRIDGE_SELFTEST_PASS * * 为什么单独一个文件:`tiktok-auto` 的产物是"加载即执行"的(尾部就是 * `TikTokAuto.main.run(...)`),在 PC 上 require 它会直接跑去开 TikTok。 * 所以这里跟 `pc-selftest-id.js` 一样,把 **src 源码**拼进一个 vm 沙箱执行, * 只挑纯逻辑模块:protocol / bridge / config / wait / logger。 * * ★ 线程桩必须"只登记、不执行": * 心跳是个 `while (running) { sleep(...) }` 的死循环, * 照抄 `android-stub.js` 里 `start: fn => fn()` 的写法会把 PC 进程挂死。 * 这里只把函数存起来,心跳的每一拍由用例显式调 `heartbeatOnce()` 驱动。 * =========================================================================== */ 'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const SRC = path.join(__dirname, '..', 'src'); const MODULES = ['logger', 'protocol', 'bridge', 'config', 'wait']; const RUN_ID = 'run-自检-1'; const STATE = '/sdcard/tiktok_app/run-state.json'; const BEAT = '/sdcard/tiktok_app/engine-beat.json'; const STOP = '/sdcard/tiktok_app/stop.flag'; const LOG = '/sdcard/tiktok_app/run-log.jsonl'; const CONF = '/sdcard/Download/tiktok-auto/config.json'; 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}`); } /* ------------------------------------------------------------ 沙箱 */ /** * 造一个隔离的沙箱。 * 每个沙箱有自己的内存文件系统与线程桩 —— 需要验"全新进程"的用例 * (比如命令行根本没传 --run-id)必须用新沙箱:bridge 一旦 enable, * 进程内就没有"取消启用"这回事。 */ function buildJail(argv) { const mem = {}; const threadsState = { started: [], shutDown: 0 }; const jail = { mem: mem, threads: threadsState, throwOnStart: false }; const box = { console: { log: () => { } }, Date: Date, Math: Math, JSON: JSON, String: String, Number: Number, Object: Object, Array: Array, RegExp: RegExp, Boolean: Boolean, isNaN: isNaN, parseInt: parseInt, parseFloat: parseFloat, TikTokAuto: {} }; box.files = { exists: (p) => Object.prototype.hasOwnProperty.apply(mem, [p]), read: (p) => (mem[p] === undefined ? '' : mem[p]), write: (p, content) => { mem[p] = String(content); }, /* bridge 的日志落盘用的是 append,少了它 enable 会静默失败 */ append: (p, content) => { mem[p] = (mem[p] || '') + String(content); }, ensureDir: () => true }; /* ★ 只登记不执行 */ box.threads = { start: (fn) => { if (jail.throwOnStart) { throw new Error('自检:模拟线程起不来'); } threadsState.started.push(fn); return { id: 'th-' + threadsState.started.length }; }, shutDownAll: () => { threadsState.shutDown += 1; } }; box.sleep = () => { /* 心跳循环不会在自检里真跑,这里只是占位 */ }; box.engines = { myEngine: () => ({ execArgv: argv }) }; vm.createContext(box); let src = 'var TikTokAuto = this.TikTokAuto;\n'; for (const name of MODULES) { src += fs.readFileSync(path.join(SRC, `${name}.js`), 'utf8') + '\n'; } vm.runInContext(src, box); jail.api = box.TikTokAuto; return jail; } const jail = buildJail(['--run-id', RUN_ID, '--state', STATE, '--stop-flag', STOP, '--log', LOG, '--config', CONF]); const memFiles = jail.mem; const threadsState = jail.threads; const T = jail.api; const bridge = T.bridge; const config = T.config; /* logger 用真的,但把行收集起来(bridge.enable 会 setSink 到 files.append) */ check('沙箱里加载出了 protocol / bridge / config', !!T.protocol && !!bridge && !!config); const readJson = (p) => { try { return JSON.parse(memFiles[p] || ''); } catch (e) { return null; } }; /* ------------------------------------------------------------ 1. 启动握手 */ const injected = bridge.fromExecArgv(null); eq('argv 里的配置路径被交给 config.resolve', injected.runtime.configPath, CONF); eq('enable 之后桥是启用的', bridge.isEnabled(), true); eq('runId 与命令行一致', bridge.currentRunId(), RUN_ID); let state = readJson(STATE); check('第一次上报就写出了 run-state', !!state); eq('run-state 带协议版本 v=2', state && state.v, 2); eq('第一个状态是 starting', state && state.status, 'starting'); eq('runId 写进了状态', state && state.runId, RUN_ID); eq('心跳线程被真的请求了一次', threadsState.started.length, 1); check('run-state 里声明了"有心跳"', !!(state && state.engine && state.engine.capabilities && state.engine.capabilities.heartbeat === true), JSON.stringify(state && state.engine)); let beat = readJson(BEAT); check('enable 时已经同步写了第一拍心跳', !!beat, '没写 beat 文件'); eq('心跳带协议版本', beat && beat.v, 2); eq('心跳 runId 与本轮一致', beat && beat.runId, RUN_ID); eq('心跳停止确认初始为 0(还没人点停止)', beat && beat.stopSeenAt, 0); check('心跳序号从 1 开始', beat && beat.seq >= 1, String(beat && beat.seq)); /* ------------------------------------------------------------ 2. 序号单调 */ bridge.report({ phase: '启动应用' }); state = readJson(STATE); const seq1 = state.seq; bridge.report({ status: 'running', phase: '干活' }); state = readJson(STATE); check('report 的 seq 单调递增', state.seq > seq1, `${seq1} → ${state.seq}`); eq('report 带着 startedAt', typeof state.startedAt, 'number'); eq('report 后的状态是 running', state.status, 'running'); check('report 不清掉 engine.task 之类的已写字段', state.engine && typeof state.engine === 'object'); bridge.setTask('video'); state = readJson(STATE); eq('setTask 把任务名写进 engine.task', state.engine && state.engine.task, 'video'); /* ------------------------------------------------------------ 3. 心跳 sticky */ const firstBeatAt = readJson(BEAT).beatAt; bridge.heartbeatOnce(); beat = readJson(BEAT); check('再写一拍:beatAt 前进', beat.beatAt >= firstBeatAt); check('再写一拍:seq 前进', beat.seq >= 2, String(beat.seq)); /* 别人的 runId 写在 stop.flag 里 → 不算我的停止请求 */ memFiles[STOP] = 'run-别的运行'; bridge.heartbeatOnce(); eq('别人的停止标志不会被当成自己的', readJson(BEAT).stopSeenAt, 0); /* 我自己的 runId → 记下时刻,而且**后续每一拍都要带着** */ memFiles[STOP] = RUN_ID; bridge.heartbeatOnce(); const seen1 = readJson(BEAT).stopSeenAt; check('本轮停止标志被心跳看见', seen1 > 0, String(seen1)); bridge.heartbeatOnce(); const seen2 = readJson(BEAT).stopSeenAt; eq('停止确认是 sticky 的(不会被下一拍抹掉)', seen2, seen1); bridge.heartbeatOnce(); eq('再跳几拍也还是同一个时刻', readJson(BEAT).stopSeenAt, seen1); /* ------------------------------------------------------------ 4. 停止信号 */ let stopped = false; let reportedStopping = false; try { bridge.checkStop(); } catch (error) { stopped = bridge.isStopSignal(error); } eq('checkStop 命中时抛 StopSignal', stopped, true); state = readJson(STATE); reportedStopping = state.status === 'stopping'; eq('抛之前先把 stopping 上报给宿主', reportedStopping, true); /* 空内容的 flag 不该触发(残留文件的兜底) */ memFiles[STOP] = ' '; let quiet = true; try { bridge.checkStop(); } catch (e) { quiet = false; } eq('stop.flag 只有空白时不触发停止', quiet, true); memFiles[STOP] = RUN_ID; /* ------------------------------------------------------------ 5. 配置绑定 */ /* 双方都非空且不等 → 拒绝(挡住迟到的旧引擎读到新配置就开跑) */ memFiles[CONF] = JSON.stringify({ runtime: { runId: 'run-另一轮' } }); let mismatch = null; try { config.resolve({ runtime: { configPath: CONF } }); } catch (error) { mismatch = error; } check('配置属于另一次运行时被拒绝', !!mismatch && mismatch.name === 'ConfigError', mismatch ? String(mismatch.message) : '没有抛错'); /* 配置里没有 runId(独立运行/老界面)→ 必须放行,否则引擎永远起不来 */ memFiles[CONF] = JSON.stringify({}); let standalone = null; try { config.resolve({ runtime: { configPath: CONF } }); } catch (error) { standalone = error; } eq('配置里没写 runId 时放行(独立运行)', standalone, null); /* 本轮没有 runId(命令行没传,比如直接在 AutoX 里打开产物)→ 同样放行。 * 这种"进程从没 enable 过"的状态只能在新沙箱里验:enable 一旦发生就无法撤销。 */ const noArgvJail = buildJail([]); noArgvJail.mem[CONF] = JSON.stringify({ runtime: { runId: 'run-另一轮' } }); let noArgvError = null; try { noArgvJail.api.config.resolve({ runtime: { configPath: CONF } }); } catch (error) { noArgvError = error; } eq('引擎自己没 runId 时放行(命令行直跑)', noArgvError, null); /* 一致时正常通过 */ memFiles[CONF] = JSON.stringify({ runtime: { runId: RUN_ID } }); let okError = null; let resolved = null; try { resolved = config.resolve({ runtime: { configPath: CONF } }); } catch (error) { okError = error; } eq('runId 一致时正常解析', okError, null); eq('解析结果里带着 runId', resolved && resolved.runtime.runId, RUN_ID); /* 配置文件本身是坏的 → 用默认值继续(不能让引擎直接不跑) */ memFiles[CONF] = '{ 半截 JSON'; let brokenError = null; try { resolved = config.resolve({ runtime: { configPath: CONF } }); } catch (error) { brokenError = error; } eq('配置文件坏了也不拦启动(退回默认值)', brokenError, null); /* ------------------------------------------------------------ 6. 收尾 */ bridge.finish('finished', { ok: true, videos: 2 }); state = readJson(STATE); eq('finish 写出终态', state.status, 'finished'); check('finish 带上了 summary', !!state.summary); check('终态里 message 是人话', typeof state.message === 'string' && state.message.length > 0, state.message); bridge.heartbeatStop(); eq('heartbeatStop 之后心跳标记为停', bridge.isHeartbeatUp(), false); const seqAfterStop = readJson(BEAT).seq; eq('停心跳后不会自己再写(线程桩本来也没跑)', readJson(BEAT).seq, seqAfterStop); /* 心跳线程起不来 → 明确告诉宿主,让它退回无心跳阈值 */ jail.throwOnStart = true; const up = bridge.heartbeatStart(); eq('线程起不来时 heartbeatStart 返回 false', up, false); eq('起不来时 isHeartbeatUp 是 false', bridge.isHeartbeatUp(), false); jail.throwOnStart = false; /* 没启用的桥(独立运行):所有上报都是空操作,一个文件都不写 */ const idleJail = buildJail([]); idleJail.api.bridge.report({ status: 'running' }); idleJail.api.bridge.finish('finished', { ok: true }); idleJail.api.bridge.heartbeatStart(); check('未启用的桥不会凭空写 run-state', idleJail.mem[STATE] === undefined); check('未启用的桥不会凭空写心跳', idleJail.mem[BEAT] === undefined); eq('未启用的桥 heartbeatStart 返回 false', idleJail.api.bridge.heartbeatStart(), false); /* ------------------------------------------------------------ 汇总 */ console.log(''); console.log(`通过 ${passed} 项`); if (failures.length > 0) { console.log(`失败 ${failures.length} 项:`); failures.forEach((f) => console.log(' ✗ ' + f)); console.log(''); console.log('BRIDGE_SELFTEST_FAILED'); process.exit(1); } console.log('BRIDGE_SELFTEST_PASS');