Files

663 lines
35 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 分道并行跑全部检查,汇总成一张表,给一个总的退出码。
//
// node scripts/check-all.mjs 全部跑一遍(默认 8 道并行)
// node scripts/check-all.mjs ui runs 只跑 check-ui、check-runs(名字去掉 check- 前缀)
// node scripts/check-all.mjs --repeat 2 整套连跑两遍,列出只在某一遍失败的项(偶发)
// node scripts/check-all.mjs --lanes 1 一个接一个地跑(以前的跑法);也可以写 CHECK_LANES=1
// CHECK_DATA_SRC=<数据目录> node scripts/check-all.mjs --stacks 2 --lanes 12
// 推荐:从当前工作区另起 2 套专给检查用的「后端 + vite」,
// 12 道分在上面跑,边改代码边跑也不受热更新打扰(见下)
//
// 默认连 5273 / 8000。对别的实例(比如一份沙箱拷贝)跑时带上地址,会原样传给每个检查:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-all.mjs
// 每个检查的完整输出写进 CHECK_LOGS(默认 /tmp/agentlab-checks/<时间>/),终端只给汇总。
//
// 默认 8 道并行(CHECK_LANES 或 --lanes 改)。各检查对后端只读(几处 POST 都是纯计算的排版接口),
// 截图各有各的目录,时间大多花在固定等待和开关浏览器上,CPU 闲着一大半,所以并行几乎是线性提速。
// 代价是大家挤同一台机器、同一个 vite 开发服务器,有几段还量时间(计时器走没走、动效多少毫秒播完、
// 取景动画落没落地)。三道防线:
// - 最慢的两项(run-states、canvas-fx)按段拆成几份(SPLIT),分到不同的道上。量时间的那几份
// 同一时刻最多跑其中一份;其余的段不量时间,和别的检查一样随便排。汇总表里仍按原检查名合并成一行;
// - 并行时没过的,全部跑完后再单独、一个接一个地重跑一遍(拆开的只重跑没过的那一份)。重跑通过
// 的算通过,但单列成「偶发」:它在挤的时候会挂,迟早也会在别处挂。连跑几遍(--repeat)时不重跑,
// 要的就是原始的偶发率;
// - --stacks N:另起 N 套隔离的「后端 + vite」,各道轮流分到各套上,见下。
//
// 关浏览器:系统 Chrome(154 起)只要开过浏览器上下文,browser.close() 就要干等 4 秒多才退出,
// 而 run-states、canvas-fx 每一段都是开一个浏览器、查完关掉——光这一项,run-states 就从 5 分多钟
// 降到 3 分钟、canvas-fx 从 4 分 40 秒降到 2 分钟。
// 所以默认给各检查的 CHROME_PATH 套一层转发:Playwright 发来「关浏览器」时替 Chrome 应答,直接
// SIGTERM 它(几十毫秒退干净),其余消息原样转发,检查本身一行不改。CHECK_FAST_CLOSE=0 关掉。
//
// --stacks N(或 CHECK_STACKS=N):从当前工作区另起 N 套「后端 + vite」,只给这一次检查用,跑完
// 连同临时目录一起收掉(出错、Ctrl+C 也收)。好处是边改代码边跑也不受影响:后端不带 --reload,
// vite 不监听文件、不开热更新(AGENTLAB_VITE_NO_WATCH,见 frontend/vite.config.ts),起来后先把
// 页面加载一遍,之后测的就是那一刻的代码;几道分摊到几个 vite 上,也不再挤一个。
// - CHECK_DATA_SRC:数据目录,必填。每套拷一份到 /tmp 下的临时目录(APFS 上是写时复制,一秒内),
// 不碰原目录;
// - CHECK_PYTHON:后端解释器,默认 ~/miniforge3/envs/agentlab/bin/python;
// - 端口由系统分配空闲的,避开 5273 / 8000 / 5373 / 8100;后端去掉模型密钥的环境变量,
// seed 不会拿会话里的密钥去种 provider;后端、vite 的输出也在 CHECK_LOGS 里;
// - 后端带 AGENTLAB_SCHEDULER=0:拷贝里的定时任务不到点发起运行、不发 webhook。
// 带 --stacks 时 AGENTLAB_WEB / AGENTLAB_API 不用。
//
// 各检查自己的段过滤(CHECK_ONLY、ONLY、THEMES……)不往下传:整套检查就是整套,只跑某几段
// 请直接跑那个脚本。
//
// 退出码:0 全部通过;1 有检查没通过、中途崩了、超时或一项都没跑;2 前后端连不上(或另起的
// 那几套没起来),一项都没跑。
import { spawn } from 'node:child_process'
import {
chmodSync, constants as fsConstants, cpSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync,
readFileSync, readdirSync, rmSync, writeFileSync,
} from 'node:fs'
import { createServer } from 'node:net'
import { homedir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve } from 'node:path'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = dirname(HERE)
const SYSTEM_CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
// 先跑快的、不开浏览器的,再按页面从外壳往里跑;最后是连真数据的两项
const ORDER = [
'tokens', // 令牌、别名、原生对话框:纯静态扫源码
'copy', // 界面文案的禁用写法:纯静态扫前后端源码
'trace', // 运行态内核的纯函数
'decode', // 事件翻译层
'canvas-layout', // 走线
'ui-kit', // 公共组件和 lib
'guards', // 输入法、未知节点、错误边界
'shell', // 外壳:导航、徽标、命令面板、离开前确认、启动页
'stream', // 回答流的渲染
'evidence', // 可点击证据:报告逐段渲染、证据面板、旧契约按位置标记
'run-states', // 节点卡的运行态
'canvas-fx', // 画布表层的运行态
'studio', // 编排页的编辑
'publish', // 发布前检查与自动修复:发布弹窗、问题面板
'chat', // 问数据
'runs', // 记录
'manage', // 数据、工具、知识、设置
'versions', // 上传表格的版本:回滚、清单、移除一期、清除原件
'catalog', // 数据目录:表清单、起草、单项审阅、批量确认、编辑即确认、409
'catalog-loop', // 数据目录闭环:表助手、审阅队列、导出导入、防错事实的说明
'catalog-profile', // 数据剖析:逐表的后台任务、表清单的剖析一列、表详情的一节、剖析设置
'catalog-changes', // 数据源的变化:审阅页「变化」标签页、数据源角标、表详情的「结构与取值变化」、定时维护、后台任务条
// 复核套件与导出
'schedules', // 定时任务
'compare', // 运行对比
'verify', // 双盲复算、反方审稿
'inquiry', // 审批质询
'challenge', // 反方审稿:审批卡上的概况与逐句展开、检查器
'bundle', // 可验证报告
'whatif', // 数字推演
'report-export', // 报告导出:自包含 HTML、不执行脚本读得到正文、点开数字看出处
// 评测与口径账本(docs/design/next-round 01、02)
'evals', // 评测:题集、加题、发起与进度、结果表、对比与 A/B、口径命中与重复度
'ledger', // 口径账本:证据里的「口径」一步、轮次摘要与出具 / 对账、对账视图、认证队列
// 应用平台(docs/design/app-platform-and-look.md §4.5)。check-auth 不在这里:它要另起一套开着本地账号登录的服务,
// 由编排者单独跑(关登录时的这些检查照常在这里跑)
'apps', // 应用页、印章、点数字看出处、入口校验、实例页
'inbox', // 待办箱:会签进度、超时横幅、动作确认的哈希卡、核实待办
'app-editor', // 应用编辑:固定版本、派生、页面树和属性面板、检查定位、发布时选处置、助手流
'capabilities', // 能力登记、端点、密钥掩码;设置页的身份、通知、审计
'access-keys', // 设置页的访问密钥:服务账号、密钥只显示一次、撤销、轮换、最近的请求、接口说明
'share', // 分享链接:/s 分享页不进外壳、印章常显、页脚、下载报告、撤销后的说明、嵌入;应用编辑里的分享管理
// 普通用户建应用(docs/design/normal-user-apps.md §4.8):全是伪造接口、不连真数据
'blueprint', // 新建应用的起点、蓝图卡片、反问、试运行、发布摘要
'personal', // 个人应用:建、刷新、提交审核
'records', // 记录列表:行级可见、更正历史、附件
'files', // 文件控件:上传、绑定、下载
'ui', // 各页面端到端,连真数据
]
// 各项的耗时(秒)。只决定排队顺序:长的先开跑,短的填空,结果不受影响。新加的检查不在表里就按 60 秒算;
// 拆开的两项看 SPLIT 里各份的 est。2026-09-29 八道并行、关浏览器不干等之后的实测
const ESTIMATE = {
challenge: 30,
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 157, versions: 60, catalog: 150, 'catalog-loop': 60, 'catalog-profile': 90, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 60, compare: 100, verify: 71, inquiry: 60, bundle: 36, whatif: 33, 'report-export': 30,
// 应用平台:还没实测,按同类页面估(全是伪造接口、不连真数据)
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30, share: 30,
// 普通用户建应用:还没实测,按同类页面估
blueprint: 90, personal: 45, records: 30, files: 30,
// 下一轮第 4 波:2026-10-16 两套服务、八道并行时的实测
'catalog-changes': 43, evals: 33, ledger: 31,
// 上面的 evidence、compare、bundle 按分析能力一轮第 2 波之后(2026-10-21,两套服务、八道并行)的实测改过:证据面板、
// 运行对比、可验证报告各加了分析结论的几段
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
// timing 的几份量时间:计时器走没走、动效多少毫秒内播出来又停掉、取景动画落没落地、减少动效时
// 一帧到位——机器一挤就可能读早了。它们同一时刻最多跑其中一份,这就是以前「run-states、canvas-fx
// 互不重叠」的意思,只是收窄到真正量时间的段;其余的段只看结构和文字,挤不挤都一样
const FX_TIMING = ['运行中:胶囊', '失败:接着跑', '跑完:执行路径', '打开宽图', 'Copilot 搭图', 'Copilot 这一轮删掉', '系统关了动效']
const SPLIT = {
'run-states': [
{ part: 'themes', env: { RUN_STATES_ONLY: 'themes' }, timing: true, est: 86 },
{ part: 'interact,exits,reduced', env: { RUN_STATES_ONLY: 'interact,exits,reduced' }, timing: true, est: 45 },
{ part: '其余', env: { RUN_STATES_SKIP: 'themes,interact,exits,reduced' }, est: 60 },
],
'canvas-fx': [
{ part: '量时间的段', env: { FX_ONLY: FX_TIMING.join(',') }, timing: true, est: 52 },
{ part: '其余', env: { FX_SKIP: FX_TIMING.join(',') }, est: 72 },
],
}
const TIMEOUT_MS = Number(process.env.CHECK_TIMEOUT_MS ?? 15 * 60_000)
// 各检查只跑其中几段、只跑一套主题的开关。壳里留着一个 ONLY=… 就会让整套检查悄悄只跑一小截,
// 却照样报「全部通过」,所以一律不传给子进程
const FILTERS = ['CHECK_ONLY', 'ONLY', 'THEMES', 'FX_ONLY', 'FX_SKIP', 'STUDIO_ONLY', 'RUN_STATES_ONLY', 'RUN_STATES_SKIP',
'UI_KIT_ONLY', 'CHECK_THEME', 'EVIDENCE_ONLY', 'PUBLISH_ONLY', 'RUNS_ONLY', 'STREAM_ONLY', 'COMPARE_ONLY', 'INQUIRY_ONLY',
'SHARE_ONLY', 'ACCESS_KEYS_ONLY', 'APP_EDITOR_ONLY', 'CAPABILITIES_ONLY']
// 另起的后端不拿会话里的模型密钥:seed 见到它们会种一个真的 provider
const SECRETS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY']
// 另起的几套不许占这几个口:开发(5273 / 8000)和沙箱(5373 / 8100)
const RESERVED_PORTS = new Set([5273, 8000, 5373, 8100])
// 新加的 check-*.mjs 忘了写进 ORDER 就永远不会跑:列出来提醒
const unlisted = readdirSync(HERE)
.map((f) => f.match(/^check-(.+)\.mjs$/)?.[1])
.filter((n) => n && n !== 'all' && !ORDER.includes(n))
const args = process.argv.slice(2)
let repeat = 1
let lanes = Math.max(1, Number(process.env.CHECK_LANES) || 8)
let stackCount = Math.max(0, Number(process.env.CHECK_STACKS) || 0)
const picked = []
for (let i = 0; i < args.length; i++) {
const [flag, inline] = args[i].split('=')
const value = () => Number(inline ?? args[++i])
if (flag === '--repeat') repeat = Math.max(1, value() || 1)
else if (flag === '--lanes') lanes = Math.max(1, value() || 1)
else if (flag === '--stacks') stackCount = Math.max(0, value() || 0)
else picked.push(args[i].replace(/^check-/, '').replace(/\.mjs$/, ''))
}
const unknown = picked.filter((n) => !ORDER.includes(n))
if (unknown.length) {
console.error(`认不出的检查:${unknown.join('、')}。可选的有:${ORDER.join(' ')}`)
process.exit(2)
}
const names = picked.length ? ORDER.filter((n) => picked.includes(n)) : ORDER
// 纯静态的几项不用起服务
const STATIC = new Set(['tokens', 'copy'])
const needsServers = names.some((n) => !STATIC.has(n))
// ---------------------------------------------------------------- 收尾
//
// 自己拉起来的东西(另起的几套服务、正在跑的检查、临时目录)登记在这里。正常跑完、出错、
// Ctrl+C、被 kill 都走一遍;进程退出前只能做同步的事,所以这里一律同步
const cleanups = []
let cleaned = false
function cleanup() {
if (cleaned) return
cleaned = true
for (const fn of cleanups.reverse()) {
try { fn() } catch { /* 收尾时能收多少收多少 */ }
}
}
process.on('exit', cleanup)
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(sig, () => {
console.error(`\n收到 ${sig}:停掉检查和另起的服务,删掉临时目录`)
cleanup()
process.exit(130)
})
}
for (const ev of ['uncaughtException', 'unhandledRejection']) {
process.on(ev, (e) => {
console.error(e)
cleanup()
process.exit(1)
})
}
const running = new Set() // 正在跑的检查子进程:被中断时一起停掉
cleanups.push(() => { for (const c of running) c.kill('SIGTERM') })
const TMP = mkdtempSync('/tmp/agentlab-check-')
cleanups.push(() => rmSync(TMP, { recursive: true, force: true }))
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
const LOGS = process.env.CHECK_LOGS ?? join('/tmp/agentlab-checks', stamp)
mkdirSync(LOGS, { recursive: true })
const baseEnv = { ...process.env }
for (const k of FILTERS) delete baseEnv[k]
// ---------------------------------------------------------------- 关浏览器不干等
/**
* 套在真 Chrome 外面的转发:Playwright 用 fd 3 发、fd 4 收(--remote-debugging-pipe),消息以 \0
* 分隔。见到 Browser.close 就不转发,替 Chrome 应答、再 SIGTERM 它——Chrome 收到 Browser.close
* 之后连 SIGTERM 也一起晾着,一样要等满 4 秒。应答要插在 Chrome 两条消息之间:它正写到一半就
* 等这条写完再插,不然 Playwright 那头的分帧就乱了。管道用 net.Socket 读写:fs 流读管道会占住
* 线程池,进程退不掉
*/
const RELAY = `import { spawn } from 'node:child_process'
import { Socket } from 'node:net'
const child = spawn(process.argv[2], process.argv.slice(3), { stdio: ['ignore', 'inherit', 'inherit', 'pipe', 'pipe'] })
const fromPw = new Socket({ fd: 3, readable: true, writable: false })
const toPw = new Socket({ fd: 4, readable: false, writable: true })
for (const s of [fromPw, toPw, child.stdio[3], child.stdio[4]]) s.on('error', () => {})
let tail = ''
let ack = null // 等着插进去的应答
let sealed = false // 应答已经发出,Chrome 之后说的都不再转
let between = true // 转给 Playwright 的最后一个字节是 \\0:正好在两条消息之间
const seal = () => {
toPw.write(ack)
sealed = true
child.kill('SIGTERM')
}
fromPw.on('data', (chunk) => {
if (ack) return
const parts = (tail + chunk.toString('latin1')).split('\\0')
tail = parts.pop()
for (const m of parts) {
const hit = m.includes('"method":"Browser.close"') && /"id":(-?\\d+)/.exec(m)
if (hit) {
ack = '{"id":' + hit[1] + ',"result":{}}\\0'
if (between) seal()
else setTimeout(() => { if (!sealed) seal() }, 1000) // 兜底:这条迟迟写不完就不等了
return
}
child.stdio[3].write(Buffer.from(m + '\\0', 'latin1'))
}
})
fromPw.on('end', () => child.stdio[3].end())
child.stdio[4].on('data', (chunk) => {
if (sealed) return
if (ack) {
const i = chunk.indexOf(0)
if (i < 0) return void toPw.write(chunk)
toPw.write(chunk.subarray(0, i + 1))
return seal()
}
toPw.write(chunk)
between = chunk[chunk.length - 1] === 0
})
child.on('exit', (code, sig) => {
const done = () => process.exit(code ?? (sig ? 0 : 1))
toPw.end(done)
setTimeout(done, 500)
})
for (const s of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(s, () => child.kill(s))
`
const sq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
let chromePath = process.env.CHROME_PATH ?? SYSTEM_CHROME
if (process.env.CHECK_FAST_CLOSE !== '0' && existsSync(chromePath)) {
writeFileSync(join(TMP, 'chrome-relay.mjs'), RELAY)
const wrapper = join(TMP, 'chrome')
writeFileSync(wrapper, `#!/bin/sh\nexec ${sq(process.execPath)} ${sq(join(TMP, 'chrome-relay.mjs'))} ${sq(chromePath)} "$@"\n`)
chmodSync(wrapper, 0o755)
chromePath = wrapper
}
baseEnv.CHROME_PATH = chromePath
// ---------------------------------------------------------------- 另起的几套服务
const reach = async (url, want) => {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(5000) })
if (!r.ok) return `HTTP ${r.status}`
return want && !(await r.text()).includes(want) ? '答话的不是 agentlab' : ''
} catch (e) {
return e.cause?.code ?? e.name ?? String(e)
}
}
const takenPorts = new Set()
function freePort() {
return new Promise((resolvePort, reject) => {
const srv = createServer()
srv.on('error', reject)
srv.listen(0, '127.0.0.1', () => {
const { port } = srv.address()
srv.close(() => {
if (RESERVED_PORTS.has(port) || takenPorts.has(port)) freePort().then(resolvePort, reject)
else { takenPorts.add(port); resolvePort(port) }
})
})
})
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const tailOf = (file, n = 6) => {
try { return readFileSync(file, 'utf8').split('\n').filter(Boolean).slice(-n).join('\n ') } catch { return '' }
}
/** 起一个服务:自成一个进程组,收尾时整组杀掉(uvicorn、vite 自己再拉的子进程也在组里) */
function launch(cmd, argv, { cwd, env, log }) {
const fd = openSync(log, 'a')
const child = spawn(cmd, argv, { cwd, env, detached: true, stdio: ['ignore', fd, fd] })
child.exited = new Promise((r) => child.on('exit', r))
let dead = false
child.on('exit', () => { dead = true })
child.isDead = () => dead
const kill = (sig) => { if (!dead) { try { process.kill(-child.pid, sig) } catch { /* 已经没了 */ } } }
child.stop = async () => {
kill('SIGTERM')
await Promise.race([child.exited, sleep(5000)])
kill('SIGKILL')
}
cleanups.push(() => kill('SIGKILL'))
return child
}
async function waitUntil(label, url, want, child, log, ms) {
const end = Date.now() + ms
for (;;) {
if (child.isDead()) throw new Error(`${label}没起来,进程退出了:\n ${tailOf(log)}`)
if (!(await reach(url, want))) return
if (Date.now() > end) throw new Error(`${label} ${Math.round(ms / 1000)} 秒内没就绪(${url}):\n ${tailOf(log)}`)
await sleep(300)
}
}
async function startStack(i, dataSrc, python) {
const dir = join(TMP, `stack-${i}`)
// 写时复制地拷一份数据目录。-shm 是正在用它的进程的共享内存索引,不拷:打开拷贝时按 -wal 重建
cpSync(dataSrc, join(dir, 'data'), { recursive: true, mode: fsConstants.COPYFILE_FICLONE, filter: (p) => !p.endsWith('-shm') })
const [apiPort, webPort] = [await freePort(), await freePort()]
const WEB = `http://127.0.0.1:${webPort}`
const API = `http://127.0.0.1:${apiPort}/api`
const env = { ...baseEnv }
for (const k of SECRETS) delete env[k]
// 后端照 scripts/dev.sh 的起法,只是不带 --reload
const backendLog = join(LOGS, `stack-${i}.backend.log`)
const backend = launch(python, ['-m', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', String(apiPort)], {
cwd: join(ROOT, 'backend'), log: backendLog,
env: {
...env, PYTHONUNBUFFERED: '1', AGENTLAB_DATA_DIR: join(dir, 'data'), AGENTLAB_HOST: '127.0.0.1', AGENTLAB_PORT: String(apiPort),
AGENTLAB_CORS_ORIGINS: JSON.stringify([WEB, `http://localhost:${webPort}`]),
// 拷来的库里有定时任务:调度一开,就会替原库到点发起运行、发出 webhook
AGENTLAB_SCHEDULER: '0',
},
})
await waitUntil(`第 ${i} 套后端`, `${API}/health`, '"agentlab"', backend, backendLog, 90_000)
const viteLog = join(LOGS, `stack-${i}.vite.log`)
const vite = launch(process.execPath, [join(ROOT, 'frontend', 'node_modules', 'vite', 'bin', 'vite.js'),
'--port', String(webPort), '--strictPort', '--host', '127.0.0.1'], {
cwd: join(ROOT, 'frontend'), log: viteLog,
env: { ...env, AGENTLAB_PORT: String(apiPort), AGENTLAB_VITE_NO_WATCH: '1', AGENTLAB_VITE_CACHE_DIR: join(dir, 'vite') },
})
await waitUntil(`第 ${i} 套 vite`, `${WEB}/`, '', vite, viteLog, 60_000)
return { i, WEB, API, backend, vite }
}
/**
* 把几个入口页各加载一遍:vite 按需转译,第一个打开页面的检查不用替大家等转译。每套的依赖在
* 自己的缓存目录里从头预构建(关了热更新,指纹和平时的开发服务器不一样),预构建赶上页面加载
* 引起的整页重载也发生在这里,不落在检查中途
*/
async function warmUp(stacks) {
const { chromium } = await import(join(ROOT, 'frontend', 'node_modules', 'playwright-core', 'index.mjs'))
const browser = await chromium.launch({ executablePath: chromePath })
try {
await Promise.all(stacks.map(async (s) => {
const page = await browser.newPage()
for (const path of ['/', '/studio', '/preview.html', '/ui-harness.html']) {
await page.goto(`${s.WEB}${path}`, { waitUntil: 'networkidle', timeout: 60_000 })
}
await page.close()
}))
} finally {
await browser.close()
}
}
let stacks = []
if (stackCount > 0 && needsServers) {
const dataSrc = process.env.CHECK_DATA_SRC && resolve(process.env.CHECK_DATA_SRC)
const python = process.env.CHECK_PYTHON ?? join(homedir(), 'miniforge3', 'envs', 'agentlab', 'bin', 'python')
const problems = []
if (!dataSrc) problems.push('--stacks 要知道拿哪份数据:用 CHECK_DATA_SRC=<数据目录> 指定(里面有 agentlab.db 的那一层)')
else if (!existsSync(join(dataSrc, 'agentlab.db'))) problems.push(`CHECK_DATA_SRC=${dataSrc} 里没有 agentlab.db`)
if (!existsSync(python)) problems.push(`找不到后端解释器 ${python},用 CHECK_PYTHON 指定`)
if (problems.length) {
for (const p of problems) console.error(`✗ ${p}`)
process.exit(2)
}
const t0 = Date.now()
console.log(`另起 ${stackCount} 套后端 + vite(数据拷自 ${dataSrc})……`)
try {
stacks = await Promise.all(Array.from({ length: stackCount }, (_, k) => startStack(k + 1, dataSrc, python)))
await warmUp(stacks)
} catch (e) {
console.error(`✗ ${String(e?.message ?? e)}`)
process.exit(2)
}
for (const s of stacks) console.log(` 第 ${s.i} 套:前端 ${s.WEB} · 后端 ${s.API}`)
console.log(` ${Math.round((Date.now() - t0) / 1000)} 秒就绪,日志 ${LOGS}/stack-*.log`)
} else {
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
// 连不上就别跑:十几个检查挨个报「打不开页面」只会淹没真正的原因
if (needsServers) {
const [web, api] = await Promise.all([reach(WEB), reach(`${API}/health`)])
if (web || api) {
if (web) console.error(`✗ 前端连不上:${WEB}(${web})`)
if (api) console.error(`✗ 后端连不上:${API}/health(${api})`)
console.error(' 先把前后端起来(./scripts/dev.sh),或者用 AGENTLAB_WEB / AGENTLAB_API 指到要检查的那一份,或者带上 --stacks')
process.exit(2)
}
}
stacks = [{ i: 0, WEB, API }]
console.log(`前端 ${WEB} · 后端 ${API}`)
}
const dropped = FILTERS.filter((k) => process.env[k])
if (dropped.length) console.log(`没传给各检查的段过滤:${dropped.map((k) => `${k}=${process.env[k]}`).join(' ')}(只跑某几段请直接跑那个脚本)`)
// ---------------------------------------------------------------- 排队与跑
// 一道一次跑一份。并行时最慢的两项拆开;一个接一个地跑时拆开没有好处,整项跑
const split = lanes > 1
const jobs = names.flatMap((name) => (split && SPLIT[name]
? SPLIT[name].map((p) => ({ name, part: p.part, env: p.env, timing: !!p.timing, est: p.est ?? 60 }))
: [{ name, part: '', env: {}, timing: false, est: ESTIMATE[name] ?? 60 }]))
lanes = Math.min(lanes, jobs.length)
console.log(`${names.length} 项检查${jobs.length > names.length ? `(拆成 ${jobs.length} 份)` : ''}${repeat > 1 ? ` × ${repeat} 遍` : ''},`
+ `${lanes > 1 ? `${lanes} 道并行` : '一个接一个'}${stacks.length > 1 ? `、分在 ${stacks.length} 套服务上` : ''},完整输出在 ${LOGS}/\n`)
const label = (r) => `check-${r.name}${r.part ? `〔${r.part}〕` : ''}`
/**
* 跑一份检查,输出落盘,数 ✓ / ✗。退出码非 0 却一个 ✗ 都没有的,是中途崩了;
* 退出码 0 却一个 ✓ 都没有的,是什么都没查——都不算通过
*/
function runOne(job, pass, stack, suffix = '') {
const file = join(LOGS, `${job.name}${job.part ? `.${job.part.replace(/[^\w一-鿿-]+/g, '_')}` : ''}${repeat > 1 ? `.${pass}` : ''}${suffix}.log`)
const out = createWriteStream(file)
const started = Date.now()
return new Promise((resolveRun) => {
const child = spawn(process.execPath, [join(HERE, `check-${job.name}.mjs`)], {
env: { ...baseEnv, AGENTLAB_WEB: stack.WEB, AGENTLAB_API: stack.API, ...job.env },
stdio: ['ignore', 'pipe', 'pipe'],
})
running.add(child)
child.stdout.pipe(out, { end: false })
child.stderr.pipe(out, { end: false })
let timedOut = false
// 先 SIGTERM:Playwright 收到它会把自己拉起来的 Chrome 一起关掉;直接 SIGKILL 会留下孤儿浏览器
let hard
const timer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
hard = setTimeout(() => child.kill('SIGKILL'), 10_000)
}, TIMEOUT_MS)
child.on('close', (code) => {
running.delete(child)
clearTimeout(timer)
clearTimeout(hard)
out.end(() => {
const text = readFileSync(file, 'utf8')
const lines = text.split('\n')
// 检查项是缩进的一行;顶格的「✓ 全部通过」「✗ N 项未通过」是脚本自己的总结
const ok = lines.filter((l) => /^\s+✓ /.test(l)).length
const bad = lines.filter((l) => /^\s+✗ /.test(l)).map((l) => l.trim())
const secs = Math.round((Date.now() - started) / 1000)
const crashed = !timedOut && code !== 0 && !bad.length
const empty = !timedOut && code === 0 && ok === 0
const passed = code === 0 && ok > 0
const tail = crashed || empty ? lines.filter(Boolean).slice(-4).join(' ⏎ ').slice(0, 300) : ''
resolveRun({ name: job.name, part: job.part, job, pass, code, ok, bad, secs, timedOut, crashed, empty, passed, tail, file })
})
})
})
}
/** 拆开跑的几份合成一行:通过要每份都通过,没过的项前面标上是哪一份 */
function merge(rs) {
if (rs.length === 1) return rs[0]
const worst = rs.find((r) => r.timedOut) ?? rs.find((r) => r.crashed) ?? rs.find((r) => r.empty) ?? rs.find((r) => !r.passed)
return {
name: rs[0].name, part: '', pass: rs[0].pass, parts: rs,
ok: rs.reduce((n, r) => n + r.ok, 0),
bad: rs.flatMap((r) => r.bad.map((l) => `〔${r.part}〕${l}`)),
secs: rs.reduce((n, r) => n + r.secs, 0),
timedOut: rs.some((r) => r.timedOut), crashed: rs.some((r) => r.crashed), empty: rs.some((r) => r.empty),
passed: rs.every((r) => r.passed),
tail: worst?.tail ? `〔${worst.part}〕${worst.tail}` : '',
file: worst?.file ?? rs[0].file,
}
}
const limit = TIMEOUT_MS >= 60_000 ? `${Math.round(TIMEOUT_MS / 60_000)} 分钟` : `${Math.round(TIMEOUT_MS / 1000)} 秒`
const verdict = (r) => r.timedOut ? `超时(${limit})`
: r.crashed ? '中途崩了'
: r.empty ? '一项都没跑'
: r.passed ? '通过'
: `${r.bad.length} 项没过`
let spent = 0 // 各份累计的秒数,重跑的那次也算
const report = (r) => {
const mark = r.passed ? '✓' : '✗'
const secs = r.parts ? `${r.secs}s(${r.parts.length} 份:${r.parts.map((p) => `${p.secs}s`).join(' / ')})` : `${r.secs}s`
console.log(`${mark} ${label(r).padEnd(20)} ${verdict(r).padEnd(10)} ${String(r.ok).padStart(4)} 项通过 · ${secs}`)
for (const l of r.bad.slice(0, 5)) console.log(` ${l}`)
if (r.bad.length > 5) console.log(` ……另有 ${r.bad.length - 5} 项,见 ${r.parts ? r.parts.filter((p) => !p.passed).map((p) => p.file).join('、') : r.file}`)
if (r.tail) console.log(` ${r.tail}`)
}
/**
* 一遍:lanes 道同时从队里取,第 k 道用第 k % N 套服务。长的排前面;量时间的一份在跑时,
* 别的量时间的先让给队里后面的。量时间的几份反正一份接一份,合起来就是一条长链,按整条链的
* 长度排队:开头就起跑,一份跑完下一份马上接上,不被别的长项压到后面。一项的几份都跑完了才
* 打印这一项(合成一行),返回时按 ORDER 排好
*/
async function runPass(pass) {
const chain = jobs.filter((j) => j.timing).reduce((n, j) => n + j.est, 0)
const rank = (j) => (j.timing ? chain : j.est)
const queue = [...jobs].sort((a, b) => rank(b) - rank(a) || b.est - a.est)
const done = []
let timingBusy = false
let waiting = [] // 等量时间的那一份跑完的几道
const take = () => {
const i = queue.findIndex((j) => !(j.timing && timingBusy))
return i < 0 ? null : queue.splice(i, 1)[0]
}
const lane = async (k) => {
const stack = stacks[k % stacks.length]
while (queue.length) {
const job = take()
if (!job) {
// 队里只剩量时间的,而另一份正在跑:等它跑完再取
await new Promise((r) => waiting.push(r))
continue
}
if (job.timing) timingBusy = true
const r = await runOne(job, pass, stack)
spent += r.secs
if (job.timing) {
timingBusy = false
const woken = waiting
waiting = []
for (const wake of woken) wake()
}
done.push(r)
const mine = jobs.filter((j) => j.name === job.name).map((j) => done.find((d) => d.job === j))
if (mine.every(Boolean)) report(merge(mine))
}
}
await Promise.all(Array.from({ length: lanes }, (_, k) => lane(k)))
return done
}
const byName = (done) => names.map((n) => merge(jobs.filter((j) => j.name === n).map((j) => done.find((r) => r.job === j))))
const results = []
const flakyRerun = [] // 并行时没过、单独重跑通过的
const started = Date.now()
for (let pass = 1; pass <= repeat; pass++) {
if (repeat > 1) console.log(`—— 第 ${pass} 遍 ——`)
const done = await runPass(pass)
if (lanes > 1 && repeat === 1 && done.some((r) => !r.passed)) {
const failed = jobs.map((j) => done.find((r) => r.job === j)).filter((r) => !r.passed)
console.log(`\n并行时没过的 ${failed.length} 份,单独重跑一遍:`)
for (const first of failed) {
const again = await runOne(first.job, pass, stacks[0], '.solo')
spent += again.secs
report(again)
if (again.passed) flakyRerun.push({ first, again })
done[done.indexOf(first)] = again
}
}
results.push(...byName(done))
if (repeat > 1) console.log('')
}
if (flakyRerun.length) {
console.log('\n偶发(并行时没过、单独重跑通过):')
for (const { first } of flakyRerun) {
const why = first.bad.length ? first.bad.slice(0, 3).join(';') : verdict(first)
console.log(` ${label(first)}:${why}(见 ${first.file})`)
}
}
console.log('')
// 连跑几遍时:同一项只在部分遍数里失败,就是偶发——比稳定失败更该查。按项名认,不带
// 「 — 」后面的读数:每遍都没过、只是毫秒数或像素差不一样的,是稳定失败,不是偶发
if (repeat > 1) {
const seen = new Map() // `${name}\t${项名}` → [{ pass, line }]
for (const r of results) {
for (const p of r.parts ?? [r]) {
const lines = p.crashed || p.timedOut || p.empty ? [`${p.part ? `〔${p.part}〕` : ''}${verdict(p)}`]
: p.bad.map((l) => `${p.part ? `〔${p.part}〕` : ''}${l}`)
for (const line of lines) {
const k = `${r.name}\t${line.replace(/ — .*$/, '')}`
seen.set(k, [...(seen.get(k) ?? []), { pass: r.pass, line }])
}
}
}
const flaky = [...seen].filter(([, hits]) => new Set(hits.map((h) => h.pass)).size < repeat)
if (flaky.length) {
console.log('偶发(只在部分遍数里失败):')
for (const [k, hits] of flaky) {
const name = k.split('\t')[0]
for (const h of hits) console.log(` check-${name} 第 ${h.pass} 遍:${h.line}`)
}
console.log('')
}
}
if (unlisted.length && !picked.length) {
console.log(`没跑:${unlisted.map((n) => `check-${n}`).join('、')} 不在 check-all 的清单里(要算进来就加进 ORDER)\n`)
}
const failedRuns = results.filter((r) => !r.passed)
const total = results.reduce((n, r) => n + r.ok, 0)
const minutes = (secs) => `${Math.floor(secs / 60)} 分 ${String(secs % 60).padStart(2, '0')} 秒`
const wall = Math.round((Date.now() - started) / 1000)
const clock = lanes > 1 ? `用时 ${minutes(wall)}(各份累计 ${minutes(spent)})` : `共 ${minutes(wall)}`
const flakyNote = flakyRerun.length ? `,其中 ${flakyRerun.length} 份是重跑才过的,见上面「偶发」` : ''
if (failedRuns.length) {
console.log(`✗ ${failedRuns.length}/${results.length} 次检查没通过(${[...new Set(failedRuns.map((r) => `check-${r.name}`))].join('、')})· ${clock}`)
} else {
console.log(`✓ 全部通过:${results.length} 次检查、${total} 项${flakyNote} · ${clock}`)
}
// 另起的几套先好好停(SIGTERM,等它们退),停不下来的由 cleanup 里的 SIGKILL 兜底
await Promise.all(stacks.flatMap((s) => [s.vite?.stop?.(), s.backend?.stop?.()]))
process.exit(failedRuns.length ? 1 : 0)