132 lines
5.5 KiB
JavaScript
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('结果:全部通过')
|