Files
daywork/scripts/ci.mjs
T
root 9a91c4c85d
CI / 测试套件(协议/用户管理/单测/双设备/实时链路/构建) (push) Failing after 30s
CI / 依赖漏洞扫描(软性检查) (push) Failing after 30s
ci: 统一测试入口 scripts/ci.mjs(协议/用户管理/单测/双设备/实时链路/构建/依赖审计)+ Gitea Actions 工作流
2026-10-16 21:03:54 +08:00

132 lines
5.5 KiB
JavaScript

// 统一 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('结果:全部通过')