// 统一 CI 编排:本地与 Gitea Actions 共用同一入口 // 用法:node scripts/ci.mjs // 步骤:起本地同步服务 → 协议审计 → 用户管理审计 → 单元测试 → 双设备回归 → 实时链路 → 网页构建 → 依赖审计(软性) // 任何硬性步骤失败即整体失败(依赖审计默认软性,CI 里单独放开) import { spawn, spawnSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const PORT = process.env.SYNC_PORT || '3780' const BASE = 'http://127.0.0.1:' + PORT const results = [] let hardFailed = false let softWarned = false function log(tag, line) { console.log('[' + tag + '] ' + line) } function runStep(name, cmd, args, opts = {}) { log('RUN', name + ' (' + cmd + ' ' + args.join(' ') + ')') const started = Date.now() const r = spawnSync(cmd, args, { cwd: opts.cwd || ROOT, env: process.env, encoding: 'utf-8', shell: process.platform === 'win32' && cmd.endsWith('.cmd') }) const out = ((r.stdout || '') + (r.stderr || '')).trim() const tail = out.split('\n').slice(-opts.tailLines || -40) for (const l of (opts.tail || out.split('\n').slice(-25))) if (l.trim()) console.log(' ' + l) const dur = ((Date.now() - started) / 1000).toFixed(1) + 's' const ok = r.status === 0 results.push({ name, ok, dur }) if (ok) log('OK ', name + ' (' + dur + ')') else { log('FAIL', name + ' (exit ' + r.status + ', ' + dur + ')') if (opts.soft) { softWarned = true; log('WARN', name + ' 为软性检查,不阻断 CI') } else hardFailed = true } return { ok, out } } function findEsbuild() { const candidates = [ path.join(ROOT, 'intern-log', 'node_modules', 'esbuild', 'bin', 'esbuild') ] const pnpmDir = path.join(ROOT, 'intern-log', 'node_modules', '.pnpm') try { for (const d of fs.readdirSync(pnpmDir)) { if (d.startsWith('esbuild@')) candidates.push(path.join(pnpmDir, d, 'node_modules', 'esbuild', 'bin', 'esbuild')) } } catch { /* 无 .pnpm 目录 */ } for (const c of candidates) if (fs.existsSync(c)) return c throw new Error('找不到 esbuild,请先在 intern-log 下执行 pnpm install') } function runBundled(name, tsPath) { const outfile = path.join(os.tmpdir(), 'daywork-' + name + '-' + Date.now() + '.cjs') const esbuildBin = findEsbuild() const b = runStep('bundle:' + name, process.execPath, [esbuildBin, tsPath, '--bundle', '--platform=node', '--format=cjs', '--outfile=' + outfile], { tail: [] }) if (!b.ok) return runStep('test:' + name, process.execPath, [outfile], {}) try { fs.rmSync(outfile, { force: true }) } catch { /* 忽略 */ } } async function waitForServer(deadlineMs) { const started = Date.now() while (Date.now() - started < deadlineMs) { try { const r = await fetch(BASE + '/api/health') if (r.ok) return true } catch { /* 未就绪 */ } await new Promise(r => globalThis.setTimeout(r, 300)) } return false } // ---------- 0) 清理历史测试数据,保证套件幂等 ---------- fs.rmSync(path.join(ROOT, 'server', 'data'), { recursive: true, force: true }) // ---------- 1) 启动本地同步服务 ---------- const serverProc = spawn(process.execPath, [path.join(ROOT, 'server', 'index.js')], { cwd: ROOT, env: { ...process.env, PORT }, stdio: ['ignore', 'pipe', 'pipe'] }) let serverLog = '' serverProc.stdout.on('data', d => { serverLog += d }) serverProc.stderr.on('data', d => { serverLog += d }) const up = await waitForServer(15000) if (!up) { console.error('[FATAL] 本地同步服务 15 秒内未就绪') console.error(serverLog.slice(-1500)) process.exit(1) } log('OK', '同步服务已就绪 ' + BASE) try { // ---------- 2) 服务端协议审计 ---------- runStep('protocol-audit', process.execPath, [path.join(ROOT, 'tests', 'server', 'protocol.mjs')], {}) // ---------- 3) 用户管理审计 ---------- runStep('admin-audit', process.execPath, [path.join(ROOT, 'tests', 'server', 'admin.mjs')], {}) // ---------- 4/5/6) 客户端引擎级测试 ---------- runBundled('unit', 'intern-log/repro/unit-tests.ts') runBundled('two-device', 'intern-log/repro/two-device.ts') runBundled('realtime', 'intern-log/repro/engine-realtime.ts') // ---------- 7) 网页构建(编译即检查) ---------- const isWin = process.platform === 'win32' const pnpmCmd = isWin ? 'pnpm.cmd' : 'pnpm' runStep('build:web', pnpmCmd, ['run', 'build'], { cwd: path.join(ROOT, 'intern-log') }) } finally { serverProc.kill() try { fs.rmSync(path.join(ROOT, 'server', 'data'), { recursive: true, force: true }) } catch { /* 忽略 */ } } // ---------- 8) 依赖漏洞审计(软性:高危及以上才告警,CI 可单独放开为硬性) ---------- const isWin = process.platform === 'win32' runStep('audit:deps', isWin ? 'pnpm.cmd' : 'pnpm', ['audit', '--prod', '--audit-level=high'], { cwd: path.join(ROOT, 'intern-log'), soft: true }) // ---------- 汇总 ---------- console.log('') console.log('==================== CI 汇总 ====================') for (const r of results) console.log((r.ok ? ' PASS ' : ' FAIL ') + r.name.padEnd(18) + r.dur) console.log('================================================') if (hardFailed) { console.error('结果:存在失败步骤,CI 不通过') process.exit(1) } if (softWarned) console.log('结果:全部硬性步骤通过(存在软性告警,见上方 WARN)') else console.log('结果:全部通过')