codex-ipc-probe.html 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. <!doctype html>
  2. <html lang="zh">
  3. <head>
  4. <meta charset="utf-8" />
  5. <title>Codex IPC 探针</title>
  6. <style>
  7. body { font: 12px/1.6 Consolas, monospace; margin: 16px; }
  8. .ok { color: #1a7f37; }
  9. .bad { color: #c00; }
  10. </style>
  11. </head>
  12. <body>
  13. <pre id="out">Codex IPC probe running…</pre>
  14. <script>
  15. /**
  16. * 真实 Electron IPC 链路探针:在应用窗口内逐条调用 controller/codexCtl/*,
  17. * 覆盖结构化克隆(含 Uint8Array 入参)、错误包装、以及真实 codex 子进程往返。
  18. *
  19. * 用法(env 开关,不污染常规 dev 启动):
  20. * ZSJZ_CODEX_PROBE=file:///<repo>/ai-electron/scripts/codex-ipc-probe.html \
  21. * ZSJZ_CODEX_PROBE_OUT=./scripts/codex-ipc-probe.json npx electron . --env=local
  22. */
  23. const { ipcRenderer } = require('electron');
  24. const fs = require('node:fs');
  25. const os = require('node:os');
  26. const path = require('node:path');
  27. const outFile = process.env.ZSJZ_CODEX_PROBE_OUT || path.join(os.tmpdir(), 'codex-ipc-probe.json');
  28. const results = [];
  29. const pre = document.getElementById('out');
  30. const call = (method, params) => ipcRenderer.invoke(`controller/codexCtl/${method}`, params);
  31. /** 既有的 osCtl controller(窗口控制 / 打开本机文件等) */
  32. const osCall = (method, params) => ipcRenderer.invoke(`controller/osCtl/${method}`, params);
  33. /** 下载管理器 controller(controller/downloadCtl/*) */
  34. const dlCall = (method, params) => ipcRenderer.invoke(`controller/downloadCtl/${method}`, params);
  35. function log(name, ok, detail) {
  36. results.push({ name, ok, detail });
  37. pre.innerHTML += `\n<span class="${ok ? 'ok' : 'bad'}">${ok ? 'PASS' : 'FAIL'} ${name}</span> ${JSON.stringify(detail).slice(0, 300)}`;
  38. }
  39. function isRpc(value) {
  40. return Boolean(value) && typeof value === 'object' && typeof value.success === 'boolean';
  41. }
  42. async function check(name, run, assert) {
  43. try {
  44. const rpc = await run();
  45. if (!isRpc(rpc)) {
  46. log(name, false, { reason: '返回不是 {success,data,message} 结构', rpc });
  47. return null;
  48. }
  49. const problem = assert ? assert(rpc) : rpc.success === false ? rpc.message : null;
  50. log(name, !problem, problem || (rpc.success ? rpc.data : { message: rpc.message }));
  51. return rpc;
  52. } catch (error) {
  53. log(name, false, { reason: 'invoke 直接 reject(说明主进程抛到了 Electron 层)', error: String(error) });
  54. return null;
  55. }
  56. }
  57. /** 断言「应当失败」:失败信息要回来,且不能被 Electron 包成 handler 错误 */
  58. function expectFailure(pattern) {
  59. return (rpc) => {
  60. if (rpc.success) return '本该失败却成功了';
  61. if (!pattern.test(rpc.message)) return `错误信息不匹配 ${pattern}: ${rpc.message}`;
  62. return null;
  63. };
  64. }
  65. function expectData(verify) {
  66. return (rpc) => {
  67. if (!rpc.success) return rpc.message;
  68. return verify(rpc.data) ? null : `数据不符合预期: ${JSON.stringify(rpc.data).slice(0, 200)}`;
  69. };
  70. }
  71. async function main() {
  72. pre.textContent = '';
  73. const tmpSkill = fs.mkdtempSync(path.join(os.tmpdir(), 'zsjz-probe-skill-'));
  74. fs.writeFileSync(
  75. path.join(tmpSkill, 'SKILL.md'),
  76. '---\nname: ipc-probe-skill\ndescription: 探针安装的技能\n---\n\n# probe\n',
  77. 'utf8',
  78. );
  79. await check('ping', () => call('ping'), expectData((d) => d.available === true && /codex-cli/.test(d.version || '')));
  80. await check('status', () => call('status'), expectData((d) => typeof d.running === 'boolean' && Boolean(d.codexHome)));
  81. await check('start', () => call('start'), expectData((d) => d.running === true));
  82. await check('listModels', () => call('listModels'), (rpc) => (rpc.success && Array.isArray(rpc.data) ? null : rpc.message));
  83. await check(
  84. 'mcpSave 含中文/空格参数',
  85. () => call('mcpSave', { id: 'probe-stdio', transport: 'stdio', command: 'node', args: ['-e', '过程 输出'], enabled: true }),
  86. expectData((list) => list.some((item) => item.id === 'probe-stdio' && item.args[1] === '过程 输出')),
  87. );
  88. await check('mcpList', () => call('mcpList'), expectData((list) => list.some((item) => item.id === 'probe-stdio')));
  89. await check('mcpSave 非法 id', () => call('mcpSave', { id: '有 空格', transport: 'stdio', command: 'x', enabled: true }), expectFailure(/MCP ID/));
  90. await check('mcpSave 明文 http 远程', () => call('mcpSave', { id: 'a', transport: 'http', url: 'http://10.0.0.8/mcp', enabled: true }), expectFailure(/HTTPS/));
  91. await check('mcpRemove 缺 id', () => call('mcpRemove', {}), expectFailure(/不能为空/));
  92. await check('mcpRemove', () => call('mcpRemove', { id: 'probe-stdio' }), expectData((list) => !list.some((item) => item.id === 'probe-stdio')));
  93. await check('skillInstallFolder', () => call('skillInstallFolder', { srcPath: tmpSkill, overwrite: true }), expectData((list) => list.some((s) => s.name === 'ipc-probe-skill' && s.managed)));
  94. await check('skillSetEnabled false', () => call('skillSetEnabled', { name: 'ipc-probe-skill', enabled: false }), expectData((d) => d.effectiveEnabled === false));
  95. await check('skillRead', () => call('skillRead', { name: 'ipc-probe-skill' }), expectData((d) => d.content.includes('ipc-probe-skill')));
  96. await check('skillInstallZip 非 zip 字节', () => call('skillInstallZip', { data: new Uint8Array([1, 2, 3, 4]) }), expectFailure(/无法解析 zip/));
  97. await check('skillRemove 内置', () => call('skillList', { forceReload: true }).then((rpc) => (rpc.success && rpc.data.find((s) => !s.managed) ? call('skillRemove', { path: rpc.data.find((s) => !s.managed).path }) : rpc)), expectFailure(/内置或暂存目录/));
  98. await check('skillRemove', () => call('skillRemove', { name: 'ipc-probe-skill' }), expectData((list) => !list.some((s) => s.name === 'ipc-probe-skill')));
  99. await check('删除后 config.toml 无残留条目', async () => {
  100. const st = await call('status');
  101. const file = st.success ? path.join(st.data.codexHome, 'config.toml') : null;
  102. if (!file || !fs.existsSync(file)) return { success: true, data: { note: '无 config.toml' } };
  103. const text = fs.readFileSync(file, 'utf8');
  104. return text.includes('ipc-probe-skill')
  105. ? { success: false, message: 'config.toml 仍留有该 skill 的条目(重装会继承禁用)' }
  106. : { success: true, data: { cleaned: true } };
  107. });
  108. await check('probeProvider 不可达端点', () => call('probeProvider', { baseUrl: 'http://127.0.0.1:9/v1' }), expectData((d) => d.protocol === 'unreachable' && d.responsesStatus === null && String(d.detail || '').includes('http://127.0.0.1:9/v1/responses')));
  109. // 用本地假端点跑一次真实 apply / clear(不触碰任何真实模型密钥)
  110. const http = require('node:http');
  111. const fake = http.createServer((req, res) => {
  112. req.resume();
  113. res.writeHead(req.url && req.url.endsWith('/responses') ? 401 : 200, { 'content-type': 'application/json' });
  114. res.end('{}');
  115. });
  116. await new Promise((resolve) => fake.listen(0, '127.0.0.1', resolve));
  117. const fakeUrl = `http://127.0.0.1:${fake.address().port}/v1`;
  118. await check(
  119. 'applyProvider 真实往返',
  120. () => call('applyProvider', { modelId: 'probe-live', baseUrl: fakeUrl, apiKey: 'sk-probe-local', providerType: 'OPENAI', modelRecordId: 'probe-1' }),
  121. expectData((d) => d.applied.model === 'probe-live' && d.applied.providerId === 'zsjz' && d.status.running === true && d.status.defaultModel === 'probe-live'),
  122. );
  123. await check('密钥不落盘(config.toml / provider.json)', async () => {
  124. const st = await call('status');
  125. if (!st.success) return st;
  126. const codexHome = st.data.codexHome;
  127. const dataDir = path.dirname(codexHome);
  128. const targets = [
  129. path.join(codexHome, 'config.toml'),
  130. path.join(dataDir, 'codex-data', 'provider.json'),
  131. ];
  132. for (const file of targets) {
  133. if (!fs.existsSync(file)) continue;
  134. if (fs.readFileSync(file, 'utf8').includes('sk-probe-local')) {
  135. return { success: false, message: `${file} 里出现了密钥` };
  136. }
  137. }
  138. return { success: true, data: { checked: targets.filter((f) => fs.existsSync(f)) } };
  139. });
  140. await check(
  141. 'applyProvider 端点不可达时报错(回归)',
  142. () => call('applyProvider', { modelId: 'qwen', baseUrl: 'http://127.0.0.1:9/v1' }),
  143. expectFailure(/不可达|没有 \/chat\/completions/),
  144. );
  145. await check('clearProvider', () => call('clearProvider'), expectData((d) => d.providerId === null && d.applied === null));
  146. fake.close();
  147. await check('applyProvider 缺 modelId', () => call('applyProvider', { modelId: ' ' }), expectFailure(/modelId/));
  148. await check('turnInterrupt 缺参', () => call('turnInterrupt', {}), expectFailure(/不能为空/));
  149. await check('approvalResolve 非法 decision', () => call('approvalResolve', { token: 'x', decision: 'whatever' }), expectFailure(/decision/));
  150. await check('eventsRead 非法 threadId', () => call('eventsRead', { threadId: '../etc' }), expectFailure(/不能为空|threadId|非法/));
  151. await check('logsTail', () => call('logsTail', { limit: 5 }), expectData((d) => Array.isArray(d)));
  152. await check('stop', () => call('stop'), expectData((d) => d.state === 'stopped'));
  153. // 本机地图目录:存在客户端 local-settings.json,不经服务端(设置抽屉的「地图」用它)
  154. const MAP_DIR_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/scripts';
  155. const MAP_STYLE_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/frontend/public/resource/map/behavior-spacetime-style.json';
  156. // 先记下运行前的配置,末尾恢复,保证探针不改动使用者机器上的设置
  157. const mapOriginal = ((await osCall('getMapLocalPath')) || {}).data?.path || '';
  158. await check('osCtl.getMapLocalPath', () => osCall('getMapLocalPath'), expectData((d) => typeof d.path === 'string'));
  159. await check(
  160. 'osCtl.setMapLocalPath 相对路径被拒',
  161. () => osCall('setMapLocalPath', { path: 'relative/maps' }),
  162. expectFailure(/绝对路径/),
  163. );
  164. await check(
  165. 'osCtl.setMapLocalPath 不存在的目录被拒',
  166. () => osCall('setMapLocalPath', { path: 'E:/no-such-dir/maps' }),
  167. expectFailure(/目录不存在/),
  168. );
  169. await check(
  170. 'osCtl.setMapLocalPath 文件被拒',
  171. () => osCall('setMapLocalPath', { path: MAP_STYLE_SAMPLE }),
  172. expectFailure(/请选择目录/),
  173. );
  174. await check(
  175. 'osCtl.setMapLocalPath 保存真实目录',
  176. () => osCall('setMapLocalPath', { path: MAP_DIR_SAMPLE }),
  177. expectData((d) => d.path === MAP_DIR_SAMPLE),
  178. );
  179. await check(
  180. 'osCtl.getMapLocalPath 回读一致',
  181. () => osCall('getMapLocalPath'),
  182. expectData((d) => d.path === MAP_DIR_SAMPLE),
  183. );
  184. await check(
  185. 'osCtl.setMapLocalPath 清空(空值)',
  186. async () => {
  187. await osCall('setMapLocalPath', { path: '' });
  188. const after = ((await osCall('getMapLocalPath')) || {}).data || {};
  189. return { success: true, data: { path: after.path } };
  190. },
  191. expectData((d) => d.path === ''),
  192. );
  193. await check(
  194. 'osCtl.setMapLocalPath 恢复运行前配置',
  195. async () => {
  196. if (mapOriginal) await osCall('setMapLocalPath', { path: mapOriginal });
  197. const after = ((await osCall('getMapLocalPath')) || {}).data || {};
  198. return { success: true, data: { path: after.path, expected: mapOriginal } };
  199. },
  200. expectData((d) => d.path === d.expected),
  201. );
  202. // 离线地图的本机读取(恢复改造前方式):瓦片按偏移分块读、样式读文本
  203. const MAP_FILE_SAMPLE = 'E:/workspace/zsjz-ai/ai-electron/frontend/public/resource/map/behavior-spacetime-style.json';
  204. await check(
  205. 'osCtl.readBinaryFileRange 读前 16 字节',
  206. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 0, length: 16 }),
  207. expectData((d) => d.size === 16 && Buffer.from(d.data, 'base64').toString('utf8').trimStart().startsWith('{')),
  208. );
  209. await check(
  210. 'osCtl.readBinaryFileRange 中段偏移',
  211. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 100, length: 32 }),
  212. expectData((d) => d.size === 32 && d.fileSize > 100),
  213. );
  214. await check(
  215. 'osCtl.readBinaryFileRange 超长自动收敛到文件末尾',
  216. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 0, length: 99999999 }),
  217. expectData((d) => d.size === d.fileSize),
  218. );
  219. await check(
  220. 'osCtl.readBinaryFileRange 偏移越界返回空',
  221. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE, offset: 99999999, length: 16 }),
  222. expectData((d) => d.size === 0 && d.data === ''),
  223. );
  224. await check(
  225. 'osCtl.readBinaryFileRange 相对路径被拒',
  226. () => osCall('readBinaryFileRange', { filePath: 'relative/a.pmtiles', length: 16 }),
  227. expectFailure(/绝对路径/),
  228. );
  229. await check(
  230. 'osCtl.readBinaryFileRange 缺 length 被拒',
  231. () => osCall('readBinaryFileRange', { filePath: MAP_FILE_SAMPLE }),
  232. expectFailure(/长度不合法/),
  233. );
  234. await check(
  235. 'osCtl.readBinaryFileRange 不存在的文件被拒',
  236. () => osCall('readBinaryFileRange', { filePath: 'E:/no-such-dir/a.pmtiles', length: 16 }),
  237. expectFailure(/文件不存在/),
  238. );
  239. await check(
  240. 'osCtl.readTextFile 能读到样式文本',
  241. () => osCall('readTextFile', { filePath: MAP_FILE_SAMPLE, encoding: 'utf8' }),
  242. expectData((d) => String(d.text || '').includes('version')),
  243. );
  244. await check(
  245. 'osCtl.readTextFile 目录被拒',
  246. () => osCall('readTextFile', { filePath: 'E:/workspace/zsjz-ai' }),
  247. expectFailure(/不是文件/),
  248. );
  249. // 下载管理器:落盘到可配置目录 + 清单 + 打开/移除(都在临时目录里做,不动使用者的文件)
  250. const dlTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zsjz-dl-'));
  251. const dirBefore = ((await dlCall('getDownloadDir')) || {}).data || {};
  252. await check(
  253. 'downloadCtl.getDownloadDir',
  254. () => dlCall('getDownloadDir'),
  255. expectData((d) => typeof d.dir === 'string' && d.dir.length > 0),
  256. );
  257. await check(
  258. 'downloadCtl.setDownloadDir 文件被拒',
  259. () => dlCall('setDownloadDir', { dir: MAP_FILE_SAMPLE }),
  260. expectFailure(/请选择目录/),
  261. );
  262. await check(
  263. 'downloadCtl.setDownloadDir 不存在被拒',
  264. () => dlCall('setDownloadDir', { dir: 'E:/no-such-dir/downloads' }),
  265. expectFailure(/目录不存在/),
  266. );
  267. await check(
  268. 'downloadCtl.setDownloadDir 切到临时目录',
  269. () => dlCall('setDownloadDir', { dir: dlTempDir }),
  270. expectData((d) => d.dir === dlTempDir),
  271. );
  272. await check(
  273. 'downloadCtl.saveDownload 落盘',
  274. async () => {
  275. const saved = await dlCall('saveDownload', {
  276. fileName: 'probe-download.txt',
  277. mime: 'text/plain',
  278. data: Buffer.from('hello-download'),
  279. source: 'probe',
  280. });
  281. const path = saved?.data?.savedPath || '';
  282. const content = path && fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : '';
  283. return { success: true, data: { fileName: saved?.data?.fileName, bytes: saved?.data?.bytes, content } };
  284. },
  285. expectData((d) => d.fileName === 'probe-download.txt' && d.bytes === 14 && d.content === 'hello-download'),
  286. );
  287. await check(
  288. 'downloadCtl.listDownloads 含新记录且目录正确',
  289. () => dlCall('listDownloads'),
  290. expectData((d) => d.dir === dlTempDir && Array.isArray(d.items) && d.items[0]?.fileName === 'probe-download.txt'),
  291. );
  292. await check(
  293. 'downloadCtl.saveDownload 重名自动加序号',
  294. () =>
  295. dlCall('saveDownload', {
  296. fileName: 'probe-download.txt',
  297. mime: 'text/plain',
  298. data: Buffer.from('second'),
  299. source: 'probe',
  300. }),
  301. expectData((d) => d.fileName === 'probe-download (1).txt'),
  302. );
  303. await check(
  304. 'downloadCtl.saveDownload 支持直接传 ArrayBuffer',
  305. async () => {
  306. const bytes = new TextEncoder().encode('array-buffer-payload');
  307. const saved = await dlCall('saveDownload', {
  308. fileName: 'probe-arraybuffer.bin',
  309. mime: 'application/octet-stream',
  310. data: bytes.buffer,
  311. source: 'probe',
  312. });
  313. const path = saved?.data?.savedPath || '';
  314. const content = path && fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : '';
  315. return { success: true, data: { fileName: saved?.data?.fileName, bytes: saved?.data?.bytes, content } };
  316. },
  317. expectData((d) => d.bytes === 20 && d.content === 'array-buffer-payload'),
  318. );
  319. await check(
  320. 'downloadCtl.openDownloadFile 记录不存在被拒',
  321. () => dlCall('openDownloadFile', { id: 'no-such-id' }),
  322. expectFailure(/记录不存在/),
  323. );
  324. await check(
  325. 'downloadCtl.removeDownload 移除一条',
  326. async () => {
  327. const list = ((await dlCall('listDownloads')) || {}).data || {};
  328. const first = (list.items || [])[0];
  329. await dlCall('removeDownload', { id: first?.id });
  330. const after = ((await dlCall('listDownloads')) || {}).data || {};
  331. return { success: true, data: { before: (list.items || []).length, after: (after.items || []).length } };
  332. },
  333. expectData((d) => d.before > 0 && d.after === d.before - 1),
  334. );
  335. await check(
  336. 'downloadCtl.clearDownloads 清空清单',
  337. async () => {
  338. await dlCall('clearDownloads');
  339. const after = ((await dlCall('listDownloads')) || {}).data || {};
  340. return { success: true, data: { count: (after.items || []).length } };
  341. },
  342. expectData((d) => d.count === 0),
  343. );
  344. await check(
  345. 'downloadCtl.setDownloadDir 恢复运行前配置',
  346. async () => {
  347. const expected = dirBefore.dir || '';
  348. await dlCall('setDownloadDir', { dir: expected });
  349. const after = ((await dlCall('getDownloadDir')) || {}).data || {};
  350. return { success: true, data: { dir: after.dir, expected } };
  351. },
  352. expectData((d) => d.dir === d.expected),
  353. );
  354. // 后台下载 + 进度推送:探针页自己起一个"细水长流"的本地服务,验证进度事件与落盘
  355. await check(
  356. 'downloadCtl.startDownload 后台下载并推送进度',
  357. async () => {
  358. const http = require('node:http');
  359. const payload = Buffer.alloc(256 * 1024, 0x41);
  360. const server = http.createServer((req, res) => {
  361. res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(payload.length) });
  362. // 分 16 片下发,每片 30ms:足够观察到进度
  363. let offset = 0;
  364. const timer = setInterval(() => {
  365. if (offset >= payload.length) {
  366. clearInterval(timer);
  367. res.end();
  368. return;
  369. }
  370. res.write(payload.subarray(offset, offset + 16 * 1024));
  371. offset += 16 * 1024;
  372. }, 30);
  373. req.on('close', () => clearInterval(timer));
  374. });
  375. await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  376. const port = server.address().port;
  377. const progresses = [];
  378. const onProgress = (_event, item) => progresses.push(item);
  379. ipcRenderer.on('downloadCtl:progress', onProgress);
  380. const started = await dlCall('startDownload', {
  381. url: `http://127.0.0.1:${port}/probe-background.bin`,
  382. fileName: 'probe-background.bin',
  383. source: 'probe',
  384. });
  385. const id = started?.data?.id;
  386. // 等它下完(最多 6 秒)
  387. let finalItem = null;
  388. for (let i = 0; i < 60; i += 1) {
  389. await new Promise((resolve) => setTimeout(resolve, 100));
  390. const list = ((await dlCall('listDownloads')) || {}).data || {};
  391. const found = (list.items || []).find((item) => item.id === id);
  392. if (found && found.status !== 'downloading') {
  393. finalItem = found;
  394. break;
  395. }
  396. }
  397. ipcRenderer.removeListener('downloadCtl:progress', onProgress);
  398. server.close();
  399. const sameId = progresses.filter((item) => item.id === id);
  400. const increased = sameId.some((item, index) => index > 0 && item.received > (sameId[index - 1].received || 0));
  401. const size = finalItem?.savedPath && fs.existsSync(finalItem.savedPath) ? fs.statSync(finalItem.savedPath).size : 0;
  402. return {
  403. status: 'ok',
  404. success: true,
  405. data: {
  406. eventCount: sameId.length,
  407. receivedIncreased: increased,
  408. finalStatus: finalItem?.status || null,
  409. size,
  410. total: finalItem?.total || 0,
  411. },
  412. };
  413. },
  414. expectData((d) => d.eventCount >= 2 && d.receivedIncreased === true && d.finalStatus === 'done' && d.size === 256 * 1024),
  415. );
  416. await check(
  417. 'downloadCtl.startDownload 后端报错记为 failed',
  418. async () => {
  419. const http = require('node:http');
  420. const server = http.createServer((req, res) => {
  421. res.writeHead(404, { 'content-type': 'application/json' });
  422. res.end(JSON.stringify({ code: 404, message: '文件不存在(探针)' }));
  423. });
  424. await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  425. const port = server.address().port;
  426. const started = await dlCall('startDownload', {
  427. url: `http://127.0.0.1:${port}/missing.bin`,
  428. fileName: 'probe-missing.bin',
  429. source: 'probe',
  430. });
  431. const id = started?.data?.id;
  432. let finalItem = null;
  433. for (let i = 0; i < 30; i += 1) {
  434. await new Promise((resolve) => setTimeout(resolve, 100));
  435. const list = ((await dlCall('listDownloads')) || {}).data || {};
  436. const found = (list.items || []).find((item) => item.id === id);
  437. if (found && found.status !== 'downloading') {
  438. finalItem = found;
  439. break;
  440. }
  441. }
  442. server.close();
  443. return { success: true, data: { status: finalItem?.status || null, message: finalItem?.message || '' } };
  444. },
  445. expectData((d) => d.status === 'failed' && /文件不存在/.test(d.message)),
  446. );
  447. await check(
  448. 'downloadCtl.startDownload 空地址被拒',
  449. () => dlCall('startDownload', { fileName: 'x.bin' }),
  450. expectFailure(/地址为空/),
  451. );
  452. fs.writeFileSync(outFile, JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');
  453. const failed = results.filter((item) => !item.ok);
  454. pre.innerHTML += `\n\n${failed.length === 0 ? 'ALL PASS' : 'FAILED ' + failed.length} → ${outFile}`;
  455. // 让最后的写入落盘再退出
  456. setTimeout(() => process.exit(failed.length === 0 ? 0 : 1), 300);
  457. }
  458. main().catch((error) => {
  459. fs.writeFileSync(outFile, JSON.stringify({ error: String(error), results }, null, 2), 'utf8');
  460. setTimeout(() => process.exit(1), 100);
  461. });
  462. </script>
  463. </body>
  464. </html>