Files
AgentLab/scripts/check-runs.mjs
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

714 lines
44 KiB
JavaScript
Raw 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.
// 记录页(/runs)的检查:页签、状态筛选、分页、时间、待审批、删除保护、运行中接流、
// 失败的排错路径、封存凭证、提取模板。
//
// 守的是审查里抓到的几条死路:待审批的运行排在第 145 位、列表只取 100 条,点了
// 徽标也找不到;输入「失败」筛出来的是两条成功的运行;时间整体慢 8 小时;正在跑
// 的运行一动不动、却能删;封存过的正式运行一键就删掉了。
//
// 不写库:探针拦下所有非 GET 的 /api 请求,按场景回假响应。库里没有的形态(正在
// 跑的、封存过的正式运行)用 page.route 伪造 GET,用 routeWebSocket 伪造实时流。
// 跑之前前端和后端都得起着,并且指向同一份数据:
// AGENTLAB_WEB=http://localhost:5373 AGENTLAB_API=http://localhost:8100/api node scripts/check-runs.mjs
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const CHROME = process.env.CHROME_PATH
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const getJson = async (path) => (await fetch(API + path)).json()
const browser = await chromium.launch({ executablePath: CHROME })
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
// ---- 探针:非 GET 一律不放行,按场景回假响应 ----
const writes = []
const fakes = new Map() // `${METHOD} ${pathname}` → (url) => {status, json}
let offline = false // 断网:所有 /api 请求都够不着
let healthAborted = 0
await page.route('**/api/**', async (route) => {
const r = route.request()
const url = new URL(r.url())
if (offline) {
if (url.pathname === '/api/health') healthAborted++
return route.abort('internetdisconnected')
}
if (r.method() === 'GET') {
const fake = fakes.get(`GET ${url.pathname}`)
if (fake) return route.fulfill(fake(url))
return route.continue()
}
writes.push(`${r.method()} ${url.pathname}${url.search}`)
const fake = fakes.get(`${r.method()} ${url.pathname}`)
if (fake) return route.fulfill(fake(url, r))
return route.abort()
})
const rowsOf = () => page.locator('[data-runs-list] [data-run-id]')
const settle = async () => {
await page.waitForLoadState('networkidle')
await page.waitForTimeout(350)
}
const statuses = () => rowsOf().evaluateAll((els) => els.map((e) => e.getAttribute('data-status')))
/** 做一个会触发列表重查的动作,等到那一次列表请求(limit=50)回来、列表画完 */
const listAfter = async (act, match) => {
const done = page.waitForResponse((r) => {
const u = new URL(r.url())
return u.pathname === '/api/runs' && u.searchParams.get('limit') === '50' && match(u.searchParams)
}, { timeout: 8000 }).catch(() => null)
await act()
await done
await page.waitForTimeout(250)
}
const search = (text, match) => listAfter(() => page.locator('[data-runs-search]').fill(text), match)
const ids = () => rowsOf().evaluateAll((els) => els.map((e) => e.getAttribute('data-run-id')))
/** 页内记下详情 data-run-code 的每一次变化:一闪而过的中间态定时采样会漏掉 */
const recordCodes = () => page.evaluate(() => {
const el = document.querySelector('[data-run-detail]')
const seen = [el?.getAttribute('data-run-code')]
window.__runCodes = seen
if (el) new MutationObserver(() => seen.push(el.getAttribute('data-run-code')))
.observe(el, { attributes: true, attributeFilter: ['data-run-code'] })
})
const recordedCodes = () => page.evaluate(() => window.__runCodes ?? [])
// ------------------------------------------------------------------ 页签
console.log('=== 页签与地址 ===')
await page.goto(`${WEB}/runs`, { waitUntil: 'networkidle' })
await rowsOf().first().waitFor({ timeout: 10000 })
check('默认是「全部」页签', await page.locator('[role=tab][data-tab=all]').getAttribute('aria-selected') === 'true')
check('页头写着「记录」', (await page.locator('[data-runs-page] header h1').innerText()).includes('记录'))
const headerH = await page.locator('[data-runs-page] > header').evaluate((el) => el.getBoundingClientRect().height)
check('页头和管理页一样高(48px,toast 不压工具栏)', headerH === 48, `${headerH}px`)
// 键盘:↓ 在行之间移动焦点,不打开
await rowsOf().first().focus()
await page.keyboard.press('ArrowDown')
const focused = await page.evaluate(() => document.activeElement?.getAttribute('data-run-id'))
check('↓ 把焦点移到下一行', focused === (await ids())[1] && !/\/runs\/./.test(new URL(page.url()).pathname))
await page.locator('[role=tab][data-tab=failed]').click()
await settle()
check('点「失败」地址变成 ?tab=failed', new URL(page.url()).searchParams.get('tab') === 'failed')
const failedStatuses = await statuses()
check('「失败」页签里全是失败的运行', failedStatuses.length > 0 && failedStatuses.every((s) => s === 'failed'),
`${failedStatuses.length} 行`)
check('失败行的第二行是原因,不是输入', await page.locator('[data-runs-list] [data-run-reason]').count() === failedStatuses.length)
const runningReq = page.waitForRequest((r) => new URL(r.url()).pathname === '/api/runs'
&& new URL(r.url()).searchParams.get('status') === 'running,queued')
await page.locator('[role=tab][data-tab=running]').click()
check('「运行中」页签按 status=running,queued 查服务端', await runningReq.then(() => true, () => false))
await settle()
check('「运行中」页签里只有在跑的', (await statuses()).every((s) => s === 'running' || s === 'queued'))
await page.goto(`${WEB}/runs?tab=bogus`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
check('认不出的页签落回「全部」并纠正地址', !new URL(page.url()).searchParams.has('tab'))
// ------------------------------------------------------------------ 待审批
console.log('\n=== 待审批页签 ===')
const pending = await getJson('/approvals?status=pending&limit=500')
// 「别处批掉了」要用的实时流:routeWebSocket 只管注册之后加载的页面,先挂上
let resumeSocket = false
if (pending.length) {
const target = pending[0]
const lastSeq = (await getJson(`/runs/${target.run_id}/events`)).at(-1)?.seq ?? 0
await page.routeWebSocket(new RegExp(`/api/runs/${target.run_id}/stream`), (ws) => {
resumeSocket = true
const t = Date.now() / 1000
ws.send(JSON.stringify({ seq: lastSeq + 1, type: 'run.resumed', node_id: target.node_id, ts: t, data: { actor: '张工' } }))
ws.send(JSON.stringify({ seq: lastSeq + 2, type: 'node.started', node_id: target.node_id, ts: t + 0.05, data: { resumed: true } }))
})
}
await page.goto(`${WEB}/runs?tab=approvals`, { waitUntil: 'networkidle' })
await page.waitForTimeout(400)
const approvalRows = page.locator('[data-approvals-list] [data-approval-id]')
const shownApprovals = await approvalRows.evaluateAll((els) => els.map((e) => e.getAttribute('data-approval-id')))
check('沙箱里的待审批都在页签里', pending.length > 0 && pending.every((a) => shownApprovals.includes(a.id)),
`API ${pending.length} 条 / 页面 ${shownApprovals.length} 条`)
const allRuns = await getJson('/runs?limit=200')
const deep = pending.filter((a) => allRuns.findIndex((r) => r.id === a.run_id) >= 100)
check('排在最近 100 条之外的待审批也看得到(原来的死路)', deep.every((a) => shownApprovals.includes(a.id)),
`${deep.length} 条在第 100 名之后`)
check('每条都写着等了多久', await page.locator('[data-approvals-list] [data-approval-age]').count() === shownApprovals.length)
if (pending.length) {
const rowText = await page.locator(`[data-approval-id="${pending[0].id}"]`).innerText()
check('每条写着工作流名和节点', rowText.includes(pending[0].workflow_name ?? '') && rowText.includes(pending[0].node_label ?? pending[0].node_id),
rowText.replace(/\s+/g, ' ').slice(0, 80))
check('每条有处理入口', rowText.includes('去处理'))
}
const badge = await page.locator('[role=tab][data-tab=approvals]').innerText()
check('页签上的数和待审批条数一致', badge.includes(String(pending.length)), badge.replace(/\s+/g, ' '))
if (pending.length) {
const target = pending[0]
await page.locator(`[data-approval-id="${target.id}"]`).click()
await page.waitForURL(`**/runs/${target.run_id}**`)
await page.locator('[data-run-detail]').waitFor()
await page.waitForTimeout(500)
check('点进去是那次运行,地址保留 ?tab=approvals', new URL(page.url()).searchParams.get('tab') === 'approvals')
check('详情显示等审批的横幅', await page.locator('[data-run-banner=waiting]').count() === 1)
check('审批卡在时间线里', await page.locator(`[data-approval-card="${target.id}"]`).count() === 1)
check('详情头的状态是「等待审批」', await page.locator('[data-run-detail]').getAttribute('data-run-code') === 'waiting')
const docScroll = await page.evaluate(() => document.scrollingElement.scrollHeight - innerHeight)
check('整页不滚动:滚的是时间线自己', docScroll <= 0, `溢出 ${docScroll}px`)
// 别处(画布、问数据、另一个标签页)把这张批掉了:catalog 轮询发现审批没了。
// 这一刻 run 还没重查的话是「interrupted、没有审批」,会被错当成已挂起——
// 要一起重查,看到它接着跑了,并接上实时流
// 后端先把审批记成已回复、再把运行改成 running:第一次重查故意落在这个空档里
// (运行还是 interrupted、审批已经没了),不能就此判成挂起
const full = await getJson(`/runs/${target.run_id}`)
let runReads = 0
fakes.set('GET /api/approvals', () => ({ status: 200, json: [] }))
fakes.set(`GET /api/runs/${target.run_id}`, () => ({
status: 200, json: { ...full, status: runReads++ === 0 ? 'interrupted' : 'running' },
}))
await recordCodes()
const resumed = await page.waitForFunction(
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'running',
null, { timeout: 9000 },
).then(() => true, () => false)
await page.waitForTimeout(300)
const codes = await recordedCodes()
check('别处批掉后:接上实时流、状态变成运行中', resumed && resumeSocket, codes.at(-1) ?? '')
check('中间没有闪成「已挂起」', !codes.includes('held'), [...new Set(codes)].join(' → '))
fakes.delete('GET /api/approvals')
fakes.delete(`GET /api/runs/${target.run_id}`)
// 别处批掉,剩下的几步在下一轮轮询之前就跑完了(审批靠近末尾的都这样):
// 重查时运行已经是已完成。头上跟着变不够,别处发生的事件要补进时间线——
// 原来只在「还在跑」时接流,这种就停在审批卡上,连「放行」那一行都没有
const other = pending[1] ?? pending[0]
const otherRun = await getJson(`/runs/${other.run_id}`)
const otherEvents = await getJson(`/runs/${other.run_id}/events`)
const lastOther = otherEvents.at(-1)?.seq ?? 0
const tf = Date.now() / 1000
const tail = [
{ seq: lastOther + 1, type: 'human.resolved', node_id: other.node_id, ts: tf, data: { response: { approved: true }, actor: '张工' } },
{ seq: lastOther + 2, type: 'run.resumed', node_id: null, ts: tf, data: { actor: '张工' } },
{ seq: lastOther + 3, type: 'node.finished', node_id: other.node_id, ts: tf + 0.01, data: { duration_ms: 1 } },
{ seq: lastOther + 4, type: 'run.finished', node_id: null, ts: tf + 0.02,
data: { output: { answer: '已发布' }, usage: {}, timing: { wall_ms: 1000, active_ms: 30, wait_ms: 970 } } },
]
await page.goto(`${WEB}/runs/${other.run_id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail][data-run-code=waiting]').waitFor({ timeout: 8000 }).catch(() => {})
const restPending = pending.filter((a) => a.id !== other.id)
fakes.set('GET /api/approvals', (url) => ({
status: 200,
json: restPending.filter((a) => !url.searchParams.get('run_id') || a.run_id === url.searchParams.get('run_id')),
}))
fakes.set(`GET /api/runs/${other.run_id}`, () => ({
status: 200,
json: { ...otherRun, status: 'succeeded', output: { answer: '已发布' }, finished_at: new Date().toISOString() },
}))
fakes.set(`GET /api/runs/${other.run_id}/events`, () => ({ status: 200, json: [...otherEvents, ...tail] }))
await recordCodes()
const finishedFast = await page.waitForFunction(
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'succeeded',
null, { timeout: 12000 },
).then(() => true, () => false)
await page.waitForTimeout(400)
const fastCodes = await recordedCodes()
check('别处批掉、已经跑完:状态变成已完成', finishedFast, [...new Set(fastCodes)].join(' → '))
check('跑完之前没有闪成「已挂起」', !fastCodes.includes('held'), fastCodes.join(' → '))
const fastStream = (await page.locator('[data-run-detail] > div').nth(0).innerText()).replace(/\s+/g, ' ')
check('别处发生的事件补进了时间线(「张工 放行了」)', fastStream.includes('张工 放行了'), fastStream.slice(0, 120))
const fastCount = (await page.locator('[data-run-detail] > header').innerText()).match(/([\d,]+) 条事件/)?.[1]
check('详情头的事件数跟着补齐', fastCount === (otherEvents.length + tail.length).toLocaleString('en-US'),
`${fastCount} / 应为 ${otherEvents.length + tail.length}`)
fakes.delete('GET /api/approvals')
fakes.delete(`GET /api/runs/${other.run_id}`)
fakes.delete(`GET /api/runs/${other.run_id}/events`)
// 断开期间别处批掉了:恢复那一刻重拉,同样可能撞上「审批已回复、运行还没改」
// 的空档。恢复走的是另一条路(useOnReconnect),也不能就此判成挂起
await page.goto(`${WEB}/runs/${target.run_id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail][data-run-code=waiting]').waitFor({ timeout: 8000 }).catch(() => {})
await recordCodes()
healthAborted = 0
offline = true
// 等 catalog 的心跳失败、探活也失败,判成断开(之后 4 秒重试 /health)
for (let i = 0; i < 60 && !healthAborted; i++) await page.waitForTimeout(200)
await page.waitForTimeout(200)
let gapReads = 0
fakes.set('GET /api/approvals', () => ({ status: 200, json: [] }))
fakes.set(`GET /api/runs/${target.run_id}`, () => ({
status: 200, json: { ...full, status: gapReads++ === 0 ? 'interrupted' : 'running' },
}))
offline = false
const back = await page.waitForFunction(
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'running',
null, { timeout: 15000 },
).then(() => true, () => false)
await page.waitForTimeout(300)
const backCodes = await recordedCodes()
check('断网恢复时撞上空档:认出它已经接着跑了', healthAborted > 0 && back, `探活失败 ${healthAborted} 次`)
check('断网恢复时撞上空档:也没有闪成「已挂起」', !backCodes.includes('held'), backCodes.join(' → '))
fakes.delete('GET /api/approvals')
fakes.delete(`GET /api/runs/${target.run_id}`)
}
// ------------------------------------------------------------------ 筛选
console.log('\n=== 状态筛选 ===')
await page.goto(`${WEB}/runs`, { waitUntil: 'networkidle' })
await rowsOf().first().waitFor()
const listRequests = []
const onReq = (r) => { if (new URL(r.url()).pathname === '/api/runs') listRequests.push(new URL(r.url()).searchParams) }
page.on('request', onReq)
await search('失败', (p) => p.get('status') === 'failed')
const zh = await statuses()
check('输入「失败」只出失败的运行', zh.length > 0 && zh.every((s) => s === 'failed'), `${zh.length} 行`)
check('状态走服务端参数 status=failed,不当名称搜',
listRequests.some((p) => p.get('status') === 'failed' && !p.get('q')))
check('提示按状态筛选', (await page.locator('[data-runs-hint]').innerText()).includes('失败'))
check('「全部」分段里「失败」亮着', await page.locator('[data-segment=failed]').getAttribute('aria-pressed') === 'true')
await search('已完成', (p) => p.get('status') === 'succeeded')
const done = await statuses()
check('输入「已完成」只出已完成的', done.length > 0 && done.every((s) => s === 'succeeded'))
await search('等待审批', (p) => p.get('status') === 'interrupted')
const waitingIds = await ids()
check('输入「等待审批」能找到所有停在审批上的运行',
pending.every((a) => waitingIds.includes(a.run_id)) && (await statuses()).every((s) => s === 'waiting'),
`${waitingIds.length} 行`)
await search('', (p) => !p.get('status'))
await listAfter(() => page.locator('[data-segment=cancelled]').click(), (p) => p.get('status') === 'cancelled')
const cancelled = await statuses()
check('点「已取消」分段只出已取消的', cancelled.length > 0 && cancelled.every((s) => s === 'cancelled'))
check('分段写进地址 ?status=cancelled', new URL(page.url()).searchParams.get('status') === 'cancelled')
await page.locator('[data-segment=all]').click()
await settle()
await search('zz一个不存在的工作流zz', (p) => p.get('q') === 'zz一个不存在的工作流zz')
check('没有匹配时说「没有匹配」,不说「还没有运行记录」',
await page.getByText('没有匹配的运行').count() === 1 && await page.getByText('还没有运行记录').count() === 0)
await page.getByRole('button', { name: '清除筛选' }).click()
await settle()
check('「清除筛选」之后列表回来了', await rowsOf().count() > 0 && !new URL(page.url()).searchParams.has('q'))
page.off('request', onReq)
// ------------------------------------------------------------------ 分页
console.log('\n=== 分页 ===')
const firstPage = await rowsOf().count()
check('第一页 50 条', firstPage === 50, String(firstPage))
const beforeReq = page.waitForRequest((r) => new URL(r.url()).pathname === '/api/runs' && new URL(r.url()).searchParams.has('before'))
await page.locator('[data-runs-more]').click()
const req = await beforeReq
await settle()
const secondIds = await ids()
check('「加载更多」带 before 游标', !!new URL(req.url()).searchParams.get('before'))
check('加载后变成 100 条', secondIds.length === 100, String(secondIds.length))
check('没有重复的行', new Set(secondIds).size === secondIds.length)
const apiIds = (await getJson('/runs?limit=100')).map((r) => r.id)
check('两页拼起来和后端的前 100 条一致', JSON.stringify(apiIds) === JSON.stringify(secondIds))
// ------------------------------------------------------------------ 时间
console.log('\n=== 时间不再慢 8 小时 ===')
const sample = (await getJson('/runs?limit=5'))[0]
const shownTime = await page.locator(`[data-run-id="${sample.id}"] [data-run-time]`).innerText()
const [expected, naive, offset] = await page.evaluate((iso) => {
const pad = (n) => String(n).padStart(2, '0')
const hm = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`
// 带不带 Z 都按 UTC 解析;naive 是以前把 UTC 当本地时间的读法
const utc = new Date(/Z|[+-]\d\d:?\d\d$/.test(iso) ? iso : iso + 'Z')
const local = new Date(iso.replace(/Z$|[+-]\d\d:?\d\d$/, ''))
return [hm(utc), hm(local), new Date().getTimezoneOffset()]
}, sample.created_at)
check('列表时间按 UTC 换算成本地', shownTime.includes(expected), `显示 ${shownTime},应为 ${expected}(created_at=${sample.created_at})`)
if (offset !== 0) check('不是把 UTC 当本地的那个错读', !shownTime.includes(naive) || naive === expected, `错读会是 ${naive}`)
const title = await page.locator(`[data-run-id="${sample.id}"] [data-run-time]`).getAttribute('title')
check('悬停给完整时间和时区', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \(UTC[+-]\d{2}:\d{2}\)/.test(title ?? ''), title ?? '')
// ------------------------------------------------------------------ 失败的排错路径
console.log('\n=== 失败:指到节点、原因、下一步 ===')
const failedRuns = await getJson('/runs?status=failed&limit=200')
const authFail = failedRuns.find((r) => r.workflow_id && /401|Authentication/i.test(r.error ?? ''))
if (authFail) {
const full = await getJson(`/runs/${authFail.id}`)
// 实时流伪造成「接着跑之后又动起来」。routeWebSocket 只管注册之后加载的页面,
// 所以要在打开详情之前挂上
const last = (await getJson(`/runs/${authFail.id}/events`)).at(-1)
await page.routeWebSocket(new RegExp(`/api/runs/${authFail.id}/stream`), (ws) => {
const t = Date.now() / 1000
ws.send(JSON.stringify({ seq: last.seq + 1, type: 'run.resumed', node_id: null, ts: t, data: { from: full.error_node_id } }))
ws.send(JSON.stringify({ seq: last.seq + 2, type: 'node.started', node_id: full.error_node_id, ts: t + 0.1, data: { resumed: true } }))
})
await page.goto(`${WEB}/runs/${authFail.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-banner=failed]').waitFor()
check('横幅指到失败的节点', await page.locator(`[data-failed-node="${full.error_node_id}"]`).count() === 1, full.error_node_id)
check('原始异常翻成人话', (await page.locator('[data-failed-title]').innerText()).includes('鉴权'))
check('有「接着跑」', await page.locator('[data-run-banner=failed] [data-action=continue]').count() === 1)
check('有「去模型接入」', await page.locator('[data-run-banner=failed] [data-action=settings]').count() === 1)
check('有「复制错误」', await page.locator('[data-run-banner=failed] [data-action=copy-error]').count() === 1)
const href = await page.locator('[data-run-banner=failed] [data-action=locate]').getAttribute('href')
check('「在画布中定位」带 run 和 focus', href === `/studio/${authFail.workflow_id}?run=${authFail.id}&focus=${full.error_node_id}`, href ?? '')
check('失败的运行不给「提取模板」', await page.locator('[data-action=extract]').count() === 0)
// 接着跑:POST 伪造成功,实时流在上面已经伪造好
fakes.set(`POST /api/runs/${authFail.id}/continue`, () => ({
status: 200, json: { ...full, status: 'running', error: null, finished_at: null },
}))
await page.locator('[data-run-banner=failed] [data-action=continue]').click()
await page.locator('[data-run-feedback=continued]').waitFor({ timeout: 5000 }).catch(() => {})
check('接着跑发的是 POST /continue', writes.includes(`POST /api/runs/${authFail.id}/continue`))
check('接着跑之后留下回执', await page.locator('[data-run-feedback=continued]').count() === 1)
const flipped = await page.waitForFunction(
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'running',
null, { timeout: 5000 },
).then(() => true, () => false)
check('接流后状态变成运行中、出现「停止」', flipped && await page.locator('[data-action=stop]').count() === 1)
fakes.delete(`POST /api/runs/${authFail.id}/continue`)
} else {
check('沙箱里有一条有工作流的 401 失败运行', false)
}
const missingInput = failedRuns.find((r) => /缺少必填输入/.test(r.error ?? '') && !r.workflow_id)
if (missingInput) {
await page.goto(`${WEB}/runs/${missingInput.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-banner=failed]').waitFor()
check('缺输入的失败不给「接着跑」(原样接着跑还会失败)',
await page.locator('[data-run-banner=failed] [data-action=continue]').count() === 0)
check('未保存的工作流不给「在画布中定位」', await page.locator('[data-action=locate]').count() === 0)
// 补上缺的那一项重新运行:用这次的图快照(未保存的图也能重跑),其余输入照旧
const field = missingInput.error.match(/缺少必填输入[::]\s*(\S+)/)[1]
const rerunBtn = page.locator('[data-run-banner=failed] [data-action=rerun]')
check(`缺输入的失败给「补上「${field}」重新运行」`, await rerunBtn.count() === 1)
const RERUN = 'fake0rerun000000000000000000000'
let startBody = null
fakes.set('POST /api/runs', (url, req) => {
startBody = req.postDataJSON()
return { status: 200, json: { ...missingInput, id: RERUN, status: 'queued', error: null } }
})
fakes.set(`GET /api/runs/${RERUN}`, () => ({ status: 200, json: { ...missingInput, id: RERUN, status: 'succeeded', error: null } }))
fakes.set(`GET /api/runs/${RERUN}/events`, () => ({ status: 200, json: [] }))
await rerunBtn.click()
const ask = page.getByRole('dialog')
await ask.waitFor()
await ask.locator('input, textarea').first().fill('检查用的主题')
await ask.getByRole('button', { name: '重新运行' }).click()
const moved = await page.waitForURL((u) => u.pathname === `/runs/${RERUN}`, { timeout: 5000 }).then(() => true, () => false)
check('补上之后发起新运行并跳过去', moved, page.url())
check('新运行带着补上的那一项和原来的其余输入',
startBody?.input?.[field] === '检查用的主题'
&& Object.keys(missingInput.input ?? {}).every((k) => k === field || k in (startBody?.input ?? {})),
JSON.stringify(startBody?.input ?? null))
check('跑的是这次运行时的图快照', Array.isArray(startBody?.graph?.nodes) && startBody.graph.nodes.length > 0 && !startBody.workflow_id)
fakes.delete('POST /api/runs')
// 正式运行缺输入:只能跑「当前」发布的版本。之后又发布过新版的话,带着原来
// 的版本号会被后端 409(「vN 不是当前发布版本」),所以不带,并在弹窗里说清跑哪一版
const published = (await getJson('/workflows')).find((w) => (w.published_version ?? 0) > 1)
if (published) {
const FORMAL = 'fake0formalmissing0000000000000'
const old = published.published_version - 1
const formalRun = {
...missingInput, id: FORMAL, run_class: 'formal', workflow_id: published.id, workflow_name: published.name,
version: old, manifest_hash: null, manifest_seq: null,
}
const missingEvents = await getJson(`/runs/${missingInput.id}/events`)
fakes.set(`GET /api/runs/${FORMAL}`, () => ({ status: 200, json: formalRun }))
fakes.set(`GET /api/runs/${FORMAL}/events`, () => ({ status: 200, json: missingEvents }))
fakes.set(`GET /api/runs/${FORMAL}/graph`, () => ({ status: 200, json: { graph: published.graph, workflow_id: published.id, version: old } }))
let formalBody = null
fakes.set('POST /api/runs', (url, req) => {
formalBody = req.postDataJSON()
return { status: 200, json: { ...formalRun, id: RERUN, status: 'queued', error: null, version: published.published_version } }
})
await page.goto(`${WEB}/runs/${FORMAL}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-banner=failed] [data-action=rerun]').click()
const formalAsk = page.getByRole('dialog')
await formalAsk.waitFor()
const askText = (await formalAsk.innerText()).replace(/\s+/g, ' ')
check('正式运行重跑:弹窗写明跑的是当前发布版本', askText.includes(`v${published.published_version}`) && askText.includes(`v${old}`),
askText.slice(0, 90))
await formalAsk.locator('input, textarea').first().fill('检查用的主题')
await formalAsk.getByRole('button', { name: '重新运行' }).click()
await page.waitForURL((u) => u.pathname === `/runs/${RERUN}`, { timeout: 5000 }).catch(() => {})
check('正式运行重跑不带旧版本号(由后端取当前发布版本)',
formalBody?.run_class === 'formal' && formalBody?.workflow_id === published.id
&& !('version' in (formalBody ?? {})) && !formalBody?.graph && formalBody?.input?.[field] === '检查用的主题',
JSON.stringify(formalBody ?? null).slice(0, 120))
fakes.delete('POST /api/runs')
fakes.delete(`GET /api/runs/${FORMAL}`)
fakes.delete(`GET /api/runs/${FORMAL}/events`)
fakes.delete(`GET /api/runs/${FORMAL}/graph`)
} else {
check('沙箱里有一个发布过两版以上的工作流', false)
}
}
// 条件表达式写错了:这一页的接着跑不带图,跑的还是那条写错的条件,只会再失败
// 一次。不给接着跑,回画布定位是主路
const syntaxFail = failedRuns.find((r) => /表达式语法错误|invalid syntax/.test(r.error ?? '') && r.workflow_id)
if (syntaxFail) {
await page.goto(`${WEB}/runs/${syntaxFail.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-banner=failed]').waitFor()
check('表达式写错的失败不给「接着跑」', await page.locator('[data-run-banner=failed] [data-action=continue]').count() === 0)
const locate = page.locator('[data-run-banner=failed] [data-action=locate]')
check('「在画布中定位」成了主按钮', await locate.count() === 1 && /\bbtn-primary\b/.test(await locate.getAttribute('class') ?? ''))
} else {
check('沙箱里有一条表达式写错的失败运行', false)
}
// ------------------------------------------------------------------ 挂起
const held = (await getJson('/runs?status=interrupted&limit=200')).find((r) => !pending.some((a) => a.run_id === r.id))
if (held) {
console.log('\n=== 挂起(没有审批的中断) ===')
await page.goto(`${WEB}/runs/${held.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail]').waitFor()
await page.waitForTimeout(500)
check('显示「已挂起」而不是「等待审批」', await page.locator('[data-run-detail]').getAttribute('data-run-code') === 'held')
check('挂起横幅带「接着跑」', await page.locator('[data-run-banner=held] [data-action=resume]').count() === 1)
}
// ------------------------------------------------------------------ 封存凭证
console.log('\n=== 封存凭证常驻 ===')
const sealed = allRuns.find((r) => r.status === 'succeeded' && r.manifest_hash && !r.workflow_id)
await page.goto(`${WEB}/runs/${sealed.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-provenance]').waitFor()
check('凭证条写着「已封存」和清单哈希前缀',
(await page.locator('[data-run-provenance]').innerText()).includes(sealed.manifest_hash.slice(0, 10)))
await page.locator('[data-verify-btn]').click()
await page.locator('[data-verify=ok], [data-verify=mismatch]').first().waitFor({ timeout: 8000 })
await page.waitForTimeout(4500)
check('核对结果 4 秒后还在(不是 toast)', await page.locator('[data-verify=ok]').count() === 1)
check('写明几条事件与清单一致', /\d+ 条事件与清单一致/.test(await page.locator('[data-verify=ok]').innerText()))
await page.locator('[data-prov-toggle]').click()
check('展开能看到完整清单哈希', (await page.locator('[data-prov-detail]').innerText()).includes(sealed.manifest_hash))
await page.goto(`${WEB}/runs/${allRuns[1].id}`, { waitUntil: 'networkidle' })
await page.goto(`${WEB}/runs/${sealed.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-provenance]').waitFor()
check('切走再回来,核对结果还在', await page.locator('[data-verify=ok]').count() === 1)
// ------------------------------------------------------------------ 时长
console.log('\n=== 三种时长 ===')
const timed = allRuns.find((r) => r.usage?.wall_ms != null && r.usage?.wait_ms > 0)
if (timed) {
await page.goto(`${WEB}/runs/${timed.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-telemetry]').waitFor()
const wall = await page.locator('[data-telemetry=wall]').innerText()
const active = await page.locator('[data-telemetry=active]').innerText()
const wait = await page.locator('[data-telemetry=wait]').innerText()
check('墙钟、执行、等人三项都有数', ![wall, active, wait].includes('—'), `${wall} / ${active} / ${wait}`)
check('列表那一行的 title 分项写了三种时长',
/墙钟 .+ · 执行 .+ · 等人 .+/.test(await page.locator(`[data-run-id="${timed.id}"] [data-run-duration]`).getAttribute('title') ?? ''))
}
// ------------------------------------------------------------------ 提取模板
console.log('\n=== 提取模板 ===')
check('已完成的探索运行有「提取模板」', await page.locator('[data-action=extract]').count() === 1)
const wfs = await getJson('/workflows')
fakes.set('POST /api/copilot/from-run', () => ({
status: 200, json: { workflow_id: wfs[0].id, name: '检查用草稿', nodes: 3, edges: 2, dropped_nodes: 1, source_run: timed?.id },
}))
await page.locator('[data-action=extract]').click()
await page.waitForURL(`**/studio/${wfs[0].id}`, { timeout: 5000 }).catch(() => {})
check('提取成功后跳到新草稿', new URL(page.url()).pathname === `/studio/${wfs[0].id}`)
fakes.delete('POST /api/copilot/from-run')
// ------------------------------------------------------------------ 运行中:接流、停止、不能删
console.log('\n=== 运行中:实时接流、停止、不能删 ===')
const base = allRuns.find((r) => r.status === 'succeeded' && !r.workflow_id)
const FAKE = 'fake0running0000000000000000000'
const t0 = Date.now() / 1000 - 3
const liveRun = {
...base, id: FAKE, status: 'running', output: {}, usage: {}, manifest_hash: null, manifest_seq: null,
created_at: new Date(t0 * 1000).toISOString(), started_at: new Date(t0 * 1000).toISOString(), finished_at: null,
}
const graph = { nodes: [
{ id: 'a', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: {} } },
{ id: 'b', type: 'llm', position: { x: 200, y: 0 }, data: { label: '总结', config: {} } },
], edges: [{ source: 'a', target: 'b' }] }
const liveEvents = [
{ seq: 1, type: 'run.started', node_id: null, ts: t0, data: { nodes: 2 } },
{ seq: 2, type: 'node.started', node_id: 'a', ts: t0 + 0.1, data: {} },
{ seq: 3, type: 'node.finished', node_id: 'a', ts: t0 + 0.2, data: { duration_ms: 100 } },
{ seq: 4, type: 'node.started', node_id: 'b', ts: t0 + 0.3, data: {} },
]
let liveStatus = 'running'
// 停下之后重读到的是后端收尾过的那一份:带结束时间和用量
fakes.set(`GET /api/runs/${FAKE}`, () => ({
status: 200,
json: liveStatus === 'running' ? liveRun
: { ...liveRun, status: liveStatus, finished_at: new Date().toISOString(), usage: { wall_ms: 3000, active_ms: 3000, wait_ms: 0 } },
}))
fakes.set(`GET /api/runs/${FAKE}/events`, () => ({ status: 200, json: liveEvents }))
fakes.set(`GET /api/runs/${FAKE}/graph`, () => ({ status: 200, json: { graph, workflow_id: null, version: null } }))
fakes.set(`POST /api/runs/${FAKE}/cancel`, () => ({ status: 200, json: { ok: true } }))
// 左边列表里也放上这一条:跑完那一刻它要闪一下,而且闪完要摘掉
const listTop = await getJson('/runs?limit=50')
fakes.set('GET /api/runs', () => ({ status: 200, json: [{ ...liveRun, status: liveStatus }, ...listTop] }))
let socket = null
await page.routeWebSocket(new RegExp(`/api/runs/${FAKE}/stream`), (ws) => { socket = ws })
await page.goto(`${WEB}/runs/${FAKE}`, { waitUntil: 'domcontentloaded' })
await page.locator('[data-run-detail]').waitFor()
await page.waitForTimeout(600)
check('运行中的记录接上了实时流', socket != null)
check('详情头有「停止」', await page.locator('[data-action=stop]').count() === 1)
const clockA = await page.locator('[data-telemetry=wall]').innerText()
await page.waitForTimeout(700)
const clockB = await page.locator('[data-telemetry=wall]').innerText()
check('墙钟在走(mm:ss.s)', /^\d\d:\d\d\.\d$/.test(clockB) && clockA !== clockB, `${clockA} → ${clockB}`)
check('状态格写着当前节点', (await page.locator('[data-run-telemetry]').innerText()).includes('当前「总结」'))
await page.getByRole('button', { name: '更多操作' }).click()
const del = page.locator('[data-menu=delete]')
check('运行中「删除记录」不可点,并说明要先停止', await del.isDisabled() && (await del.innerText()).includes('先停止'))
await page.keyboard.press('Escape')
await page.locator('[data-action=stop]').click()
await page.waitForTimeout(300)
check('停止发的是 POST /cancel', writes.includes(`POST /api/runs/${FAKE}/cancel`))
// 后端的回应经实时流到达:节点收尾、运行取消、流结束
liveStatus = 'cancelled'
const t1 = Date.now() / 1000
socket?.send(JSON.stringify({ seq: 5, type: 'run.cancelled', node_id: null, ts: t1, data: { timing: { wall_ms: 3000, active_ms: 3000, wait_ms: 0 } } }))
// 流结束比取消事件晚一拍到(后端收尾完才发):相位先变、那一行先改一次
await page.waitForTimeout(300)
socket?.send(JSON.stringify({ type: 'stream.end', status: 'cancelled', data: { status: 'cancelled' } }))
const flashRow = page.locator(`[data-runs-list] [data-run-id="${FAKE}"] .runs-row-changed`)
const flashed = await flashRow.waitFor({ state: 'attached', timeout: 1500 }).then(() => true, () => false)
await page.waitForTimeout(900)
check('流里的事件落进了时间线(状态变成已取消)', await page.locator('[data-run-detail]').getAttribute('data-run-code') === 'cancelled')
check('停下后「停止」消失', await page.locator('[data-action=stop]').count() === 0)
check('停下后不再转圈', await page.locator('[data-run-detail] .animate-spin').count() === 0)
// 相位一变先改一次那一行,紧接着流结束、重读运行又改一次(用量、结束时间)。
// 第二次改动不能把摘掉闪光的计时器清掉,否则这一行一直挂着,下次变状态也闪不起来
check('左边那一行状态变了闪一下', flashed)
await page.waitForTimeout(1600)
check('闪完就摘掉,不一直挂着', await flashRow.count() === 0)
fakes.delete('GET /api/runs')
// ------------------------------------------------------------------ 等了很久之后接着跑
console.log('\n=== 等了几天的审批批掉之后:计时读得懂 ===')
// 第一次开始在 8 天前,挂在审批上 8 天,刚被批掉、正接着跑。从第一次开始算的
// 秒表会读成「192:00:05.3」;墙钟要写跨度,执行和轮次头只数执行的部分
const LONG = 'fake0longwait00000000000000000'
const tl = Date.now() / 1000 - 8 * 86400 - 5
const tr = Date.now() / 1000 - 5
const longRun = {
...liveRun, id: LONG, created_at: new Date(tl * 1000).toISOString(), started_at: new Date(tl * 1000).toISOString(),
}
const longGraph = { nodes: [
{ id: 'a', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: {} } },
{ id: 'h', type: 'human', position: { x: 200, y: 0 }, data: { label: '人工把关', config: {} } },
{ id: 'b', type: 'llm', position: { x: 400, y: 0 }, data: { label: '总结', config: {} } },
], edges: [{ source: 'a', target: 'h' }, { source: 'h', target: 'b' }] }
const longEvents = [
{ seq: 1, type: 'run.started', node_id: null, ts: tl, data: { nodes: 3 } },
{ seq: 2, type: 'node.started', node_id: 'a', ts: tl + 0.1, data: {} },
{ seq: 3, type: 'node.finished', node_id: 'a', ts: tl + 0.2, data: { duration_ms: 100 } },
{ seq: 4, type: 'node.started', node_id: 'h', ts: tl + 0.3, data: {} },
{ seq: 5, type: 'human.requested', node_id: 'h', ts: tl + 0.4, data: { mode: 'approve', prompt: '放行吗?' } },
{ seq: 6, type: 'run.interrupted', node_id: null, ts: tl + 0.5, data: {} },
{ seq: 7, type: 'human.resolved', node_id: 'h', ts: tr, data: { response: { approved: true }, actor: '张工' } },
{ seq: 8, type: 'run.resumed', node_id: null, ts: tr, data: { actor: '张工' } },
{ seq: 9, type: 'node.finished', node_id: 'h', ts: tr + 0.1, data: { duration_ms: 1 } },
{ seq: 10, type: 'node.started', node_id: 'b', ts: tr + 0.2, data: {} },
]
fakes.set(`GET /api/runs/${LONG}`, () => ({ status: 200, json: longRun }))
fakes.set(`GET /api/runs/${LONG}/events`, () => ({ status: 200, json: longEvents }))
fakes.set(`GET /api/runs/${LONG}/graph`, () => ({ status: 200, json: { graph: longGraph, workflow_id: null, version: null } }))
fakes.set('GET /api/runs', () => ({ status: 200, json: [longRun, ...listTop] }))
let longSocket = null
await page.routeWebSocket(new RegExp(`/api/runs/${LONG}/stream`), (ws) => { longSocket = ws })
await page.goto(`${WEB}/runs/${LONG}`, { waitUntil: 'domcontentloaded' })
await page.locator('[data-run-detail]').waitFor()
await page.waitForTimeout(800)
check('接上了实时流', longSocket != null)
const HMS = /\d+:\d\d:\d\d\.\d/
const longWall = await page.locator('[data-telemetry=wall]').innerText()
check('墙钟写跨度(8 天),不是秒表', /8 天/.test(longWall) && !HMS.test(longWall), longWall)
const activeA = await page.locator('[data-telemetry=active]').innerText()
await page.waitForTimeout(500)
const activeB = await page.locator('[data-telemetry=active]').innerText()
check('执行仍是走动的秒表,从批掉那一刻数起', /^00:0\d\.\d$/.test(activeB) && activeA !== activeB, `${activeA} → ${activeB}`)
const turnClock = await page.locator('[data-run-detail] > div span[title="已运行"]').first().innerText().catch(() => '')
check('轮次头的秒表只数执行的部分', /^00:0\d\.\d$/.test(turnClock), turnClock)
const rowClock = await page.locator(`[data-runs-list] [data-run-id="${LONG}"] [data-run-duration]`).innerText().catch(() => '')
check('列表那一行写「8 天」,不是秒表', /8 天/.test(rowClock) && !HMS.test(rowClock), rowClock)
fakes.delete('GET /api/runs')
fakes.delete(`GET /api/runs/${LONG}`)
fakes.delete(`GET /api/runs/${LONG}/events`)
fakes.delete(`GET /api/runs/${LONG}/graph`)
// ------------------------------------------------------------------ 删除保护
console.log('\n=== 删除保护 ===')
const FORMAL = 'fake0formal00000000000000000000'
const formalRun = { ...base, id: FORMAL, run_class: 'formal', version: 3, version_hash: 'f'.repeat(64) }
fakes.set(`GET /api/runs/${FORMAL}`, () => ({ status: 200, json: formalRun }))
fakes.set(`GET /api/runs/${FORMAL}/events`, () => ({ status: 200, json: liveEvents.slice(0, 3) }))
let deleteCalls = []
fakes.set(`DELETE /api/runs/${FORMAL}`, (url) => {
deleteCalls.push(url.search)
return url.searchParams.get('force') === 'true'
? { status: 204, body: '' }
: { status: 409, json: { detail: '这是一次已封存的正式运行,是出具结果的追溯凭证。删除后它的事件和工件会一并删掉,封存清单再也无法核对。确认要删的话请再确认一次(强制删除)' } }
})
await page.goto(`${WEB}/runs/${FORMAL}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail]').waitFor()
check('头部没有常驻的删除按钮', await page.locator('[data-run-detail] header button[title*="删除"]').count() === 0)
await page.getByRole('button', { name: '更多操作' }).click()
await page.locator('[data-menu=delete]').click()
const dialog = page.getByRole('dialog')
await dialog.waitFor()
check('确认框写清后果(清单无法再核对)', (await dialog.innerText()).includes('封存清单再也无法核对'))
await dialog.getByRole('button', { name: '删除记录' }).click()
await page.waitForTimeout(400)
const second = page.getByRole('dialog')
await second.waitFor()
check('后端 409 的 detail 显示在二次确认里', (await second.innerText()).includes('已封存的正式运行'))
const forceBtn = second.getByRole('button', { name: '强制删除' })
check('强制删除要照抄 id 才能点', await forceBtn.isDisabled())
await second.locator('input').fill(FORMAL.slice(0, 6))
await forceBtn.click()
await page.waitForURL((u) => u.pathname === '/runs', { timeout: 5000 }).catch(() => {})
check('先不带 force、确认后再带 force=true', deleteCalls.length === 2 && deleteCalls[0] === '' && deleteCalls[1] === '?force=true',
JSON.stringify(deleteCalls))
check('删掉后回到列表', new URL(page.url()).pathname === '/runs')
// 普通 409(比如后端说还在执行):原话提示,不静默
const BUSY = 'fake0busy0000000000000000000000'
fakes.set(`GET /api/runs/${BUSY}`, () => ({ status: 200, json: { ...base, id: BUSY } }))
fakes.set(`GET /api/runs/${BUSY}/events`, () => ({ status: 200, json: liveEvents.slice(0, 3) }))
fakes.set(`DELETE /api/runs/${BUSY}`, () => ({ status: 409, json: { detail: '这次运行还在执行,现在删除会留下没人管的任务。先停止它,再删除' } }))
await page.goto(`${WEB}/runs/${BUSY}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail]').waitFor()
await page.getByRole('button', { name: '更多操作' }).click()
await page.locator('[data-menu=delete]').click()
await page.getByRole('dialog').getByRole('button', { name: '删除记录' }).click()
check('409 时把后端的话原样告诉人', await page.getByText('先停止它,再删除').first().waitFor({ timeout: 4000 }).then(() => true, () => false))
check('409 时停在原地', new URL(page.url()).pathname === `/runs/${BUSY}`)
console.log('\n=== 收尾 ===')
check('只有伪造过的写请求', writes.every((w) => /\/continue$|\/cancel$|\/copilot\/from-run$|DELETE \/api\/runs\/fake0|^POST \/api\/runs$/.test(w)),
writes.join(', '))
check('没有未捕获的运行时错误', errors.length === 0, errors[0] ?? '')
await browser.close()
console.log(failed ? `\n${failed} 项没过` : '\n全部通过')
process.exit(failed ? 1 : 0)