Files
AgentLab/scripts/check-runs.mjs
T
ThearchyHelios fcdcc30d4e 可点击证据第二期:取数链路打通、问数据的答案逐段可点;发布前自动修复
一、可点击证据第二期

取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
  表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
  再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号

证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
  遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
  和子工作流同一套规则;子工作流节点也补上了升版处置的配置项

Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
  模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口

二、发布前自动修复

- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
  Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽

验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
2026-09-28 21:20:04 +08:00

1712 lines
120 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 伪造实时流。
// 前端和后端要指向同一份数据(前端的代理连的就是这个后端)。
// 跑之前前后端都得起着(./scripts/dev.sh),默认连 5273 / 8000。对别的实例(比如一份
// 沙箱拷贝)跑时带上地址:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-runs.mjs
import { readFileSync } from 'node:fs'
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'
const getJson = async (path) => (await fetch(API + path)).json()
// 仓库是公开的,这份输出会被贴进报告:沙箱库是用户开发库的拷贝,里面的工作流名、
// 节点名一律换成占位符再打印。本脚本源码里自己写着的串(夹具)本来就公开,不换。
const ownSource = readFileSync(new URL(import.meta.url), 'utf8')
const secrets = []
for (const w of await getJson('/workflows').catch(() => [])) {
secrets.push([w.name, '‹工作流名›'])
for (const n of w.graph?.nodes ?? []) secrets.push([n.data?.label ?? n.label, '‹节点名›'])
}
for (const r of await getJson('/runs?limit=200').catch(() => [])) secrets.push([r.workflow_name, '‹工作流名›'])
const maskList = [...new Map(secrets
.filter(([v]) => typeof v === 'string' && v.trim().length >= 2 && !ownSource.includes(v))
.map(([v, tag]) => [v, tag])).entries()]
.sort((a, b) => b[0].length - a[0].length)
const masked = (t) => maskList.reduce((s, [v, tag]) => s.split(v).join(tag), String(t))
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${masked(detail)}` : ''}`)
if (!cond) failed++
}
/**
* 一节一节地跑:某一节里元素找不到、等待超时,只记成这一节失败,接着跑下一节,
* 不让一处卡住把后面的检查一起吞掉。页面是各节共用的:
* 出错那一节停在哪,下一节就从哪接着
*/
async function section(name, fn) {
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
}
}
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 ?? [])
// 各节共用的真数据,只读,在所有节之前取好。放在某一节里取的话,那一节的界面一出错,
// 后面十几节都拿着 undefined 报「中途出错」,各自真正的状态就看不见了
const pending = await getJson('/approvals?status=pending&limit=500')
const allRuns = await getJson('/runs?limit=200')
const listTop = await getJson('/runs?limit=50')
const wfHost = (await getJson('/workflows'))[0]
const base = allRuns.find((r) => r.status === 'succeeded' && !r.workflow_id)
// ------------------------------------------------------------------ 页签
await section('页签与地址', async () => {
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'))
})
// ------------------------------------------------------------------ 待审批
await section('待审批页签', async () => {
// 「别处批掉了」要用的实时流: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 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()
const hasWf = rowText.includes(pending[0].workflow_name ?? '')
const hasNode = rowText.includes(pending[0].node_label ?? pending[0].node_id)
// 名字来自沙箱库,只报在不在
check('每条写着工作流名和节点', hasWf && hasNode, `工作流名 ${hasWf ? '在' : '缺'} · 节点 ${hasNode ? '在' : '缺'}`)
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, ' ')
// 时间线里是沙箱真实运行的内容(节点名、SQL、预览),只报字数,不打原文
check('别处发生的事件补进了时间线(「张工 放行了」)', fastStream.includes('张工 放行了'), `时间线 ${fastStream.length} 字`)
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}`)
}
})
// ------------------------------------------------------------------ 筛选
await section('状态筛选', async () => {
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)
})
// ------------------------------------------------------------------ 分页
await section('分页', async () => {
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))
})
// ------------------------------------------------------------------ 时间
await section('时间不再慢 8 小时', async () => {
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 ?? '')
})
// ------------------------------------------------------------------ 失败的排错路径
await section('失败:指到节点、原因、下一步', async () => {
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)
// 时间线里的报错照原样画横幅那份拆好的失败(3C REQ-14):以前只交一个标题字符串,流里拿它
// 再讲一遍,原因、怎么办丢了,「去模型接入」的直达入口也没有
const bannerTitle = (await page.locator('[data-failed-title]').innerText().catch(() => '')).trim()
// 横幅标题那一行下面紧跟的一行是原因(没有原因时是「下一步:…」,不算)
const bannerReason = await page.evaluate(() => {
const next = document.querySelector('[data-run-banner=failed] [data-failed-title]')?.parentElement?.nextElementSibling
const text = next?.textContent?.trim() ?? ''
return text.startsWith('下一步') ? '' : text
})
const turnError = page.locator('[data-view-pane=stream] [data-turn-error]')
const turnErrorText = (await turnError.innerText().catch(() => '')).replace(/\s+/g, ' ')
const sameTitle = turnErrorText.includes(bannerTitle)
const sameReason = !bannerReason || turnErrorText.includes(bannerReason.replace(/\s+/g, ' '))
const fixLink = await turnError.locator('[data-fix=settings]').count() === 1
const onceTitle = (await turnError.locator('.font-medium').first().innerText().catch(() => '')).trim() === bannerTitle
// 报错原文来自沙箱里的真实运行,只报各项对不对得上
check('时间线里的报错:标题、原因和横幅同一句,出错的节点只说一遍,给「去模型接入」',
sameTitle && sameReason && fixLink && onceTitle,
`标题 ${sameTitle ? '同' : '异'} · 原因 ${sameReason ? '同' : '异'} · 直达入口 ${fixLink ? '有' : '无'} · 标题行 ${onceTitle ? '同' : '异'}`)
check('失败的运行不给「提取模板」', await page.locator('[data-action=extract]').count() === 0)
// 横幅里的定位是排错的主路,详情头上的回放是看全程:两处都得够得着画布。失败的节点
// 在工作流现在的图里已经没了的话(之后改过结构),对准它会落空:横幅改给「在航迹中看」,
// 详情头照样回放、只是不带 focus。两种情形下面的航迹夹具各测一遍,这里按库里的实情测
const authWf = (await getJson('/workflows')).find((w) => w.id === authFail.workflow_id)
const stillThere = !!authWf?.graph?.nodes?.some((n) => n.id === full.error_node_id)
const canvasBase = `/studio/${authFail.workflow_id}?run=${authFail.id}`
const headLink = page.locator('[data-run-detail] > header [data-action=open-canvas]')
const headHref = await headLink.getAttribute('href').catch(() => null)
const headText = await headLink.innerText().catch(() => '')
if (stillThere) {
const href = await page.locator('[data-run-banner=failed] [data-action=locate]').getAttribute('href').catch(() => null)
check('「在画布中定位」带 run 和 focus', href === `${canvasBase}&focus=${full.error_node_id}`, href ?? '')
check('失败的运行详情头上也有「在画布中回放」,对准失败的节点',
headText.includes('在画布中回放') && headHref === href, headHref ?? '没有这个按钮')
} else {
check('失败的节点在工作流现在的图里没了:不给落空的「在画布中定位」,改给「在航迹中看」',
await page.locator('[data-run-banner=failed] [data-action=locate]').count() === 0
&& await page.locator('[data-run-banner=failed] [data-action=locate-trace]').count() === 1)
check('失败的运行详情头上照样「在画布中回放」,不带对不上的 focus',
headText.includes('在画布中回放') && headHref === canvasBase, headHref ?? '没有这个按钮')
}
// 接着跑: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')
// 补上之后发起被拒:绑定的工具在本机不存在(422 run_tool_missing)。和画布上发起被拒同一套,
// 报错里给直达入口(数据源工具去数据页),不是只有一句 toast
await page.goto(`${WEB}/runs/${missingInput.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-banner=failed] [data-action=rerun]').waitFor()
fakes.set('POST /api/runs', () => ({ status: 422, json: {
detail: '绑定的工具在本机不存在:「查数」(调用工具)绑的 db_query__nope。去数据页接入,或在节点里重新选', code: 'run_tool_missing' } }))
await page.locator('[data-run-banner=failed] [data-action=rerun]').click()
const refused = page.getByRole('dialog')
await refused.waitFor()
await refused.locator('input, textarea').first().fill('检查用的主题')
await refused.getByRole('button', { name: '重新运行' }).click()
const toData = page.getByRole('button', { name: '去数据页接入' })
await toData.waitFor({ timeout: 5000 }).catch(() => {})
check('补上重新运行被拒(工具不在本机):报错里有「去数据页接入」', await toData.count() === 1)
const said = await page.getByText('绑定的工具「db_query__nope」在本机不存在').first().innerText().catch(() => '')
check('……点名那个工具,说清这次运行没有发起', said.includes('db_query__nope') && said.includes('没有发起'), said.slice(0, 120))
check('……人还在这条运行上', new URL(page.url()).pathname === `/runs/${missingInput.id}`, page.url())
await toData.click().catch(() => {})
await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 }).catch(() => {})
check('……点了去数据页', new URL(page.url()).pathname.startsWith('/data'), page.url())
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]')
// 失败的节点在工作流现在的图里没了的话,定位落空,主路改成去航迹看(定位作主按钮的
// 情形由后面「模型没有真正调用工具」的夹具测)
const synNode = (await getJson(`/runs/${syntaxFail.id}`)).error_node_id
const synWf = (await getJson('/workflows')).find((w) => w.id === syntaxFail.workflow_id)
if (synWf?.graph?.nodes?.some((n) => n.id === synNode)) {
check('「在画布中定位」成了主按钮', await locate.count() === 1 && /\bbtn-primary\b/.test(await locate.getAttribute('class') ?? ''))
} else {
check('失败的节点在工作流现在的图里没了:不给落空的定位,给「在航迹中看」',
await locate.count() === 0 && await page.locator('[data-run-banner=failed] [data-action=locate-trace]').count() === 1)
}
} 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)
// 挂起的没人会再去接:不放弃的话它在记录里永远挂着「可续跑」
check('挂起横幅也能放弃这次运行', await page.locator('[data-run-banner=held] [data-action=abandon]').count() === 1)
}
})
// ------------------------------------------------------------------ 封存凭证
await section('封存凭证常驻', async () => {
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)
})
// ------------------------------------------------------------------ 时长
let timed
await section('三种时长', async () => {
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') ?? ''))
}
})
// ------------------------------------------------------------------ 提取模板
await section('提取模板', async () => {
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 },
}))
// 画布上还有没保存的改动(store 里的画布离开编排页也还在):先问要不要放弃,再建草稿。
// 以前建完才问,点了「取消」库里就多出一张没人要的草稿(3C REQ-15)
const extractPosts = () => writes.filter((w) => w === 'POST /api/copilot/from-run').length
const discardAsk = page.getByRole('dialog').filter({ hasText: '未保存的改动' })
await page.evaluate(() => window.__studio.setState({ dirty: true,
workflow: { ...(window.__studio.getState().workflow ?? {}), id: 'fake0dirtycanvas', name: '没存的那张' } }))
const postsBefore = extractPosts()
await page.locator('[data-action=extract]').click()
const asked = await discardAsk.waitFor({ timeout: 3000 }).then(() => true, () => false)
check('画布有没保存的改动:先问要不要放弃,还没建草稿', asked && extractPosts() === postsBefore,
`${asked ? '问了' : '没问'} · 已发 ${extractPosts() - postsBefore} 次提取`)
await discardAsk.getByRole('button', { name: '取消' }).click().catch(() => {})
await page.waitForTimeout(400)
check('说了取消:不建草稿,留在记录页', extractPosts() === postsBefore && new URL(page.url()).pathname.startsWith('/runs'))
const countAsks = () => page.evaluate(() => {
window.__asks = 0
new MutationObserver(() => {
const d = [...document.querySelectorAll('[role=dialog]')].filter((e) => e.textContent?.includes('未保存的改动'))
if (d.length && !window.__askOpen) window.__asks += 1
window.__askOpen = d.length > 0
}).observe(document.body, { childList: true, subtree: true })
})
await countAsks()
await page.locator('[data-action=extract]').click()
await discardAsk.waitFor({ timeout: 3000 }).catch(() => {})
await discardAsk.getByRole('button', { name: '放弃并切换' }).click().catch(() => {})
await page.waitForURL(`**/studio/${wfs[0].id}`, { timeout: 5000 }).catch(() => {})
await page.waitForTimeout(400)
const asks = await page.evaluate(() => window.__asks ?? -1)
check('说了放弃:建一次草稿、跳过去,整条路只问这一遍', extractPosts() === postsBefore + 1 && asks === 1
&& new URL(page.url()).pathname === `/studio/${wfs[0].id}`, `提取 ${extractPosts() - postsBefore} 次 · 问了 ${asks} 遍`)
check('提取成功后跳到新草稿', new URL(page.url()).pathname === `/studio/${wfs[0].id}`)
fakes.delete('POST /api/copilot/from-run')
})
// ------------------------------------------------------------------ 运行中:接流、停止、不能删
const FAKE = 'fake0running0000000000000000000'
let t0, liveRun, liveEvents
await section('运行中:实时接流、停止、不能删', async () => {
t0 = Date.now() / 1000 - 3
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' }] }
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 } }))
// 左边列表里也放上这一条:跑完那一刻它要闪一下,而且闪完要摘掉
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.locator('[data-run-detail] [role=tab][data-tab=trace]').click()
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
const liveAtA = await page.locator('[data-trace-readout] [data-readout=at]').innerText().catch(() => '')
await page.waitForTimeout(500)
const liveAtB = await page.locator('[data-trace-readout] [data-readout=at]').innerText().catch(() => '')
const liveMode = await page.locator('[data-trace-readout]').getAttribute('data-mode').catch(() => '')
check('航迹上游标是实时的,而且在走', liveMode === '实时' && liveAtA !== liveAtB, `${liveMode} ${liveAtA} → ${liveAtB}`.replace(/\s+/g, ' '))
check('「此刻」列出在跑的「总结」', await page.locator('[data-trace-now] [data-now-node=b]').count() === 1)
await page.locator('[data-run-detail] [role=tab][data-tab=stream]').click()
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')
})
// ------------------------------------------------------------------ 等了很久之后接着跑
await section('等了几天的审批批掉之后:计时读得懂', async () => {
// 第一次开始在 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`)
})
// ------------------------------------------------------------------ 删除保护
await section('删除保护', async () => {
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}`)
})
// ------------------------------------------------------------------ 航迹
const iso = (s) => new Date(s * 1000).toISOString()
const TRACE = 'fake0trace00000000000000000000'
const WAIT_S = 300
const traceGraph = { nodes: [
{ id: 'in', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: {} } },
{ id: 'query', type: 'agent', position: { x: 200, y: 0 }, data: { label: '查询订单', config: { tools: ['db_query__shop'] } } },
{ id: 'gate', type: 'human', position: { x: 400, y: 0 }, data: { label: '人工审批', config: {} } },
{ id: 'sum', type: 'llm', position: { x: 600, y: 0 }, data: { label: '汇总', config: {} } },
], edges: [{ source: 'in', target: 'query' }, { source: 'query', target: 'gate' }, { source: 'gate', target: 'sum' }] }
const traceEvents = (usageIn = 2000) => [
{ seq: 1, type: 'run.started', node_id: null, ts: tt, data: { nodes: 4 } },
{ seq: 2, type: 'node.started', node_id: 'in', ts: tt + 0.1, data: { node_type: 'input', label: '输入' } },
{ seq: 3, type: 'node.finished', node_id: 'in', ts: tt + 0.2, data: { duration_ms: 100 } },
{ seq: 4, type: 'node.started', node_id: 'query', ts: tt + 0.3, data: { node_type: 'agent', label: '查询订单' } },
{ seq: 5, type: 'tool.start', node_id: 'query', ts: tt + 0.5,
data: { tool: 'db_query__shop', call_id: 'c1', args: { sql: 'select count(*) as n from orders' } } },
{ seq: 6, type: 'tool.end', node_id: 'query', ts: tt + 2.5,
data: { tool: 'db_query__shop', call_id: 'c1', duration_ms: 2000, preview: '{"columns":["n"],"rows":[[42]]}' } },
{ seq: 7, type: 'llm.end', node_id: 'query', ts: tt + 3.0,
data: { agent: '查询订单', model: 'demo-model', input_tokens: 1200, output_tokens: 300, cost_usd: 0.004 } },
{ seq: 8, type: 'node.finished', node_id: 'query', ts: tt + 3.1, data: { duration_ms: 2800, preview: '共 42 单' } },
{ seq: 9, type: 'node.started', node_id: 'gate', ts: tt + 3.2, data: { node_type: 'human', label: '人工审批' } },
{ seq: 10, type: 'human.requested', node_id: 'gate', ts: tt + 3.3, data: { mode: 'approve', prompt: '放行吗?' } },
{ seq: 11, type: 'run.interrupted', node_id: null, ts: tt + 3.4, data: {} },
{ seq: 12, type: 'human.resolved', node_id: 'gate', ts: tt + 3.4 + WAIT_S, data: { response: { approved: true }, actor: '张工' } },
{ seq: 13, type: 'run.resumed', node_id: null, ts: tt + 3.4 + WAIT_S, data: { actor: '张工' } },
{ seq: 14, type: 'node.finished', node_id: 'gate', ts: tt + 3.5 + WAIT_S, data: { duration_ms: 1 } },
{ seq: 15, type: 'node.started', node_id: 'sum', ts: tt + 3.6 + WAIT_S, data: { node_type: 'llm', label: '汇总' } },
{ seq: 16, type: 'llm.end', node_id: 'sum', ts: tt + 6.0 + WAIT_S,
data: { model: 'demo-model', input_tokens: 800, output_tokens: 500, cost_usd: 0.003 } },
{ seq: 17, type: 'node.finished', node_id: 'sum', ts: tt + 6.1 + WAIT_S, data: { duration_ms: 2500 } },
{ seq: 18, type: 'run.finished', node_id: null, ts: tt + 6.2 + WAIT_S, data: {
output: { answer: '共 42 单' }, usage: { input_tokens: usageIn, output_tokens: 800, cost_usd: 0.007 },
timing: { wall_ms: (6.2 + WAIT_S) * 1000, active_ms: 6200, wait_ms: WAIT_S * 1000 } } },
]
const traceRun = (id, usageIn = 2000) => ({
...base, id, workflow_id: wfHost.id, workflow_name: wfHost.name, status: 'succeeded', run_class: 'exploratory',
version: null, version_hash: null, manifest_hash: null, manifest_seq: null, error: null, error_node_id: null,
input: { q: '上周的订单' }, output: { answer: '共 42 单' },
usage: { input_tokens: usageIn, output_tokens: 800, cost_usd: 0.007, duration_ms: 6200,
wall_ms: (6.2 + WAIT_S) * 1000, active_ms: 6200, wait_ms: WAIT_S * 1000 },
created_at: iso(tt), started_at: iso(tt), finished_at: iso(tt + 6.2 + WAIT_S),
})
const viewTab = (k) => page.locator(`[data-run-detail] [role=tab][data-tab=${k}]`)
const readout = (k) => page.locator(`[data-trace-readout] [data-readout=${k}]`).innerText().catch(() => '')
// 实时、回放、终态写在游标那一格的标签行,数值那一行只有时刻
const mode = () => page.locator('[data-trace-readout]').getAttribute('data-mode').catch(() => '')
let tt, ART_Q
await section('航迹:按时间摊开,拖到哪一刻读到哪一刻', async () => {
// 一次等过 5 分钟审批的运行:查询 → 审批 → 汇总。名字都是编的通用示例
const TRACE_OFF = 'fake0traceoff00000000000000000'
tt = Date.now() / 1000 - 3600
ART_Q = 'a1'.repeat(32)
const ART_QOUT = 'b2'.repeat(32)
const ART_SUM = 'c3'.repeat(32)
const traceArtifacts = [
{ id: ART_Q, kind: 'query_snapshot', node_id: 'query', size: 2048, meta: {}, created_at: iso(tt + 2.5) },
{ id: ART_QOUT, kind: 'node_output', node_id: 'query', size: 120, meta: { type: 'agent', attempt: 1 }, created_at: iso(tt + 3.1) },
{ id: ART_SUM, kind: 'node_output', node_id: 'sum', size: 300, meta: { type: 'llm', attempt: 1 }, created_at: iso(tt + 6.1 + WAIT_S) },
]
for (const [id, usageIn] of [[TRACE, 2000], [TRACE_OFF, 5000]]) {
fakes.set(`GET /api/runs/${id}`, () => ({ status: 200, json: traceRun(id, usageIn) }))
fakes.set(`GET /api/runs/${id}/events`, () => ({ status: 200, json: traceEvents(usageIn) }))
fakes.set(`GET /api/runs/${id}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${id}/artifacts`, () => ({ status: 200, json: traceArtifacts }))
}
fakes.set(`GET /api/artifacts/${ART_Q}`, () => ({ status: 200, json: { id: ART_Q, content: {
tool: 'db_query__shop', args: { sql: 'select count(*) as n from orders' }, result: '{"columns":["n"],"rows":[[42]]}',
} } }))
// 工作流「现在」的图:这些运行之后改过结构——「汇总」删了,换成「通知」;「查询订单」还在。
// 画布上还对得准的节点给「在画布中看这一步」,已经没了的不给(给了也对不到任何东西)
const wfNow = (await getJson('/workflows')).map((w) => (w.id !== wfHost.id ? w : { ...w, graph: {
nodes: [...traceGraph.nodes.filter((n) => n.id !== 'sum'),
{ id: 'notify', type: 'output', position: { x: 600, y: 0 }, data: { label: '通知', config: {} } }],
edges: [...traceGraph.edges.filter((e) => e.target !== 'sum'), { source: 'gate', target: 'notify' }],
} }))
fakes.set('GET /api/workflows', () => ({ status: 200, json: wfNow }))
await page.goto(`${WEB}/runs/${TRACE}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail]').waitFor()
check('详情分「时间线 / 航迹 / 工件」三个页签',
await viewTab('stream').count() === 1 && await viewTab('trace').count() === 1 && await viewTab('artifacts').count() === 1)
check('默认停在时间线', await viewTab('stream').getAttribute('aria-selected').catch(() => null) === 'true')
check('工件页签上写着有几件', (await viewTab('artifacts').innerText().catch(() => '')).includes('3'))
check('读屏念的是「3 件」,不是「3 条」',
await viewTab('artifacts').locator('[aria-label]').getAttribute('aria-label').catch(() => null) === '3 件',
await viewTab('artifacts').locator('[aria-label]').getAttribute('aria-label').catch(() => '') ?? '')
await viewTab('trace').click().catch(() => {})
await page.locator('[data-run-trace] section[aria-label="航迹"]').waitFor({ timeout: 5000 }).catch(() => {})
check('点「航迹」地址记下 ?view=trace', new URL(page.url()).searchParams.get('view') === 'trace')
const lanes = await page.locator('[data-run-trace] [data-lane]').evaluateAll((els) => els.map((e) => e.getAttribute('data-lane')))
check('每个节点一条泳道,按执行顺序排', lanes.join(',') === 'in,query,gate,sum', lanes.join(',') || '没有泳道')
check('没拖游标时读的是终态:已完成、2.8k tok',
(await readout('phase')).includes('已完成') && (await readout('tokens')).includes('2.8k'),
`${await readout('phase')} / ${await readout('tokens')}`)
check('没选节点时列出最耗时的节点(慢在哪)',
(await page.locator('[data-trace-slowest] [data-slow-node]').first().getAttribute('data-slow-node').catch(() => null)) === 'query')
// 地址里带着时刻:从工件、分享的链接直接落到那一刻
await page.goto(`${WEB}/runs/${TRACE}?view=trace&at=2500`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
check('?at=2500 落到 T+00:02.5', (await readout('at')).includes('00:02.5'), await readout('at'))
check('游标在时间轴上也在回放', (await page.locator('[data-run-trace] [role=slider]').getAttribute('aria-valuenow').catch(() => null)) === '2500')
check('那一刻在跑的是「查询订单」', await page.locator('[data-trace-now] [data-now-node=query]').count() === 1)
check('那一刻整次运行还没用 token(模型 3.0 s 才回)', /^0\b/.test((await readout('tokens')).trim()), await readout('tokens'))
check('进来之后地址里的时刻摘掉(拖动时不再和它对不上)', !new URL(page.url()).searchParams.has('at'))
// 慢在哪:那一刻卡在哪个工具上(查询 0.5 s 开始、2.5 s 返回)
await page.goto(`${WEB}/runs/${TRACE}?view=trace&at=2000`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
await page.locator('[data-run-trace] [data-lane=query] .tl-label').click().catch(() => {})
const callText = await page.locator('[data-trace-node=query] [data-node-field=call]').innerText().catch(() => '')
check('那一刻在跑的节点,卡片写着正在调哪个工具、调了多久', /db_query__shop.*已 1\.5 s/.test(callText), callText || '没有这一行')
check('点泳道名只是选中节点,游标不动', (await readout('at')).includes('00:02.0'), await readout('at'))
// 拖动:在坞的刻度行上按下、拖过去,读数跟着游标走
const track = page.locator('[data-run-trace] .tl-scrub').first()
const box = await track.boundingBox().catch(() => null)
if (box) {
await page.mouse.move(box.x + box.width * 0.05, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + box.width * 0.15, box.y + box.height / 2, { steps: 4 })
await page.waitForTimeout(120)
const mid = `${await mode()} ${await readout('at')}`
await page.mouse.move(box.x + box.width * 0.3, box.y + box.height / 2, { steps: 4 })
await page.mouse.up()
await page.waitForTimeout(150)
const after = `${await mode()} ${await readout('at')}`
check('拖动游标:读数跟着走,并标成回放', mid.includes('回放') && after.includes('回放') && mid !== after, `${mid} → ${after}`)
} else {
check('拖动游标:读数跟着走,并标成回放', false, '找不到坞的刻度行')
}
const slider = page.locator('[data-run-trace] [role=slider]')
await slider.focus().catch(() => {})
await page.keyboard.press('Home')
await page.waitForTimeout(100)
check('键盘 Home:游标回到开始', (await readout('at')).includes('00:00.0'), await readout('at'))
await page.keyboard.press('End')
await page.waitForTimeout(100)
check('键盘 End:回到终态读数', await mode() === '终态', await mode())
// 关键时刻:长时间等人的空档被压缩了,拖游标很难正好停在那一下
const moments = page.locator('[data-trace-moments] [data-moment]')
const momentText = (await moments.allInnerTexts().catch(() => [])).map((t) => t.replace(/\s+/g, ' '))
check('列出关键时刻:开始、停下等审批、审批已处理(等了多久)、结局',
momentText.length === 4 && momentText[0].includes('开始运行') && momentText[1].includes('「人工审批」停下等审批')
&& momentText[2].includes('审批已处理') && momentText[2].includes('5 分 00 秒') && momentText[3].includes('运行完成'),
momentText.join(' | '))
check('没拖游标时,当前落在结局那一条', await moments.nth(3).getAttribute('aria-current').catch(() => null) === 'step')
await moments.nth(2).click().catch(() => {})
await page.waitForTimeout(150)
check('点「审批已处理」:游标跳到那一刻(T+05:03.4)', await mode() === '回放' && (await readout('at')).includes('05:03.4'),
`${await mode()} ${await readout('at')}`)
check('跳过去之后它成了当前那一条', await moments.nth(2).getAttribute('aria-current').catch(() => null) === 'step')
check('坞上的游标也跟着过去', (await slider.getAttribute('aria-valuenow').catch(() => null)) === '303400',
await slider.getAttribute('aria-valuenow').catch(() => '') ?? '')
await moments.nth(3).click().catch(() => {})
await page.waitForTimeout(150)
check('点结局那一条回到终态', await mode() === '终态', await mode())
await page.goto(`${WEB}/runs/${TRACE}?view=trace&at=30000`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
check('拖到等审批那一段:状态是等待审批', (await readout('phase')).includes('等待审批'), await readout('phase'))
check('那一刻等审批的是「人工审批」', await page.locator('[data-trace-now] [data-now-node=gate]').count() === 1)
check('用量只算到那一刻:1.5k', (await readout('tokens')).includes('1.5k'), await readout('tokens'))
await page.locator('[data-run-trace] [data-lane=query]').click().catch(() => {})
const nodeCard = page.locator('[data-trace-node=query]')
check('点泳道看这个节点在那一刻的样子', await nodeCard.count() === 1)
check('节点的用量、工具数取自那一刻',
(await nodeCard.locator('[data-node-field=tokens]').innerText().catch(() => '')).includes('1.5k')
&& (await nodeCard.locator('[data-node-field=tools]').innerText().catch(() => '')).includes('1'))
check('节点卡能回画布看这一步',
(await nodeCard.locator('[data-action=node-canvas]').getAttribute('href').catch(() => null)) === `/studio/${wfHost.id}?run=${TRACE}&focus=query`)
await page.locator('[data-run-trace] [data-lane=sum] .tl-label').click().catch(() => {})
const goneCard = page.locator('[data-trace-node=sum]')
check('工作流现在的图里已经没有的节点:节点卡不给落空的「在画布中看这一步」,说明为什么',
await goneCard.count() === 1 && await goneCard.locator('[data-action=node-canvas]').count() === 0
&& await goneCard.locator('[data-node-gone]').count() === 1)
await page.locator('[data-run-trace] [data-lane=query] .tl-label').click().catch(() => {})
await nodeCard.locator('[data-action=node-stream]').click().catch(() => {})
await page.waitForTimeout(300)
check('「在时间线里看」切回时间线', await viewTab('stream').getAttribute('aria-selected').catch(() => null) === 'true')
check('并且把那一步描出来', await page.locator('[data-run-detail] [data-node-id="query"][data-flash]').count() >= 1)
await page.goto(`${WEB}/runs/${TRACE_OFF}?view=trace&at=30000`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
check('各次调用加起来对不上后端总数时,那一刻的用量写「—」,不拿终值冒充', (await readout('tokens')).trim().startsWith('—'), await readout('tokens'))
})
// 模型把工具调用写成了文字、一次都没真正查(引擎判失败):结局是失败,而且说得出为什么
await section('航迹:模型没有真正调用工具', async () => {
const LEAK = 'fake0leak000000000000000000000'
const LEAK_ERROR = '模型输出了工具调用的原始标记,但没有真正调用工具,这一步一次都没查到数据。常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。到画布里给这个节点绑定要用的工具;绑定了还这样,就换一个支持工具调用的模型'
fakes.set(`GET /api/runs/${LEAK}`, () => ({ status: 200, json: {
...traceRun(LEAK), status: 'failed', output: null, error: LEAK_ERROR, error_node_id: 'query',
usage: { input_tokens: 900, output_tokens: 400, cost_usd: 0.002, duration_ms: 4200, wall_ms: 4200, active_ms: 4200, wait_ms: 0 },
finished_at: iso(tt + 4.2),
} }))
fakes.set(`GET /api/runs/${LEAK}/events`, () => ({ status: 200, json: [
...traceEvents().slice(0, 4),
{ seq: 5, type: 'llm.end', node_id: 'query', ts: tt + 2.0,
data: { agent: '查询订单', model: 'demo-model', input_tokens: 450, output_tokens: 200, cost_usd: 0.001 } },
{ seq: 6, type: 'log', node_id: 'query', ts: tt + 2.1, data: {
level: 'warn', code: 'tool_markup_leak', message: '模型把工具调用写成了文字(<||DSML||invoke name="db_query__shop"…),没有真正调用工具' } },
{ seq: 7, type: 'llm.end', node_id: 'query', ts: tt + 4.0,
data: { agent: '查询订单', model: 'demo-model', input_tokens: 450, output_tokens: 200, cost_usd: 0.001 } },
{ seq: 8, type: 'node.failed', node_id: 'query', ts: tt + 4.1, data: { error: LEAK_ERROR, duration_ms: 3800 } },
{ seq: 9, type: 'run.failed', node_id: null, ts: tt + 4.2, data: {
error: LEAK_ERROR, node_id: 'query', timing: { wall_ms: 4200, active_ms: 4200, wait_ms: 0 } } },
] }))
fakes.set(`GET /api/runs/${LEAK}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${LEAK}/artifacts`, () => ({ status: 200, json: [] }))
await page.goto(`${WEB}/runs/${LEAK}?view=trace`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
const leakBanner = (await page.locator('[data-run-banner=failed]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('失败横幅说清是模型没有真正调用工具', leakBanner.includes('没有真正调用工具'), leakBanner.slice(0, 60))
const leakLocate = page.locator('[data-run-banner=failed] [data-action=locate]')
check('失败的节点还在工作流现在的图里:「在画布中定位」对准它;接着跑过不去,它就是主按钮',
await leakLocate.getAttribute('href').catch(() => null) === `/studio/${wfHost.id}?run=${LEAK}&focus=query`
&& /\bbtn-primary\b/.test(await leakLocate.getAttribute('class').catch(() => '') ?? '')
&& await page.locator('[data-run-banner=failed] [data-action=locate-trace]').count() === 0)
const leakMoments = (await page.locator('[data-trace-moments] [data-moment]').allInnerTexts().catch(() => [])).map((t) => t.replace(/\s+/g, ' '))
check('关键时刻里有那一下警告,结局写失败于哪个节点',
leakMoments.some((t) => t.includes('「查询订单」把工具调用写成了文字'))
&& (leakMoments.at(-1) ?? '').includes('失败于「查询订单」') && (leakMoments.at(-1) ?? '').includes('没有真正调用工具'),
leakMoments.join(' | '))
const leakCard = (await page.locator('[data-trace-node=query]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('失败的节点默认选中,卡片上写着警告和原因',
leakCard.includes('把工具调用写成了文字') && leakCard.includes('没有真正调用工具') && leakCard.includes('失败'),
leakCard.slice(0, 80))
await page.locator('[data-trace-moments] [data-moment="query:markup"]').click().catch(() => {})
await page.waitForTimeout(150)
check('跳到警告那一刻:节点还在跑,卡片上已经有警告',
(await readout('at')).includes('00:02.1') && (await page.locator('[data-trace-node=query] [data-node-warn]').count()) === 1,
await readout('at'))
})
// 工具库里的参数定义写坏了:要改的东西不在运行快照里,先去改、再接着跑。以前「接着跑」是主按钮,
// 人先点它,同样的错再来一遍;「去工具库」只到列表,还得自己找是哪一个(3C REQ-1 / REQ-5)
await section('失败:自定义工具的参数定义写坏了,先去改', async () => {
const BROKEN = 'fake0brokentool000000000000000'
const BROKEN_ERROR = '自定义工具「lookup_order」的参数定义格式不对:参数 store 要写成 {"type": "string"} 这样的对象,不能直接写 "string"。到「工具」页把它的参数定义改好再运行'
+ ';自定义工具「fetch_rate」的参数定义格式不对:类型「int」认不出来,是不是想写 integer;只能是 string、number、integer、boolean、array、object。到「工具」页把它的参数定义改好再运行'
fakes.set(`GET /api/runs/${BROKEN}`, () => ({ status: 200, json: {
...traceRun(BROKEN), status: 'failed', output: null, error: BROKEN_ERROR, error_node_id: 'query',
usage: { input_tokens: 0, output_tokens: 0, cost_usd: 0, duration_ms: 400, wall_ms: 400, active_ms: 400, wait_ms: 0 },
finished_at: iso(tt + 0.4),
} }))
fakes.set(`GET /api/runs/${BROKEN}/events`, () => ({ status: 200, json: [
...traceEvents().slice(0, 4),
{ seq: 5, type: 'node.failed', node_id: 'query', ts: tt + 0.35, data: { error: BROKEN_ERROR, duration_ms: 50 } },
{ seq: 6, type: 'run.failed', node_id: null, ts: tt + 0.4, data: {
error: BROKEN_ERROR, node_id: 'query', timing: { wall_ms: 400, active_ms: 400, wait_ms: 0 } } },
] }))
fakes.set(`GET /api/runs/${BROKEN}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${BROKEN}/artifacts`, () => ({ status: 200, json: [] }))
await page.goto(`${WEB}/runs/${BROKEN}?view=stream`, { waitUntil: 'networkidle' })
await page.locator('[data-run-banner=failed]').waitFor({ timeout: 5000 }).catch(() => {})
const brokenFix = page.locator('[data-run-banner=failed] [data-action=tools]')
const brokenGo = page.locator('[data-run-banner=failed] [data-action=continue]')
check('标题说有几个工具坏了,不只认第一个',
(await page.locator('[data-failed-title]').innerText().catch(() => '')).trim() === '2 个自定义工具的参数定义写坏了')
check('主按钮是「去改参数定义」,直达第一个坏工具的编辑框',
(await brokenFix.innerText().catch(() => '')).includes('去改参数定义')
&& /\bbtn-primary\b/.test(await brokenFix.getAttribute('class').catch(() => '') ?? '')
&& await brokenFix.getAttribute('href').catch(() => null) === '/tools/custom?edit=lookup_order',
await brokenFix.getAttribute('href').catch(() => '没有这个按钮') ?? '')
check('「接着跑」还在(改好之后接着跑能过),但退成次要、排在后面',
await brokenGo.count() === 1 && !/\bbtn-primary\b/.test(await brokenGo.getAttribute('class').catch(() => '') ?? '')
&& await page.evaluate(() => {
const fix = document.querySelector('[data-run-banner=failed] [data-action=tools]')
const go = document.querySelector('[data-run-banner=failed] [data-action=continue]')
return !!fix && !!go && !!(fix.compareDocumentPosition(go) & Node.DOCUMENT_POSITION_FOLLOWING)
}))
const streamFix = page.locator('[data-view-pane=stream] [data-turn-error] [data-fix=tools]')
check('时间线里的报错也直达那个工具,写的是「去改参数定义」',
await streamFix.getAttribute('href').catch(() => null) === '/tools/custom?edit=lookup_order'
&& (await streamFix.innerText().catch(() => '')).includes('去改参数定义'),
await streamFix.getAttribute('href').catch(() => '没有这个入口') ?? '')
await brokenFix.click().catch(() => {})
await page.waitForURL((u) => u.pathname === '/tools/custom', { timeout: 5000 }).catch(() => {})
check('点了打开工具页的自定义工具', new URL(page.url()).pathname === '/tools/custom')
// 归不了类的失败:标题就是原话。时间线里的报错不再在「技术细节」里把同一句重复一遍(3C REQ-25)
const PLAIN = 'fake0plainfail0000000000000000'
const PLAIN_ERROR = '汇总节点交回的内容是空的'
fakes.set(`GET /api/runs/${PLAIN}`, () => ({ status: 200, json: {
...traceRun(PLAIN), status: 'failed', output: null, error: PLAIN_ERROR, error_node_id: 'query', finished_at: iso(tt + 0.4),
} }))
fakes.set(`GET /api/runs/${PLAIN}/events`, () => ({ status: 200, json: [
...traceEvents().slice(0, 4),
{ seq: 5, type: 'node.failed', node_id: 'query', ts: tt + 0.35, data: { error: PLAIN_ERROR, duration_ms: 50 } },
{ seq: 6, type: 'run.failed', node_id: null, ts: tt + 0.4, data: { error: PLAIN_ERROR, node_id: 'query', timing: { wall_ms: 400, active_ms: 400, wait_ms: 0 } } },
] }))
fakes.set(`GET /api/runs/${PLAIN}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${PLAIN}/artifacts`, () => ({ status: 200, json: [] }))
await page.goto(`${WEB}/runs/${PLAIN}?view=stream`, { waitUntil: 'networkidle' })
await page.locator('[data-view-pane=stream] [data-turn-error]').waitFor({ timeout: 5000 }).catch(() => {})
const plainErr = page.locator('[data-view-pane=stream] [data-turn-error]')
check('原话就是标题时,时间线的报错不再收一份一模一样的技术细节',
(await plainErr.innerText().catch(() => '')).includes(PLAIN_ERROR) && await plainErr.locator('details').count() === 0,
`${await plainErr.locator('details').count()} 个技术细节`)
for (const k of ['', '/events', '/graph', '/artifacts']) fakes.delete(`GET /api/runs/${PLAIN}${k}`)
})
// 等审批时实时流断了,重连上来后端回 stream.end{interrupted};这一刻审批正好在别处批掉、
// 运行还没改状态(后端先记审批、再改运行)。以前流收尾时运行和审批分两头查,拼出
// 「interrupted、没有审批」就判成已挂起,而且再没人重查,一直挂着「可续跑」(3C REQ-3)
const GAP = 'fake0gapstream0000000000000000'
let gapApproval
await section('等审批时流断了又重连,撞上审批刚批掉的空档', async () => {
let gapPhase = 'live'
let gapReads = 0
const gapSockets = []
fakes.set(`GET /api/runs/${GAP}`, () => ({ status: 200, json: {
...traceRun(GAP), status: gapPhase === 'live' ? 'running' : gapReads++ === 0 ? 'interrupted' : 'running',
output: null, finished_at: null,
} }))
fakes.set(`GET /api/runs/${GAP}/events`, () => ({ status: 200, json: traceEvents().slice(0, gapPhase === 'live' ? 9 : 11) }))
fakes.set(`GET /api/runs/${GAP}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${GAP}/artifacts`, () => ({ status: 200, json: [] }))
gapApproval = { id: 'fake0gapapproval', run_id: GAP, node_id: 'gate', status: 'pending', mode: 'approve',
prompt: '放行吗?', created_at: iso(tt + 3.3), workflow_id: wfHost.id, workflow_name: '订单日报', node_label: '人工审批' }
fakes.set('GET /api/approvals', (url) => ({ status: 200, json: gapPhase === 'live'
&& (!url.searchParams.get('run_id') || url.searchParams.get('run_id') === GAP) ? [gapApproval] : [] }))
await page.routeWebSocket(new RegExp(`/api/runs/${GAP}/stream`), (ws) => {
gapSockets.push(ws)
// 第二条连接:重连上来,后端看运行还是 interrupted,按协议回结束标记
if (gapSockets.length === 2) ws.send(JSON.stringify({ type: 'stream.end', status: 'interrupted', data: { status: 'interrupted' } }))
// 再往后是确认它在跑之后接回来的:补上别处批掉之后的事件
if (gapSockets.length >= 3) {
const t = Date.now() / 1000
ws.send(JSON.stringify({ seq: 12, type: 'human.resolved', node_id: 'gate', ts: t, data: { response: { approved: true }, actor: '张工' } }))
ws.send(JSON.stringify({ seq: 13, type: 'run.resumed', node_id: null, ts: t, data: { actor: '张工' } }))
}
})
await page.goto(`${WEB}/runs/${GAP}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail][data-run-code=running]').waitFor({ timeout: 5000 }).catch(() => {})
const gapT = Date.now() / 1000
gapSockets[0]?.send(JSON.stringify({ seq: 10, type: 'human.requested', node_id: 'gate', ts: gapT, data: { mode: 'approve', prompt: '放行吗?' } }))
gapSockets[0]?.send(JSON.stringify({ seq: 11, type: 'run.interrupted', node_id: null, ts: gapT, data: {} }))
await page.locator('[data-run-detail][data-run-code=waiting]').waitFor({ timeout: 5000 }).catch(() => {})
await recordCodes()
gapPhase = 'gap'
await gapSockets[0]?.close()
const gapBack = await page.waitForFunction(
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'running', null, { timeout: 9000 },
).then(() => true, () => false)
const gapCodes = await recordedCodes()
check('空档里没有判成「已挂起」', !gapCodes.includes('held'), gapCodes.join(' → '))
check('再看一次发现在跑:状态回到运行中,接回实时流', gapBack && gapSockets.length >= 3, `${gapSockets.length} 条连接`)
for (const k of ['', '/events', '/graph', '/artifacts']) fakes.delete(`GET /api/runs/${GAP}${k}`)
fakes.delete('GET /api/approvals')
})
// 同一个空档,但别处批掉之后运行转眼就跑完了:再看一次已经是 succeeded。流停在审批上,
// 之后的事件这里一条也没收到,只把头上换成已完成的话,时间线永远停在审批卡上:没有
// 「放行」、没有汇总那一步、没有收尾(3C 返工 D1)
await section('等审批时流断了又重连,空档之后运行已经跑完', async () => {
const GAPEND = 'fake0gapfinish0000000000000000'
let gapEndPhase = 'live'
let gapEndReads = 0
const gapEndSockets = []
fakes.set(`GET /api/runs/${GAPEND}`, () => {
if (gapEndPhase === 'live') return { status: 200, json: { ...traceRun(GAPEND), status: 'running', output: null, finished_at: null } }
return { status: 200, json: gapEndReads++ === 0
? { ...traceRun(GAPEND), status: 'interrupted', output: null, finished_at: null }
: traceRun(GAPEND) }
})
fakes.set(`GET /api/runs/${GAPEND}/events`, () => ({ status: 200, json: gapEndPhase === 'live' ? traceEvents().slice(0, 9) : traceEvents() }))
fakes.set(`GET /api/runs/${GAPEND}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${GAPEND}/artifacts`, () => ({ status: 200, json: [] }))
const gapEndApproval = { ...gapApproval, id: 'fake0gapendapproval', run_id: GAPEND }
fakes.set('GET /api/approvals', (url) => ({ status: 200, json: gapEndPhase === 'live'
&& (!url.searchParams.get('run_id') || url.searchParams.get('run_id') === GAPEND) ? [gapEndApproval] : [] }))
await page.routeWebSocket(new RegExp(`/api/runs/${GAPEND}/stream`), (ws) => {
gapEndSockets.push(ws)
if (gapEndSockets.length === 2) ws.send(JSON.stringify({ type: 'stream.end', status: 'interrupted', data: { status: 'interrupted' } }))
})
await page.goto(`${WEB}/runs/${GAPEND}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail][data-run-code=running]').waitFor({ timeout: 5000 }).catch(() => {})
const gapEndT = Date.now() / 1000
gapEndSockets[0]?.send(JSON.stringify({ seq: 10, type: 'human.requested', node_id: 'gate', ts: gapEndT, data: { mode: 'approve', prompt: '放行吗?' } }))
gapEndSockets[0]?.send(JSON.stringify({ seq: 11, type: 'run.interrupted', node_id: null, ts: gapEndT, data: {} }))
await page.locator('[data-run-detail][data-run-code=waiting]').waitFor({ timeout: 5000 }).catch(() => {})
await recordCodes()
gapEndPhase = 'gap'
await gapEndSockets[0]?.close()
const gapEndDone = await page.waitForFunction(
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'succeeded', null, { timeout: 9000 },
).then(() => true, () => false)
const gapEndSum = page.locator('[data-view-pane=stream] [data-step-status][data-node-id=sum]')
await gapEndSum.first().waitFor({ timeout: 4000 }).catch(() => {})
const gapEndCodes = await recordedCodes()
const gapEndCount = Number(((await page.locator('[data-run-detail] > header').innerText().catch(() => ''))
.match(/([\d,]+) 条事件/)?.[1] ?? '0').replace(/,/g, ''))
check('空档之后已经跑完:状态落到已完成,中间没有判成「已挂起」', gapEndDone && !gapEndCodes.includes('held'), gapEndCodes.join(' → '))
check('时间线补上了审批之后的事:汇总那一步在', await gapEndSum.count() > 0, `${await gapEndSum.count()} 行`)
check('事件整条补齐,条数和后端一致', gapEndCount === traceEvents().length, `${gapEndCount} / ${traceEvents().length} 条`)
for (const k of ['', '/events', '/graph', '/artifacts']) fakes.delete(`GET /api/runs/${GAPEND}${k}`)
fakes.delete('GET /api/approvals')
})
// 同一个节点执行了三次:第一次写成文字被提醒、重答了;第二次收尾轮还想调工具;第三次干干净净。
// 节点卡片说的是游标那一刻所在的那一次,别把上一次的事挂到这一次上,也别因为后面还有一件就看不到这一次的
await section('航迹:节点卡片按这一次执行说事(3C REQ-13)', async () => {
const REEXEC = 'fake0reexec0000000000000000000'
fakes.set(`GET /api/runs/${REEXEC}`, () => ({ status: 200, json: {
...traceRun(REEXEC), output: { answer: '共 42 单' },
usage: { input_tokens: 900, output_tokens: 400, cost_usd: 0.002, duration_ms: 3200, wall_ms: 3200, active_ms: 3200, wait_ms: 0 },
finished_at: iso(tt + 3.2),
} }))
fakes.set(`GET /api/runs/${REEXEC}/events`, () => ({ status: 200, json: [
...traceEvents().slice(0, 4),
{ seq: 5, type: 'log', node_id: 'query', ts: tt + 1.0, data: {
level: 'warn', code: 'tool_markup_leak', message: '查询订单把工具调用写成了文字(<||DSML||invoke name="db_query__shop"…),没有真正调用工具,已提醒它重试一次' } },
{ seq: 6, type: 'node.finished', node_id: 'query', ts: tt + 1.5, data: { duration_ms: 1200, preview: '共 40 单' } },
{ seq: 7, type: 'node.started', node_id: 'query', ts: tt + 2.0, data: { node_type: 'agent', label: '查询订单' } },
{ seq: 8, type: 'log', node_id: 'query', ts: tt + 2.5, data: {
level: 'warn', code: 'tool_markup_leak', message: '查询订单收尾轮仍想调用工具(<||DSML||invoke name="db_query__shop"…),交出前面写的内容' } },
{ seq: 9, type: 'node.finished', node_id: 'query', ts: tt + 3.0, data: { duration_ms: 1000, preview: '共 41 单' } },
{ seq: 10, type: 'node.started', node_id: 'query', ts: tt + 3.05, data: { node_type: 'agent', label: '查询订单' } },
{ seq: 11, type: 'node.finished', node_id: 'query', ts: tt + 3.15, data: { duration_ms: 100, preview: '共 42 单' } },
{ seq: 12, type: 'run.finished', node_id: null, ts: tt + 3.2, data: {
output: { answer: '共 42 单' }, usage: { input_tokens: 900, output_tokens: 400, cost_usd: 0.002 },
timing: { wall_ms: 3200, active_ms: 3200, wait_ms: 0 } } },
] }))
fakes.set(`GET /api/runs/${REEXEC}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${REEXEC}/artifacts`, () => ({ status: 200, json: [] }))
await page.goto(`${WEB}/runs/${REEXEC}?view=trace&at=1200`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
await page.locator('[data-trace-now] [data-now-node=query]').click({ timeout: 5000 }).catch(() => {})
await page.waitForTimeout(150)
const warnsOf = async () => (await page.locator('[data-trace-node=query] [data-node-warn]').allInnerTexts().catch(() => [])).join(' | ')
const firstExec = await warnsOf()
check('回放到第一次执行:说它被提醒后重答了,看不到第二次才有的收尾轮那件事',
firstExec.includes('提醒之后重答了') && !firstExec.includes('收尾轮'), firstExec || '(没有提醒)')
await page.goto(`${WEB}/runs/${REEXEC}?view=trace&at=2700`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
await page.locator('[data-trace-now] [data-now-node=query]').click({ timeout: 5000 }).catch(() => {})
await page.waitForTimeout(150)
const secondExec = await warnsOf()
check('回放到第二次执行:说收尾轮还想调工具、交出来的可能不完整,不说「重答」',
secondExec.includes('收尾轮仍想调用工具') && !secondExec.includes('重答'), secondExec || '(没有提醒)')
await page.locator('[data-run-trace] [role=slider]').focus().catch(() => {})
await page.keyboard.press('End')
await page.waitForTimeout(150)
check('回到终态(第三次执行,干干净净):不挂前两次的提醒',
await mode() === '终态' && await page.locator('[data-trace-node=query]').count() === 1 && !(await warnsOf()),
`${await mode()} ${await warnsOf() || '(没有提醒)'}`)
// 关键时刻和节点卡同一个说法:两次执行各一个时刻,各用各的措辞。以前只取整次运行最后一次,
// 第一次那下从列表里消失,收尾轮那下又被写成「把工具调用写成了文字」(3C REQ-23)
const reMoments = await page.locator('[data-trace-moments] [data-moment*=":markup"]').evaluateAll((els) =>
els.map((e) => ({ key: e.getAttribute('data-moment'), text: (e.textContent ?? '').replace(/\s+/g, ' ') })))
const nudgeM = reMoments.find((m) => m.key === 'query:markup0')
const settleM = reMoments.find((m) => m.key === 'query:markup')
check('两次执行各有一个时刻:前一次带序号,最后一次沿用 query:markup', reMoments.length === 2 && !!nudgeM && !!settleM,
reMoments.map((m) => m.key).join(', '))
check('第一次写「把工具调用写成了文字、提醒之后重答了」,第二次写「收尾轮仍想调用工具」',
!!nudgeM?.text.includes('把工具调用写成了文字') && !!nudgeM?.text.includes('重答') && !nudgeM?.text.includes('收尾轮')
&& !!settleM?.text.includes('收尾轮仍想调用工具') && !settleM?.text.includes('写成了文字'),
reMoments.map((m) => m.text).join(' | '))
})
// 失败在「汇总」上,而工作流之后把「汇总」删了:画布上对准它什么也对不到
await section('失败的节点在工作流现在的图里已经没了', async () => {
const GONE = 'fake0gonefail00000000000000000'
const GONE_ERROR = '节点「汇总」失败:模型服务出错了(HTTP 500)'
fakes.set(`GET /api/runs/${GONE}`, () => ({ status: 200, json: {
...traceRun(GONE), status: 'failed', output: null, error: GONE_ERROR, error_node_id: 'sum', finished_at: iso(tt + 6.2 + WAIT_S),
} }))
fakes.set(`GET /api/runs/${GONE}/events`, () => ({ status: 200, json: [
...traceEvents().slice(0, 15),
{ seq: 16, type: 'node.failed', node_id: 'sum', ts: tt + 6.0 + WAIT_S, data: { error: GONE_ERROR, duration_ms: 2400 } },
{ seq: 17, type: 'run.failed', node_id: null, ts: tt + 6.1 + WAIT_S, data: {
error: GONE_ERROR, node_id: 'sum', timing: { wall_ms: (6.1 + WAIT_S) * 1000, active_ms: 6100, wait_ms: WAIT_S * 1000 } } },
] }))
fakes.set(`GET /api/runs/${GONE}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${GONE}/artifacts`, () => ({ status: 200, json: [] }))
await page.goto(`${WEB}/runs/${GONE}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-banner=failed]').waitFor({ timeout: 5000 }).catch(() => {})
check('横幅不给落空的「在画布中定位」,改给「在航迹中看」',
await page.locator('[data-run-banner=failed] [data-action=locate]').count() === 0
&& await page.locator('[data-run-banner=failed] [data-action=locate-trace]').count() === 1)
const goneHead = page.locator('[data-run-detail] > header [data-action=open-canvas]')
check('详情头照样能在画布中回放,只是不带对不上的 focus',
await goneHead.getAttribute('href').catch(() => null) === `/studio/${wfHost.id}?run=${GONE}`
&& !/对准/.test(await goneHead.getAttribute('title').catch(() => '') ?? ''),
await goneHead.getAttribute('href').catch(() => '没有这个按钮') ?? '')
await page.locator('[data-run-banner=failed] [data-action=locate-trace]').click().catch(() => {})
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
check('「在航迹中看」:切到航迹,选中失败的那个节点,停在终态',
await viewTab('trace').getAttribute('aria-selected').catch(() => null) === 'true'
&& await page.locator('[data-trace-node=sum]').count() === 1 && await mode() === '终态')
check('那张节点卡也说清画布上已经没有它', await page.locator('[data-trace-node=sum] [data-node-gone]').count() === 1)
})
// ------------------------------------------------------------------ 工件
await section('工件:按节点分组,就地打开', async () => {
await page.goto(`${WEB}/runs/${TRACE}?view=artifacts`, { waitUntil: 'networkidle' })
await page.locator('[data-run-artifacts]').waitFor({ timeout: 5000 }).catch(() => {})
const groups = await page.locator('[data-run-artifacts] [data-artifact-group]').evaluateAll((els) => els.map((e) => e.getAttribute('data-artifact-group')))
check('按节点分组,按执行顺序', groups.join(',') === 'query,sum', groups.join(',') || '没有分组')
const qText = await page.locator(`[data-artifact-id="${ART_Q}"]`).innerText().catch(() => '')
check('每件写着类型和大小', qText.includes('查询快照') && qText.includes('2.0 KB'), qText.replace(/\s+/g, ' '))
await page.locator(`[data-artifact-id="${ART_Q}"] [data-action=artifact-open]`).click().catch(() => {})
const artDialog = page.getByRole('dialog')
// 弹窗先出来、内容取回来之后才画表:等到解开的那一块再读
await artDialog.locator('[data-artifact]').waitFor({ timeout: 5000 }).catch(() => {})
const artText = await artDialog.innerText().catch(() => '')
check('用证据查看器打开:带节点名,解开 SQL 和结果表',
artText.includes('查询订单') && artText.includes('select count(*)') && artText.includes('42'), artText.replace(/\s+/g, ' ').slice(0, 90))
await page.keyboard.press('Escape')
await page.locator(`[data-artifact-id="${ART_Q}"] [data-action=artifact-moment]`).click().catch(() => {})
await page.waitForTimeout(400)
check('「在航迹里看这一刻」跳到航迹、定位到它产出的时刻',
await viewTab('trace').getAttribute('aria-selected').catch(() => null) === 'true' && (await readout('at')).includes('00:02.5'),
await readout('at'))
const realWithArt = []
for (const r of allRuns.slice(0, 40)) {
const a = await getJson(`/runs/${r.id}/artifacts`)
if (a.length) { realWithArt.push([r, a]); break }
}
if (realWithArt.length) {
const [r, a] = realWithArt[0]
await page.goto(`${WEB}/runs/${r.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail]').waitFor()
await page.waitForTimeout(300)
check('真实运行:工件页签的数和接口一致', (await viewTab('artifacts').innerText().catch(() => '')).includes(String(a.length)),
`接口 ${a.length} 件`)
}
})
// ------------------------------------------------------------------ 回画布
await section('在画布中回放', async () => {
await page.goto(`${WEB}/runs/${TRACE}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail]').waitFor()
const replayLink = page.locator('[data-run-detail] > header [data-action=open-canvas]')
check('跑完的运行,详情头是「在画布中回放」',
(await replayLink.innerText().catch(() => '')).includes('在画布中回放')
&& await replayLink.getAttribute('href').catch(() => null) === `/studio/${wfHost.id}?run=${TRACE}`,
await replayLink.getAttribute('href').catch(() => '') ?? '')
check('工作流之后改过结构:链接说明画布上是现在的图',
await replayLink.getAttribute('data-graph-drift').catch(() => null) === '1'
&& /改过/.test(await replayLink.getAttribute('title').catch(() => '') ?? ''))
fakes.delete('GET /api/workflows')
})
// ------------------------------------------------------------------ 列表:缩略条、取消原因
await section('列表:每行一条缩略条', async () => {
const FAILED_ROW = 'fake0failedrow0000000000000000'
const CANCEL_ROW = 'fake0cancelrow0000000000000000'
const failedRow = { ...traceRun(FAILED_ROW), status: 'failed', error: '节点「汇总」失败:模型服务出错了(HTTP 500)', error_node_id: 'sum',
usage: { wall_ms: 4000, active_ms: 4000, wait_ms: 0, duration_ms: 4000 }, finished_at: iso(tt + 4) }
const cancelRow = { ...traceRun(CANCEL_ROW), status: 'cancelled', error: '用户取消:在等审批时放弃了这次运行',
usage: { wall_ms: 90000, active_ms: 3000, wait_ms: 87000, duration_ms: 3000 }, finished_at: iso(tt + 90) }
fakes.set('GET /api/runs', () => ({ status: 200, json: [traceRun(TRACE), failedRow, cancelRow, ...listTop] }))
await page.goto(`${WEB}/runs`, { waitUntil: 'networkidle' })
await rowsOf().first().waitFor()
const shapeCount = await page.locator('[data-runs-list] [data-run-id] [data-run-shape]').count()
check('每一行都有一条缩略条', shapeCount === await rowsOf().count() && shapeCount > 0, `${shapeCount} 条`)
const waitFrac = await page.locator(`[data-run-id="${TRACE}"] [data-shape-seg=wait]`).evaluate(
(el) => el.getBoundingClientRect().width / el.parentElement.getBoundingClientRect().width).catch(() => 0)
check('等人占了九成八的墙钟,缩略条上等人那段也占大半', waitFrac > 0.9, waitFrac.toFixed(2))
check('失败的那一行收在失败色上', await page.locator(`[data-run-id="${FAILED_ROW}"] [data-shape-end=failed]`).count() === 1)
check('缩略条的 title 写着执行和等人',
/执行 .+ · 等人 .+/.test(await page.locator(`[data-run-id="${TRACE}"] [data-run-shape]`).getAttribute('title').catch(() => '') ?? ''))
const cancelText = (await page.locator(`[data-run-id="${CANCEL_ROW}"]`).innerText().catch(() => '')).replace(/\s+/g, ' ')
const cancelWhy = cancelText.includes('在等审批时放弃了这次运行')
const cancelDup = cancelText.includes('用户取消')
// 这一行的工作流名借的是沙箱里的真实工作流,只报两项判断
check('已取消写原因,不重复「用户取消」', cancelWhy && !cancelDup, `原因 ${cancelWhy ? '在' : '缺'} · 「用户取消」${cancelDup ? '重复了' : '没重复'}`)
fakes.delete('GET /api/runs')
})
await section('工作流筛选:未保存的工作流', async () => {
await page.goto(`${WEB}/runs`, { waitUntil: 'networkidle' })
await rowsOf().first().waitFor()
const wfOptions = await page.locator('[data-runs-workflow] option').evaluateAll((els) => els.map((e) => e.value))
check('工作流下拉里有「未保存的工作流」', wfOptions.includes('__none__'))
if (wfOptions.includes('__none__')) {
await listAfter(() => page.locator('[data-runs-workflow]').selectOption('__none__'), (p) => p.get('workflow_id') === '__none__')
check('地址记下 ?wf=__none__', new URL(page.url()).searchParams.get('wf') === '__none__')
const names = await page.locator('[data-runs-list] [data-run-id] > div:first-of-type').evaluateAll((els) => els.map((e) => e.textContent ?? ''))
check('筛出来的都是未保存的工作流', names.length > 0 && names.every((n) => n.includes('未保存的工作流')), `${names.length} 行`)
await page.locator('[data-runs-workflow]').selectOption('')
await settle()
}
// ------------------------------------------------------------------ 放弃
if (pending.length) {
console.log('\n=== 等审批的运行就地放弃 ===')
const target = pending[0]
const full = await getJson(`/runs/${target.run_id}`)
const evs = await getJson(`/runs/${target.run_id}/events`)
await page.goto(`${WEB}/runs/${target.run_id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail][data-run-code=waiting]').waitFor({ timeout: 8000 }).catch(() => {})
const abandon = page.locator('[data-run-banner=waiting] [data-action=abandon]')
check('等审批的横幅上有「放弃这次运行」', await abandon.count() === 1)
if (await abandon.count()) {
let cancelCalls = 0
fakes.set(`POST /api/runs/${target.run_id}/cancel`, () => { cancelCalls++; return { status: 200, json: { ok: true, status: 'cancelled' } } })
await abandon.click()
const ask = page.getByRole('dialog')
await ask.waitFor({ timeout: 5000 }).catch(() => {})
check('放弃前要确认,并写清待审批一并关闭', (await ask.innerText().catch(() => '')).includes('待审批'))
const tc = Date.now() / 1000
fakes.set(`GET /api/runs/${target.run_id}`, () => ({ status: 200, json: {
...full, status: 'cancelled', error: '用户取消:在等审批时放弃了这次运行', finished_at: iso(tc),
} }))
fakes.set(`GET /api/runs/${target.run_id}/events`, () => ({ status: 200, json: [...evs, {
seq: (evs.at(-1)?.seq ?? 0) + 1, type: 'run.cancelled', node_id: null, ts: tc,
data: { actor: '张工', message: '放弃了这次运行,1 条待审批一并关闭', timing: { wall_ms: 1000, active_ms: 10, wait_ms: 990 } },
}] }))
fakes.set('GET /api/approvals', (url) => ({
status: 200, json: pending.filter((a) => a.id !== target.id && (!url.searchParams.get('run_id') || a.run_id === url.searchParams.get('run_id'))),
}))
await ask.getByRole('button', { name: '放弃这次运行' }).click({ timeout: 5000 }).catch(() => {})
const gone = await page.waitForFunction(
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'cancelled',
null, { timeout: 6000 },
).then(() => true, () => false)
check('发的是 POST /cancel', cancelCalls === 1)
check('状态变成已取消', gone)
const status = (await page.locator('[data-run-telemetry]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('状态格写着谁放弃的', status.includes('张工') && status.includes('放弃'), status.slice(0, 60))
fakes.delete(`POST /api/runs/${target.run_id}/cancel`)
fakes.delete(`GET /api/runs/${target.run_id}`)
fakes.delete(`GET /api/runs/${target.run_id}/events`)
fakes.delete('GET /api/approvals')
}
}
})
// ------------------------------------------------------------------ 等了几天、窄屏
// 停在审批上十天多:等人要写「10 天 05 小时」,而不是被截成「245 小时 3…」;游标写「T+10 天 05:30:07」,
// 不写「T+245:30:07.3」(同一行的墙钟、等人写的是「10 天」,而且放不下)。等人的时长在涨,游标要走
const STALE = 'fake0stalewait0000000000000000'
const fits = (sel) => page.locator(sel).evaluateAll((els) => els.length > 0 && els.every((e) => e.scrollWidth <= e.clientWidth + 0.5))
let tw, longApproval
await section('等了几天的审批、1024 宽', async () => {
tw = Date.now() / 1000 - 10 * 86400 - 5.5 * 3600
longApproval = {
id: 'fake0longappr0000000000000000', run_id: STALE, node_id: 'gate', mode: 'approve', title: '放行吗?',
payload: { kind: 'human_node', node_id: 'gate', mode: 'approve', title: '放行吗?', message: '', schema: {}, context: {} },
status: 'pending', response: {}, created_at: iso(tw + 3.3), resolved_by: null, resolved_at: null,
workflow_id: wfHost.id, workflow_name: '订单日报', node_label: '人工审批', run_status: 'interrupted', run_class: 'exploratory',
}
fakes.set(`GET /api/runs/${STALE}`, () => ({ status: 200, json: {
...traceRun(STALE), status: 'interrupted', output: null, finished_at: null, created_at: iso(tw), started_at: iso(tw),
usage: { input_tokens: 1200, output_tokens: 300, cost_usd: 0.004 },
} }))
fakes.set(`GET /api/runs/${STALE}/events`, () => ({ status: 200, json: traceEvents().slice(0, 11).map((e) => ({ ...e, ts: e.ts - tt + tw })) }))
fakes.set(`GET /api/runs/${STALE}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${STALE}/artifacts`, () => ({ status: 200, json: [] }))
fakes.set('GET /api/approvals', (url) => ({
status: 200, json: !url.searchParams.get('run_id') || url.searchParams.get('run_id') === STALE ? [longApproval] : [],
}))
const DAY_AT = /^T\+10 天 05:3\d:\d\d$/
// 1300 宽:详情区 836px,排成一行的话一格只剩 78px,「10 天 05 小时」放不下,得排两行
for (const [w, h] of [[1440, 900], [1300, 860], [1024, 768]]) {
await page.setViewportSize({ width: w, height: h })
await page.goto(`${WEB}/runs/${STALE}?view=trace`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
const waitRead = await readout('wait')
check(`${w} 宽:等人写成跨天的跨度(10 天 05 小时),不被截断`,
waitRead.startsWith('10 天 05 小时') && await fits('[data-trace-readout] [data-readout=wait]'), waitRead)
const atRead = (await readout('at')).trim()
check(`${w} 宽:游标写成跨天的时刻(T+10 天 05:30:xx),不被截断`,
DAY_AT.test(atRead) && await fits('[data-trace-readout] [data-readout=at]'), atRead)
}
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto(`${WEB}/runs/${STALE}?view=trace`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-readout]').waitFor({ timeout: 5000 }).catch(() => {})
check('「此刻」的等审批也写跨天', (await page.locator('[data-trace-now] [data-now-node=gate]').innerText().catch(() => '')).includes('10 天 05 小时'))
const waitAtA = await readout('at')
// 跨天的时刻只写到秒:隔一秒多再读
await page.waitForTimeout(1100)
const waitAtB = await readout('at')
check('停在审批上时游标照样跟着现在走(和坞里同一拍)', await mode() === '实时' && waitAtA !== waitAtB, `${waitAtA} → ${waitAtB}`.replace(/\s+/g, ' '))
})
// 去审批卡:审批卡在时间线里。从航迹、工件、原始事件点过来,要切回时间线、描一下卡片,
// 并且焦点进到卡片里——键盘和读屏用户才接得着。视图经地址切,提交比点击晚好几帧
await section('去审批卡:从哪个视图点都能把焦点送进卡片', async () => {
for (const [from, label] of [['trace', '航迹'], ['artifacts', '工件'], ['raw', '原始事件'], ['stream', '时间线']]) {
await page.goto(`${WEB}/runs/${STALE}${from === 'trace' || from === 'artifacts' ? `?view=${from}` : ''}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-banner=waiting] [data-action=jump-approval]').waitFor({ timeout: 5000 }).catch(() => {})
if (from === 'raw') {
await page.locator('[data-action=raw]').click().catch(() => {})
await page.locator('[data-raw-events]').waitFor({ timeout: 3000 }).catch(() => {})
}
await page.locator('[data-run-banner=waiting] [data-action=jump-approval]').click().catch(() => {})
const into = await page.waitForFunction(() => !!document.activeElement?.closest('[data-approval-card]'), null, { timeout: 3000 })
.then(() => true, () => false)
const ringed = await page.locator(`[data-approval-card="${longApproval.id}"].runs-focus-ring`).count()
const back = await viewTab('stream').getAttribute('aria-selected').catch(() => null) === 'true'
check(`从「${label}」点「去审批卡」:回到时间线,焦点进了审批卡,卡片描了一下边`, into && ringed === 1 && back,
`时间线 ${back ? '是' : '否'} · 描边 ${ringed} · 焦点在 ${await page.evaluate(() => {
const a = document.activeElement
return `${a?.tagName}${a?.getAttribute('data-action') ? `[${a.getAttribute('data-action')}]` : ''}`
})}`)
}
})
// 批过的十天等待:「审批已处理」「运行完成」都在 T+10 天之后。关键时刻里时刻那一栏按最宽的
// 一条排齐,不压到后面的徽标上;终态的游标放得下
await section('跨天的关键时刻', async () => {
const LONGDONE = 'fake0longdone00000000000000000'
const td = Date.now() / 1000 - 11 * 86400
const LONG_S = 10 * 86400
const longTiming = { wall_ms: (6.2 + LONG_S) * 1000, active_ms: 6200, wait_ms: LONG_S * 1000 }
fakes.set(`GET /api/runs/${LONGDONE}`, () => ({ status: 200, json: {
...traceRun(LONGDONE), created_at: iso(td), started_at: iso(td), finished_at: iso(td + 6.2 + LONG_S),
usage: { input_tokens: 2000, output_tokens: 800, cost_usd: 0.007, duration_ms: 6200, ...longTiming },
} }))
fakes.set(`GET /api/runs/${LONGDONE}/events`, () => ({ status: 200, json: traceEvents().map((e) => ({
...e,
ts: e.ts - tt + td + (e.seq >= 12 ? LONG_S - WAIT_S : 0),
...(e.type === 'run.finished' ? { data: { ...e.data, timing: longTiming } } : {}),
})) }))
fakes.set(`GET /api/runs/${LONGDONE}/graph`, () => ({ status: 200, json: { graph: traceGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${LONGDONE}/artifacts`, () => ({ status: 200, json: [] }))
const momentsAligned = () => page.locator('[data-trace-moments] [data-moment]').evaluateAll((els) => {
const cols = els.map((b) => {
const at = b.querySelector('[data-moment-at]')
return { at: at.getBoundingClientRect(), fit: at.scrollWidth <= at.clientWidth + 0.5, mark: at.nextElementSibling.getBoundingClientRect().left }
})
return cols.length > 0 && cols.every((c) => c.fit && c.at.right <= c.mark + 0.5 && Math.abs(c.mark - cols[0].mark) < 0.5)
})
for (const [w, h] of [[1440, 900], [1024, 768]]) {
await page.setViewportSize({ width: w, height: h })
await page.goto(`${WEB}/runs/${LONGDONE}?view=trace`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-moments]').waitFor({ timeout: 5000 }).catch(() => {})
const texts = (await page.locator('[data-trace-moments] [data-moment]').allInnerTexts().catch(() => [])).map((t) => t.replace(/\s+/g, ' '))
check(`${w} 宽:跨天的关键时刻写「T+10 天 00:00:03」,时刻一栏排齐、不压到徽标上`,
texts.some((t) => t.includes('T+10 天 00:00:03') && t.includes('审批已处理') && t.includes('等了 10 天')) && await momentsAligned(),
texts.join(' | '))
check(`${w} 宽:终态的游标写「T+10 天 00:00:06」,放得下`,
(await readout('at')).trim() === 'T+10 天 00:00:06' && await fits('[data-trace-readout] [data-readout=at]'), await readout('at'))
}
})
// 屏幕矮、节点多:九个节点的失败运行,横幅占去一截。读数区先拿够它要的,默认选中的失败
// 节点卡、关键时刻都得在首屏;坞拿剩下的,不矮过能拖到的最矮,泳道在坞里滚
await section('屏幕矮、节点多:读数区不被航迹坞压住', async () => {
const FAN = 'fake0fanfail000000000000000000'
const FAN_ERROR = '节点「查询订单」失败:模型服务出错了(HTTP 500)'
const FAN_STEPS = ['check', 'score', 'route', 'publish', 'archive']
const fanGraph = {
nodes: [...traceGraph.nodes, ...FAN_STEPS.map((id, i) => ({
id, type: 'llm', position: { x: 800 + i * 200, y: 0 }, data: { label: `步骤 ${i + 1}`, config: {} },
}))],
edges: [...traceGraph.edges, ...FAN_STEPS.map((id, i) => ({ source: i ? FAN_STEPS[i - 1] : 'sum', target: id }))],
}
fakes.set(`GET /api/runs/${FAN}`, () => ({ status: 200, json: {
...traceRun(FAN), status: 'failed', output: null, error: FAN_ERROR, error_node_id: 'query',
usage: { input_tokens: 0, output_tokens: 0, cost_usd: 0, duration_ms: 1200, wall_ms: 1200, active_ms: 1200, wait_ms: 0 },
finished_at: iso(tt + 1.2),
} }))
fakes.set(`GET /api/runs/${FAN}/events`, () => ({ status: 200, json: [
...traceEvents().slice(0, 4),
{ seq: 5, type: 'node.failed', node_id: 'query', ts: tt + 1.1, data: { error: FAN_ERROR, duration_ms: 800 } },
{ seq: 6, type: 'run.failed', node_id: null, ts: tt + 1.2, data: {
error: FAN_ERROR, node_id: 'query', timing: { wall_ms: 1200, active_ms: 1200, wait_ms: 0 } } },
] }))
fakes.set(`GET /api/runs/${FAN}/graph`, () => ({ status: 200, json: { graph: fanGraph, workflow_id: wfHost.id, version: null } }))
fakes.set(`GET /api/runs/${FAN}/artifacts`, () => ({ status: 200, json: [] }))
const firstScreen = () => page.evaluate(() => {
const read = document.querySelector('[data-trace-readout]')?.getBoundingClientRect()
const whole = (sel) => {
const b = document.querySelector(sel)?.getBoundingClientRect()
return !!b && !!read && b.top >= read.top - 0.5 && b.bottom <= read.bottom + 1
}
return {
card: whole('[data-trace-node=query]'), moments: whole('[data-trace-moments]'),
dock: Math.round(document.querySelector('[data-run-trace] section[aria-label="航迹"]')?.getBoundingClientRect().height ?? 0),
}
})
// 屏幕够高:坞按 RunTimeline.dockHeightFor 给,泳道一行不裁、不用滚,也不多留一截空白
// (以前这里自己抄了一份排版常量,再加 10px 余量糊住少算的上边框,3C REQ-8 / REQ-13)
await page.setViewportSize({ width: 1440, height: 1500 })
await page.goto(`${WEB}/runs/${FAN}?view=trace`, { waitUntil: 'networkidle' })
await page.locator('[data-run-trace] .tl-lane').first().waitFor({ timeout: 5000 }).catch(() => {})
await page.waitForTimeout(200)
const tallDock = await page.evaluate(() => {
const sc = document.querySelector('[data-run-trace] .tl-scroll')
const lanes = [...document.querySelectorAll('[data-run-trace] .tl-lane')]
const last = lanes.at(-1)?.getBoundingClientRect()
const b = sc?.getBoundingClientRect()
return { lanes: lanes.length, over: sc ? sc.scrollHeight - sc.clientHeight : null,
slack: last && b ? Math.round(b.bottom - last.bottom) : null }
})
check('屏幕够高:坞正好装下每一条泳道,不滚、不多留空白', tallDock.lanes >= 9 && tallDock.over === 0
&& tallDock.slack != null && tallDock.slack >= 0 && tallDock.slack <= 1, JSON.stringify(tallDock))
for (const [w, h] of [[1440, 900], [1180, 800], [1024, 768]]) {
await page.setViewportSize({ width: w, height: h })
await page.goto(`${WEB}/runs/${FAN}?view=trace`, { waitUntil: 'networkidle' })
await page.locator('[data-trace-node=query]').waitFor({ timeout: 5000 }).catch(() => {})
await page.waitForTimeout(150)
const seen = await firstScreen()
check(`${w}×${h}:失败节点卡、关键时刻整块都在首屏,坞不矮过 132px`, seen.card && seen.moments && seen.dock >= 132, JSON.stringify(seen))
}
// 再矮(或者横幅更高)就放不下了:坞不再让,读数区底边淡出,看得出下面还有
await page.setViewportSize({ width: 1024, height: 640 })
await page.waitForTimeout(300)
check('1024×640:读数区放不下时底边淡出,提示能往下滚', await page.locator('[data-trace-readout][data-more]').count() === 1)
// 1024 宽:详情区只有 600px,读数一格不到 70px 的字宽
await page.setViewportSize({ width: 1024, height: 768 })
fakes.set('GET /api/runs', () => ({ status: 200, json: [traceRun(TRACE), ...listTop] }))
await page.goto(`${WEB}/runs/${TRACE}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-telemetry]').waitFor({ timeout: 5000 }).catch(() => {})
const cut = await page.locator('[data-run-telemetry] [data-telemetry]').evaluateAll((els) =>
els.filter((e) => e.scrollWidth > e.clientWidth + 0.5).map((e) => `${e.getAttribute('data-telemetry')}「${e.textContent}」`))
check('1024 宽:详情头的读数没有一格被截成省略号', cut.length === 0, cut.join('、') || '全部放得下')
const footH = await page.locator(`[data-runs-list] [data-run-id="${TRACE}"] [data-run-duration]`).evaluate(
(el) => el.parentElement.getBoundingClientRect().height).catch(() => 99)
check('1024 宽:列表行底下那一行不折行(时长太长就截断,用量不被拆开)', footH <= 18, `${footH}px`)
await page.setViewportSize({ width: 1440, height: 900 })
fakes.delete('GET /api/runs')
fakes.delete('GET /api/approvals')
})
await section('收尾', async () => {
// 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 POST
// (同样被探针拦下,不落库)。赶上它们发出来之前就离开了画布的话就没有
check('只有伪造过的写请求', writes.every((w) => /\/continue$|\/cancel$|\/copilot\/from-run$|DELETE \/api\/runs\/fake0|^POST \/api\/runs$|^POST \/api\/workflows\/(validate|variables)$/.test(w)),
writes.join(', '))
check('没有未捕获的运行时错误', errors.length === 0, errors[0] ?? '')
})
await browser.close()
console.log(failed ? `\n${failed} 项没过` : '\n全部通过')
process.exit(failed ? 1 : 0)