pc-selftest-bridge.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. #!/usr/bin/env node
  2. /* ===========================================================================
  3. * pc-selftest-bridge.js —— 引擎侧「桥 + 心跳 + 配置绑定」的 PC 自检
  4. *
  5. * 跑法:cd tiktok-auto && $NODE tools/pc-selftest-bridge.js
  6. * 期望最后一行:BRIDGE_SELFTEST_PASS
  7. *
  8. * 为什么单独一个文件:`tiktok-auto` 的产物是"加载即执行"的(尾部就是
  9. * `TikTokAuto.main.run(...)`),在 PC 上 require 它会直接跑去开 TikTok。
  10. * 所以这里跟 `pc-selftest-id.js` 一样,把 **src 源码**拼进一个 vm 沙箱执行,
  11. * 只挑纯逻辑模块:protocol / bridge / config / wait / logger。
  12. *
  13. * ★ 线程桩必须"只登记、不执行":
  14. * 心跳是个 `while (running) { sleep(...) }` 的死循环,
  15. * 照抄 `android-stub.js` 里 `start: fn => fn()` 的写法会把 PC 进程挂死。
  16. * 这里只把函数存起来,心跳的每一拍由用例显式调 `heartbeatOnce()` 驱动。
  17. * =========================================================================== */
  18. 'use strict';
  19. const fs = require('fs');
  20. const path = require('path');
  21. const vm = require('vm');
  22. const SRC = path.join(__dirname, '..', 'src');
  23. const MODULES = ['logger', 'protocol', 'bridge', 'config', 'wait'];
  24. const RUN_ID = 'run-自检-1';
  25. const STATE = '/sdcard/tiktok_app/run-state.json';
  26. const BEAT = '/sdcard/tiktok_app/engine-beat.json';
  27. const STOP = '/sdcard/tiktok_app/stop.flag';
  28. const LOG = '/sdcard/tiktok_app/run-log.jsonl';
  29. const CONF = '/sdcard/Download/tiktok-auto/config.json';
  30. let passed = 0;
  31. const failures = [];
  32. function check(name, condition, detail) {
  33. if (condition) {
  34. passed += 1;
  35. return;
  36. }
  37. failures.push(name + (detail ? ' → ' + detail : ''));
  38. }
  39. function eq(name, actual, expected) {
  40. check(name, actual === expected, `期望 ${expected},实际 ${actual}`);
  41. }
  42. /* ------------------------------------------------------------ 沙箱 */
  43. /**
  44. * 造一个隔离的沙箱。
  45. * 每个沙箱有自己的内存文件系统与线程桩 —— 需要验"全新进程"的用例
  46. * (比如命令行根本没传 --run-id)必须用新沙箱:bridge 一旦 enable,
  47. * 进程内就没有"取消启用"这回事。
  48. */
  49. function buildJail(argv) {
  50. const mem = {};
  51. const threadsState = { started: [], shutDown: 0 };
  52. const jail = {
  53. mem: mem,
  54. threads: threadsState,
  55. throwOnStart: false
  56. };
  57. const box = {
  58. console: { log: () => { } },
  59. Date: Date, Math: Math, JSON: JSON,
  60. String: String, Number: Number, Object: Object,
  61. Array: Array, RegExp: RegExp, Boolean: Boolean,
  62. isNaN: isNaN, parseInt: parseInt, parseFloat: parseFloat,
  63. TikTokAuto: {}
  64. };
  65. box.files = {
  66. exists: (p) => Object.prototype.hasOwnProperty.apply(mem, [p]),
  67. read: (p) => (mem[p] === undefined ? '' : mem[p]),
  68. write: (p, content) => { mem[p] = String(content); },
  69. /* bridge 的日志落盘用的是 append,少了它 enable 会静默失败 */
  70. append: (p, content) => { mem[p] = (mem[p] || '') + String(content); },
  71. ensureDir: () => true
  72. };
  73. /* ★ 只登记不执行 */
  74. box.threads = {
  75. start: (fn) => {
  76. if (jail.throwOnStart) {
  77. throw new Error('自检:模拟线程起不来');
  78. }
  79. threadsState.started.push(fn);
  80. return { id: 'th-' + threadsState.started.length };
  81. },
  82. shutDownAll: () => { threadsState.shutDown += 1; }
  83. };
  84. box.sleep = () => { /* 心跳循环不会在自检里真跑,这里只是占位 */ };
  85. box.engines = { myEngine: () => ({ execArgv: argv }) };
  86. vm.createContext(box);
  87. let src = 'var TikTokAuto = this.TikTokAuto;\n';
  88. for (const name of MODULES) {
  89. src += fs.readFileSync(path.join(SRC, `${name}.js`), 'utf8') + '\n';
  90. }
  91. vm.runInContext(src, box);
  92. jail.api = box.TikTokAuto;
  93. return jail;
  94. }
  95. const jail = buildJail(['--run-id', RUN_ID, '--state', STATE, '--stop-flag', STOP, '--log', LOG, '--config', CONF]);
  96. const memFiles = jail.mem;
  97. const threadsState = jail.threads;
  98. const T = jail.api;
  99. const bridge = T.bridge;
  100. const config = T.config;
  101. /* logger 用真的,但把行收集起来(bridge.enable 会 setSink 到 files.append) */
  102. check('沙箱里加载出了 protocol / bridge / config',
  103. !!T.protocol && !!bridge && !!config);
  104. const readJson = (p) => {
  105. try { return JSON.parse(memFiles[p] || ''); } catch (e) { return null; }
  106. };
  107. /* ------------------------------------------------------------ 1. 启动握手 */
  108. const injected = bridge.fromExecArgv(null);
  109. eq('argv 里的配置路径被交给 config.resolve', injected.runtime.configPath, CONF);
  110. eq('enable 之后桥是启用的', bridge.isEnabled(), true);
  111. eq('runId 与命令行一致', bridge.currentRunId(), RUN_ID);
  112. let state = readJson(STATE);
  113. check('第一次上报就写出了 run-state', !!state);
  114. eq('run-state 带协议版本 v=2', state && state.v, 2);
  115. eq('第一个状态是 starting', state && state.status, 'starting');
  116. eq('runId 写进了状态', state && state.runId, RUN_ID);
  117. eq('心跳线程被真的请求了一次', threadsState.started.length, 1);
  118. check('run-state 里声明了"有心跳"',
  119. !!(state && state.engine && state.engine.capabilities && state.engine.capabilities.heartbeat === true),
  120. JSON.stringify(state && state.engine));
  121. let beat = readJson(BEAT);
  122. check('enable 时已经同步写了第一拍心跳', !!beat, '没写 beat 文件');
  123. eq('心跳带协议版本', beat && beat.v, 2);
  124. eq('心跳 runId 与本轮一致', beat && beat.runId, RUN_ID);
  125. eq('心跳停止确认初始为 0(还没人点停止)', beat && beat.stopSeenAt, 0);
  126. check('心跳序号从 1 开始', beat && beat.seq >= 1, String(beat && beat.seq));
  127. /* ------------------------------------------------------------ 2. 序号单调 */
  128. bridge.report({ phase: '启动应用' });
  129. state = readJson(STATE);
  130. const seq1 = state.seq;
  131. bridge.report({ status: 'running', phase: '干活' });
  132. state = readJson(STATE);
  133. check('report 的 seq 单调递增', state.seq > seq1, `${seq1} → ${state.seq}`);
  134. eq('report 带着 startedAt', typeof state.startedAt, 'number');
  135. eq('report 后的状态是 running', state.status, 'running');
  136. check('report 不清掉 engine.task 之类的已写字段',
  137. state.engine && typeof state.engine === 'object');
  138. bridge.setTask('video');
  139. state = readJson(STATE);
  140. eq('setTask 把任务名写进 engine.task', state.engine && state.engine.task, 'video');
  141. /* ------------------------------------------------------------ 3. 心跳 sticky */
  142. const firstBeatAt = readJson(BEAT).beatAt;
  143. bridge.heartbeatOnce();
  144. beat = readJson(BEAT);
  145. check('再写一拍:beatAt 前进', beat.beatAt >= firstBeatAt);
  146. check('再写一拍:seq 前进', beat.seq >= 2, String(beat.seq));
  147. /* 别人的 runId 写在 stop.flag 里 → 不算我的停止请求 */
  148. memFiles[STOP] = 'run-别的运行';
  149. bridge.heartbeatOnce();
  150. eq('别人的停止标志不会被当成自己的', readJson(BEAT).stopSeenAt, 0);
  151. /* 我自己的 runId → 记下时刻,而且**后续每一拍都要带着** */
  152. memFiles[STOP] = RUN_ID;
  153. bridge.heartbeatOnce();
  154. const seen1 = readJson(BEAT).stopSeenAt;
  155. check('本轮停止标志被心跳看见', seen1 > 0, String(seen1));
  156. bridge.heartbeatOnce();
  157. const seen2 = readJson(BEAT).stopSeenAt;
  158. eq('停止确认是 sticky 的(不会被下一拍抹掉)', seen2, seen1);
  159. bridge.heartbeatOnce();
  160. eq('再跳几拍也还是同一个时刻', readJson(BEAT).stopSeenAt, seen1);
  161. /* ------------------------------------------------------------ 4. 停止信号 */
  162. let stopped = false;
  163. let reportedStopping = false;
  164. try {
  165. bridge.checkStop();
  166. } catch (error) {
  167. stopped = bridge.isStopSignal(error);
  168. }
  169. eq('checkStop 命中时抛 StopSignal', stopped, true);
  170. state = readJson(STATE);
  171. reportedStopping = state.status === 'stopping';
  172. eq('抛之前先把 stopping 上报给宿主', reportedStopping, true);
  173. /* 空内容的 flag 不该触发(残留文件的兜底) */
  174. memFiles[STOP] = ' ';
  175. let quiet = true;
  176. try { bridge.checkStop(); } catch (e) { quiet = false; }
  177. eq('stop.flag 只有空白时不触发停止', quiet, true);
  178. memFiles[STOP] = RUN_ID;
  179. /* ------------------------------------------------------------ 5. 配置绑定 */
  180. /* 双方都非空且不等 → 拒绝(挡住迟到的旧引擎读到新配置就开跑) */
  181. memFiles[CONF] = JSON.stringify({ runtime: { runId: 'run-另一轮' } });
  182. let mismatch = null;
  183. try {
  184. config.resolve({ runtime: { configPath: CONF } });
  185. } catch (error) {
  186. mismatch = error;
  187. }
  188. check('配置属于另一次运行时被拒绝', !!mismatch && mismatch.name === 'ConfigError',
  189. mismatch ? String(mismatch.message) : '没有抛错');
  190. /* 配置里没有 runId(独立运行/老界面)→ 必须放行,否则引擎永远起不来 */
  191. memFiles[CONF] = JSON.stringify({});
  192. let standalone = null;
  193. try {
  194. config.resolve({ runtime: { configPath: CONF } });
  195. } catch (error) {
  196. standalone = error;
  197. }
  198. eq('配置里没写 runId 时放行(独立运行)', standalone, null);
  199. /* 本轮没有 runId(命令行没传,比如直接在 AutoX 里打开产物)→ 同样放行。
  200. * 这种"进程从没 enable 过"的状态只能在新沙箱里验:enable 一旦发生就无法撤销。 */
  201. const noArgvJail = buildJail([]);
  202. noArgvJail.mem[CONF] = JSON.stringify({ runtime: { runId: 'run-另一轮' } });
  203. let noArgvError = null;
  204. try {
  205. noArgvJail.api.config.resolve({ runtime: { configPath: CONF } });
  206. } catch (error) {
  207. noArgvError = error;
  208. }
  209. eq('引擎自己没 runId 时放行(命令行直跑)', noArgvError, null);
  210. /* 一致时正常通过 */
  211. memFiles[CONF] = JSON.stringify({ runtime: { runId: RUN_ID } });
  212. let okError = null;
  213. let resolved = null;
  214. try {
  215. resolved = config.resolve({ runtime: { configPath: CONF } });
  216. } catch (error) {
  217. okError = error;
  218. }
  219. eq('runId 一致时正常解析', okError, null);
  220. eq('解析结果里带着 runId', resolved && resolved.runtime.runId, RUN_ID);
  221. /* 配置文件本身是坏的 → 用默认值继续(不能让引擎直接不跑) */
  222. memFiles[CONF] = '{ 半截 JSON';
  223. let brokenError = null;
  224. try {
  225. resolved = config.resolve({ runtime: { configPath: CONF } });
  226. } catch (error) {
  227. brokenError = error;
  228. }
  229. eq('配置文件坏了也不拦启动(退回默认值)', brokenError, null);
  230. /* ------------------------------------------------------------ 6. 收尾 */
  231. bridge.finish('finished', { ok: true, videos: 2 });
  232. state = readJson(STATE);
  233. eq('finish 写出终态', state.status, 'finished');
  234. check('finish 带上了 summary', !!state.summary);
  235. check('终态里 message 是人话', typeof state.message === 'string' && state.message.length > 0,
  236. state.message);
  237. bridge.heartbeatStop();
  238. eq('heartbeatStop 之后心跳标记为停', bridge.isHeartbeatUp(), false);
  239. const seqAfterStop = readJson(BEAT).seq;
  240. eq('停心跳后不会自己再写(线程桩本来也没跑)', readJson(BEAT).seq, seqAfterStop);
  241. /* 心跳线程起不来 → 明确告诉宿主,让它退回无心跳阈值 */
  242. jail.throwOnStart = true;
  243. const up = bridge.heartbeatStart();
  244. eq('线程起不来时 heartbeatStart 返回 false', up, false);
  245. eq('起不来时 isHeartbeatUp 是 false', bridge.isHeartbeatUp(), false);
  246. jail.throwOnStart = false;
  247. /* 没启用的桥(独立运行):所有上报都是空操作,一个文件都不写 */
  248. const idleJail = buildJail([]);
  249. idleJail.api.bridge.report({ status: 'running' });
  250. idleJail.api.bridge.finish('finished', { ok: true });
  251. idleJail.api.bridge.heartbeatStart();
  252. check('未启用的桥不会凭空写 run-state', idleJail.mem[STATE] === undefined);
  253. check('未启用的桥不会凭空写心跳', idleJail.mem[BEAT] === undefined);
  254. eq('未启用的桥 heartbeatStart 返回 false',
  255. idleJail.api.bridge.heartbeatStart(), false);
  256. /* ------------------------------------------------------------ 汇总 */
  257. console.log('');
  258. console.log(`通过 ${passed} 项`);
  259. if (failures.length > 0) {
  260. console.log(`失败 ${failures.length} 项:`);
  261. failures.forEach((f) => console.log(' ✗ ' + f));
  262. console.log('');
  263. console.log('BRIDGE_SELFTEST_FAILED');
  264. process.exit(1);
  265. }
  266. console.log('BRIDGE_SELFTEST_PASS');