test-ops-import.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. #!/usr/bin/env node
  2. 'use strict';
  3. // 店铺运营数据:解析器单测 + 端到端接口测试
  4. // 全部本地跑:临时 SQLite 文件 + 随机端口,不依赖网络与云端数据库。
  5. // 用法:npm run test:ops
  6. const { spawn } = require('child_process');
  7. const net = require('net');
  8. const fs = require('fs');
  9. const os = require('os');
  10. const path = require('path');
  11. const crypto = require('crypto');
  12. const ROOT = path.resolve(__dirname, '..');
  13. const FIXTURE = path.join(__dirname, 'fixtures', 'ops-manual-sample.xlsx');
  14. const PLATFORM_FIXTURE = path.join(__dirname, 'fixtures', 'platform-export-sample.xlsx');
  15. const PLATFORM_VARIANT = path.join(__dirname, 'fixtures', 'platform-export-variant.xlsx');
  16. const importer = require(path.join(ROOT, 'ops-import.js'));
  17. const results = [];
  18. function expect(cond, msg) {
  19. if (!cond) throw new Error(msg);
  20. }
  21. async function check(name, fn) {
  22. try {
  23. await fn();
  24. results.push({ name, ok: true });
  25. } catch (e) {
  26. results.push({ name, ok: false, error: e.message || String(e) });
  27. }
  28. }
  29. function pickPort() {
  30. return new Promise((resolve, reject) => {
  31. const srv = net.createServer();
  32. srv.unref();
  33. srv.on('error', reject);
  34. srv.listen(0, '127.0.0.1', () => {
  35. const port = srv.address().port;
  36. srv.close(() => resolve(port));
  37. });
  38. });
  39. }
  40. async function waitForHealth(port, timeoutMs = 20000) {
  41. const start = Date.now();
  42. for (;;) {
  43. try {
  44. const res = await fetch(`http://127.0.0.1:${port}/healthz`);
  45. if (res.ok) return;
  46. } catch { /* 还没起来 */ }
  47. if (Date.now() - start > timeoutMs) throw new Error('服务未在超时内启动');
  48. await new Promise((r) => setTimeout(r, 300));
  49. }
  50. }
  51. async function readJson(res) {
  52. const text = await res.text();
  53. try {
  54. return { status: res.status, body: JSON.parse(text) };
  55. } catch {
  56. return { status: res.status, body: { raw: text } };
  57. }
  58. }
  59. async function main() {
  60. const fixture = fs.readFileSync(FIXTURE);
  61. const platformFixture = fs.readFileSync(PLATFORM_FIXTURE);
  62. const platformVariant = fs.readFileSync(PLATFORM_VARIANT);
  63. const fixtureSha = crypto.createHash('sha256').update(fixture).digest('hex');
  64. // ---------- 解析器单测 ----------
  65. await check('cleanValue: 空值 → null + missing', () => {
  66. const r = importer.cleanValue(null, 'order_cnt');
  67. expect(r.value === null, 'value 应为 null');
  68. expect(r.reasons.includes('missing'), '应标记 missing');
  69. });
  70. await check('cleanValue: 0 与 null 可区分', () => {
  71. const zero = importer.cleanValue(0, 'order_cnt');
  72. expect(zero.value === 0, '0 应保留为 0');
  73. expect(zero.reasons.length === 0, '0 不应有质量标记');
  74. const empty = importer.cleanValue('', 'order_cnt');
  75. expect(empty.value === null, '空串应为 null');
  76. });
  77. await check('cleanValue: 10..6% → 0.106 + malformed-percent', () => {
  78. const r = importer.cleanValue('10..6%', 'click_rate');
  79. expect(Math.abs(r.value - 0.106) < 1e-9, `实际 ${r.value}`);
  80. expect(r.reasons.includes('malformed-percent'), '应标记 malformed-percent');
  81. });
  82. await check('cleanValue: 12.3 → 0.123 + percent-scaled', () => {
  83. const r = importer.cleanValue(12.3, 'click_rate');
  84. expect(Math.abs(r.value - 0.123) < 1e-9, `实际 ${r.value}`);
  85. expect(r.reasons.includes('percent-scaled'), '应标记 percent-scaled');
  86. });
  87. await check('cleanValue: 未开放 → null + non-numeric', () => {
  88. const r = importer.cleanValue('未开放', 'order_cnt');
  89. expect(r.value === null, 'value 应为 null');
  90. expect(r.reasons.includes('non-numeric'), '应标记 non-numeric');
  91. expect(r.rawText === '未开放', '应保留原文');
  92. });
  93. await check('parseDateColumns: 9.9 + year 2026 → 20260909', () => {
  94. const cols = importer.parseDateColumns(['9.9订单量', '10.7营收'], 2026);
  95. expect(cols.length === 2, `列数 ${cols.length}`);
  96. expect(cols[0].date === '20260909', cols[0].date);
  97. expect(cols[1].date === '20261007', cols[1].date);
  98. });
  99. await check('parseDateColumns: 跨年 12.31 → 1.1 年 +1', () => {
  100. const cols = importer.parseDateColumns(['12.31订单量', '1.1订单量'], 2025);
  101. expect(cols[0].date === '20251231', cols[0].date);
  102. expect(cols[1].date === '20260101', cols[1].date);
  103. });
  104. await check('detect: fixture 识别为 manual-ops', () => {
  105. expect(importer.detectBuffer(fixture) === 'manual-ops', '未识别');
  106. });
  107. const parsed = importer.parseWorkbook(fixture, { year: 2026 });
  108. await check('fixture 解析行数 = 36 店 × 29 天', () => {
  109. expect(parsed.rows.length === 1044, `实际 ${parsed.rows.length}`);
  110. expect(parsed.shops.length === 36, `shops ${parsed.shops.length}`);
  111. expect(parsed.dates.length === 29, `dates ${parsed.dates.length}`);
  112. expect(parsed.dates[0] === '20260909' && parsed.dates[28] === '20261007', '日期范围');
  113. });
  114. await check('fixture 脏值已清洗', () => {
  115. const dirty = parsed.rows.find((r) => (r.raw.click_rate || '').includes('10..6'));
  116. expect(dirty, '找不到 10..6% 的行');
  117. expect(Math.abs(dirty.click_rate - 0.106) < 1e-9, `实际 ${dirty.click_rate}`);
  118. expect(dirty.quality.includes('malformed-percent'), '缺少 malformed-percent');
  119. const scaled = parsed.rows.find((r) => Number(r.raw.click_rate) === 12.3);
  120. expect(scaled && scaled.quality.includes('percent-scaled'), '12.3 未按百分数处理');
  121. const nonNumeric = parsed.rows.find((r) => (r.raw.order_cnt || '') === '未开放');
  122. expect(nonNumeric && nonNumeric.order_cnt === null, '未开放 未转为 null');
  123. });
  124. await check('fixture 无 ID 店铺用 school|shopName 兜底', () => {
  125. const noId = parsed.rows.filter((r) => r.quality.includes('no-shop-id'));
  126. expect(noId.length > 0, '应存在无 ID 店铺');
  127. expect(noId.every((r) => r.shopKey.includes('|')), 'shopKey 兜底格式不对');
  128. });
  129. await check('fixture 指定学校时覆盖表格里的学校列', () => {
  130. const forced = importer.parseWorkbook(fixture, { year: 2026, school: '统一校区' });
  131. expect(forced.rows.length === 1044, `rows ${forced.rows.length}`);
  132. expect(forced.rows.every((r) => r.school === '统一校区'), '仍有行没被覆盖');
  133. expect([...new Set(forced.shops.map((x) => x.school))].length === 1, '学校数应为 1');
  134. expect(forced.shops.every((x) => x.school === '统一校区'), '店铺注册表学校未覆盖');
  135. });
  136. await check('无「学校名称」列时 school 为空(由上传接口要求补选)', () => {
  137. const cells = [
  138. ['店铺名称', 'ID', '9.9订单量', '9.9营收', '9.9曝光量', '9.9进店转换率'],
  139. ['测试店', '123', 1, 2, 3, 0.1],
  140. ];
  141. const out = importer.parseManualOps(cells, { year: 2026 });
  142. expect(out.rows.length === 1, `rows ${out.rows.length}`);
  143. expect(out.rows[0].school === '', `school='${out.rows[0].school}'`);
  144. });
  145. await check('fixture 占位行未产生数据', () => {
  146. expect(parsed.shops.every((s) => s.shopName && s.shopName.trim()), '存在空店铺名');
  147. });
  148. // ---------- 平台导出(全字段明细) ----------
  149. await check('platform: 识别为 platform-export', () => {
  150. expect(importer.detectBuffer(platformFixture) === 'platform-export', '识别失败');
  151. });
  152. const platformParsed = importer.parseAny(platformFixture, { school: '测试校区' });
  153. await check('platform: 10 行 / 10 店 / 124 字段', () => {
  154. expect(platformParsed.kind === 'platform-export', platformParsed.kind);
  155. expect(platformParsed.rows.length === 10, `rows ${platformParsed.rows.length}`);
  156. expect(platformParsed.shops.length === 10, `shops ${platformParsed.shops.length}`);
  157. expect(Object.keys(platformParsed.rows[0].data).length === 124, `fields ${Object.keys(platformParsed.rows[0].data).length}`);
  158. expect(platformParsed.rows[0].date === '20261018', platformParsed.rows[0].date);
  159. expect(platformParsed.rows[0].school === '测试校区', platformParsed.rows[0].school);
  160. });
  161. await check('platform: 日期归一化', () => {
  162. const cells = [
  163. ['日期', '门店编号', '门店名称', '有效订单'],
  164. ['2026/1/9', '1', 'A店', 1],
  165. ['2026.10.18', '2', 'B店', 2],
  166. ];
  167. const r = importer.parsePlatformExport(cells, {});
  168. expect(r.rows[0].date === '20260109', r.rows[0].date);
  169. expect(r.rows[1].date === '20261018', r.rows[1].date);
  170. });
  171. // ---------- 端到端(临时 SQLite) ----------
  172. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-'));
  173. const dbFile = path.join(tmp, 'ops.db');
  174. const configPath = path.join(tmp, 'config.json');
  175. fs.writeFileSync(configPath, JSON.stringify({
  176. aitable: { enabled: false },
  177. storage: { driver: 'sqlite', sqlite: { file: dbFile } },
  178. }));
  179. const port = await pickPort();
  180. const child = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
  181. env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_CONFIG: configPath, GENPIC_LAKE_DIR: path.join(tmp, 'lake'), NAPOS_CDP_PORT: '1' },
  182. stdio: ['ignore', 'pipe', 'pipe'],
  183. });
  184. let bootLog = '';
  185. child.stdout.on('data', (c) => { bootLog += c; });
  186. child.stderr.on('data', (c) => { bootLog += c; });
  187. const base = `http://127.0.0.1:${port}`;
  188. const upload = (force) => fetch(base + '/api/ops/upload', {
  189. method: 'POST',
  190. headers: { 'Content-Type': 'application/json' },
  191. body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: !!force }),
  192. }).then(readJson);
  193. try {
  194. await waitForHealth(port);
  195. await check('E2E 空态 summary', async () => {
  196. const r = await fetch(base + '/api/ops/summary').then(readJson);
  197. expect(r.status === 200, `status ${r.status}`);
  198. expect(r.body.enabled === true, 'enabled 应为 true');
  199. expect(r.body.rowCount === 0, 'rowCount 应为 0');
  200. expect(r.body.driver === 'sqlite', `driver ${r.body.driver}`);
  201. });
  202. let uploadId = '';
  203. await check('E2E 上传成功', async () => {
  204. const r = await upload(false);
  205. expect(r.status === 200, `status ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`);
  206. uploadId = r.body.uploadId;
  207. expect(/^\d{8}-\d{6}-[a-z0-9]{6}$/.test(uploadId), `uploadId ${uploadId}`);
  208. expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`);
  209. expect(r.body.report.shops === 36, `shops ${r.body.report.shops}`);
  210. });
  211. await check('E2E 重复上传 409', async () => {
  212. const r = await upload(false);
  213. expect(r.status === 409, `status ${r.status}`);
  214. expect(r.body.uploadId === uploadId, '应返回已存在的 uploadId');
  215. });
  216. await check('E2E force 覆盖导入', async () => {
  217. const r = await upload(true);
  218. expect(r.status === 200, `status ${r.status}`);
  219. expect(r.body.report.overwritten === 1044, `overwritten ${r.body.report.overwritten}`);
  220. });
  221. await check('E2E summary 有数据', async () => {
  222. const r = await fetch(base + '/api/ops/summary').then(readJson);
  223. expect(r.body.rowCount === 1044, `rowCount ${r.body.rowCount}`);
  224. expect(r.body.schools.length === 3, `schools ${r.body.schools.length}`);
  225. expect(r.body.coverage.from === '20260909' && r.body.coverage.to === '20261007', 'coverage');
  226. });
  227. await check('E2E 矩阵查询 + 指标过滤', async () => {
  228. const r = await fetch(base + '/api/ops/rows?metrics=order_cnt').then(readJson);
  229. expect(r.status === 200, `status ${r.status}`);
  230. expect(r.body.dates.length === 29, `dates ${r.body.dates.length}`);
  231. expect(r.body.metrics.length === 1 && r.body.metrics[0] === 'order_cnt', 'metrics 过滤失效');
  232. expect(r.body.groups.length === 3, `groups ${r.body.groups.length}`);
  233. const shop = r.body.groups[0].shops[0];
  234. expect(typeof shop.totals.order_cnt === 'number', 'totals 缺失');
  235. });
  236. await check('E2E 学校过滤', async () => {
  237. const r = await fetch(base + '/api/ops/rows?schools=' + encodeURIComponent('样例学校A')).then(readJson);
  238. expect(r.body.groups.length === 1, `groups ${r.body.groups.length}`);
  239. expect(r.body.groups[0].school === '样例学校A', r.body.groups[0].school);
  240. });
  241. await check('E2E 下载原件字节一致', async () => {
  242. const res = await fetch(`${base}/api/ops/uploads/${uploadId}/file`);
  243. expect(res.status === 200, `status ${res.status}`);
  244. const buf = Buffer.from(await res.arrayBuffer());
  245. const sha = crypto.createHash('sha256').update(buf).digest('hex');
  246. expect(sha === fixtureSha, 'sha256 不一致');
  247. });
  248. await check('E2E 非法 uploadId 400', async () => {
  249. const res = await fetch(`${base}/api/ops/uploads/${encodeURIComponent('../etc')}/file`);
  250. expect(res.status === 400, `status ${res.status}`);
  251. });
  252. await check('E2E 不存在的 uploadId 404', async () => {
  253. const res = await fetch(`${base}/api/ops/uploads/20000101-000000-abcdef/file`);
  254. expect(res.status === 404, `status ${res.status}`);
  255. });
  256. await check('E2E 重新解析', async () => {
  257. const r = await fetch(`${base}/api/ops/uploads/${uploadId}/reparse`, { method: 'POST' }).then(readJson);
  258. expect(r.status === 200, `status ${r.status}`);
  259. expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`);
  260. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  261. expect(sum.body.rowCount === 1044, `rowCount ${sum.body.rowCount}`);
  262. });
  263. await check('E2E 导出 CSV', async () => {
  264. const res = await fetch(base + '/api/ops/export?metrics=order_cnt');
  265. expect(res.status === 200, `status ${res.status}`);
  266. const text = await res.text();
  267. expect(/text\/csv/.test(res.headers.get('content-type') || ''), 'content-type');
  268. expect(text.includes('样例学校A'), 'CSV 缺少学校列');
  269. expect(text.trim().split('\n').length === 37, `行数 ${text.trim().split('\n').length}`);
  270. });
  271. await check('E2E 非 xlsx 拒绝', async () => {
  272. const res = await fetch(base + '/api/ops/upload', {
  273. method: 'POST',
  274. headers: { 'Content-Type': 'application/json' },
  275. body: JSON.stringify({ fileName: 'a.txt', contentBase64: Buffer.from('x').toString('base64'), year: 2026 }),
  276. });
  277. expect(res.status === 400, `status ${res.status}`);
  278. });
  279. await check('E2E 坏文件 400 且不落库', async () => {
  280. const before = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length;
  281. const res = await fetch(base + '/api/ops/upload', {
  282. method: 'POST',
  283. headers: { 'Content-Type': 'application/json' },
  284. body: JSON.stringify({ fileName: 'bad.xlsx', contentBase64: Buffer.from('not a zip file at all').toString('base64'), year: 2026 }),
  285. });
  286. expect(res.status === 400, `status ${res.status}`);
  287. const after = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length;
  288. expect(after === before, '坏文件不应产生上传记录');
  289. });
  290. await check('E2E 上传时指定学校 → 覆盖并写入', async () => {
  291. const res = await fetch(base + '/api/ops/upload', {
  292. method: 'POST',
  293. headers: { 'Content-Type': 'application/json' },
  294. body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: true, school: '测试校区' }),
  295. }).then(readJson);
  296. expect(res.status === 200, `status ${res.status}`);
  297. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  298. expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
  299. });
  300. await check('E2E 重新解析保留指定学校', async () => {
  301. const list = await fetch(base + '/api/ops/uploads').then(readJson);
  302. const id = list.body.uploads[0].id;
  303. const r = await fetch(`${base}/api/ops/uploads/${id}/reparse`, { method: 'POST' }).then(readJson);
  304. expect(r.status === 200, `status ${r.status}`);
  305. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  306. expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
  307. });
  308. const uploadPlatform = (buf, force, school) => fetch(base + '/api/ops/upload', {
  309. method: 'POST',
  310. headers: { 'Content-Type': 'application/json' },
  311. body: JSON.stringify({
  312. fileName: '平台导出.xlsx',
  313. contentBase64: buf.toString('base64'),
  314. year: 2026,
  315. force: !!force,
  316. ...(school === undefined ? {} : { school }),
  317. }),
  318. }).then(readJson);
  319. await check('E2E 平台导出:未选学校 → 400 提示补选', async () => {
  320. const r = await uploadPlatform(platformFixture);
  321. expect(r.status === 400 && r.body.code === 'OPS_SCHOOL_REQUIRED', `status ${r.status} ${JSON.stringify(r.body)}`);
  322. });
  323. await check('E2E 平台导出:首次导入 10 行', async () => {
  324. const r = await uploadPlatform(platformFixture, false, '测试校区');
  325. expect(r.status === 200, `status ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`);
  326. expect(r.body.report.kind === 'platform-export', r.body.report.kind);
  327. expect(r.body.report.newRows === 10, `newRows ${r.body.report.newRows}`);
  328. expect(r.body.report.writtenRows === 10, `written ${r.body.report.writtenRows}`);
  329. });
  330. await check('E2E 平台导出:同文件强制重传 → 全部已存在', async () => {
  331. const r = await uploadPlatform(platformFixture, true, '测试校区');
  332. expect(r.status === 200, `status ${r.status}`);
  333. expect(r.body.report.newRows === 0, `newRows ${r.body.report.newRows}`);
  334. expect(r.body.report.identicalRows === 10, `identical ${r.body.report.identicalRows}`);
  335. expect(r.body.duplicateData === true, '应标记重复');
  336. });
  337. await check('E2E 平台导出:内容变化 → 冲突 1 行', async () => {
  338. const r = await uploadPlatform(platformVariant, false, '测试校区');
  339. expect(r.status === 200, `status ${r.status}`);
  340. expect(r.body.report.conflictRows === 1, `conflict ${r.body.report.conflictRows}`);
  341. expect(r.body.report.identicalRows === 9, `identical ${r.body.report.identicalRows}`);
  342. expect(r.body.report.writtenRows === 0, `written ${r.body.report.writtenRows}`);
  343. });
  344. await check('E2E 平台导出:强制覆盖冲突', async () => {
  345. const r = await uploadPlatform(platformVariant, true, '测试校区');
  346. expect(r.status === 200, `status ${r.status}`);
  347. expect(r.body.report.writtenRows === 10, `written ${r.body.report.writtenRows}`);
  348. const sum = await fetch(base + '/api/ops/summary').then(readJson);
  349. expect(sum.body.platformRowCount === 10, `platformRows ${sum.body.platformRowCount}`);
  350. });
  351. await check('E2E 导入记录只含元数据', async () => {
  352. const r = await fetch(base + '/api/ops/uploads').then(readJson);
  353. expect(r.body.uploads.length >= 1, '记录为空');
  354. expect(!Object.prototype.hasOwnProperty.call(r.body.uploads[0], 'content'), '不应返回 content');
  355. });
  356. } finally {
  357. try { child.kill('SIGKILL'); } catch { /* ignore */ }
  358. }
  359. // ---------- 存储不可用时的降级 ----------
  360. const badTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-bad-'));
  361. const badConfig = path.join(badTmp, 'config.json');
  362. fs.writeFileSync(badConfig, JSON.stringify({
  363. aitable: { enabled: false },
  364. storage: { driver: 'sqlite', sqlite: { file: '/dev/null/ops.db' } },
  365. }));
  366. const badPort = await pickPort();
  367. const badChild = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
  368. env: { ...process.env, PORT: String(badPort), HOST: '127.0.0.1', GENPIC_CONFIG: badConfig, GENPIC_LAKE_DIR: path.join(badTmp, 'lake'), NAPOS_CDP_PORT: '1' },
  369. stdio: ['ignore', 'pipe', 'pipe'],
  370. });
  371. const badBase = `http://127.0.0.1:${badPort}`;
  372. try {
  373. await waitForHealth(badPort);
  374. await check('E2E 存储不可用时 summary 降级为 enabled:false', async () => {
  375. const r = await fetch(badBase + '/api/ops/summary').then(readJson);
  376. expect(r.status === 200, `status ${r.status}`);
  377. expect(r.body.enabled === false, 'enabled 应为 false');
  378. expect(!!r.body.reason, '缺少 reason');
  379. });
  380. await check('E2E 存储不可用时其它接口 503', async () => {
  381. const r = await fetch(badBase + '/api/ops/rows').then(readJson);
  382. expect(r.status === 503, `status ${r.status}`);
  383. });
  384. await check('E2E 存储不可用时主服务仍正常', async () => {
  385. const r = await fetch(badBase + '/healthz');
  386. expect(r.status === 200, `healthz ${r.status}`);
  387. });
  388. } finally {
  389. try { badChild.kill('SIGKILL'); } catch { /* ignore */ }
  390. try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
  391. try { fs.rmSync(badTmp, { recursive: true, force: true }); } catch { /* ignore */ }
  392. }
  393. // ---------- 汇总 ----------
  394. const failed = results.filter((r) => !r.ok);
  395. console.log('\n========== 店铺运营测试结果 ==========');
  396. for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : ' ← ' + r.error}`);
  397. console.log(`\n通过 ${results.length - failed.length}/${results.length}`);
  398. if (failed.length) {
  399. if (bootLog) console.log('\n服务启动日志(末尾 800 字):\n' + bootLog.slice(-800));
  400. process.exit(1);
  401. }
  402. }
  403. main().catch((e) => {
  404. console.error('测试脚本自身异常:', e);
  405. process.exit(1);
  406. });