mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:31:58 +00:00
587bf7e27c
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」, 校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮, 轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。 平台可信度(先写失败测试) - Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则, 工具集合变小时自查报警、改图回执列出变化 - 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修 - 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量 - 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因 - 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员 - 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行 后端 - 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、 SQLite progress handler),可按数据源配置;超时报错与实际耗时一致 - 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存; 自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存; 检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors 前端 - 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时, 一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子 - 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚; 回放用量取那一刻的值 - 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口 - 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对 - 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条 - 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问 验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿; check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
1329 lines
91 KiB
JavaScript
1329 lines
91 KiB
JavaScript
// 记录页(/runs)的检查:页签、状态筛选、分页、时间、待审批、删除保护、运行中接流、
|
||
// 失败的排错路径、封存凭证、提取模板。
|
||
//
|
||
// 守的是审查里抓到的几条死路:待审批的运行排在第 145 位、列表只取 100 条,点了
|
||
// 徽标也找不到;输入「失败」筛出来的是两条成功的运行;时间整体慢 8 小时;正在跑
|
||
// 的运行一动不动、却能删;封存过的正式运行一键就删掉了。
|
||
//
|
||
// 不写库:探针拦下所有非 GET 的 /api 请求,按场景回假响应。库里没有的形态(正在
|
||
// 跑的、封存过的正式运行)用 page.route 伪造 GET,用 routeWebSocket 伪造实时流。
|
||
// 跑之前前端和后端都得起着,并且指向同一份数据:
|
||
// AGENTLAB_WEB=http://localhost:5373 AGENTLAB_API=http://localhost:8100/api node scripts/check-runs.mjs
|
||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
|
||
const CHROME = process.env.CHROME_PATH
|
||
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
const getJson = async (path) => (await fetch(API + path)).json()
|
||
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||
const page = await ctx.newPage()
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push(e.message))
|
||
|
||
// ---- 探针:非 GET 一律不放行,按场景回假响应 ----
|
||
const writes = []
|
||
const fakes = new Map() // `${METHOD} ${pathname}` → (url) => {status, json}
|
||
let offline = false // 断网:所有 /api 请求都够不着
|
||
let healthAborted = 0
|
||
await page.route('**/api/**', async (route) => {
|
||
const r = route.request()
|
||
const url = new URL(r.url())
|
||
if (offline) {
|
||
if (url.pathname === '/api/health') healthAborted++
|
||
return route.abort('internetdisconnected')
|
||
}
|
||
if (r.method() === 'GET') {
|
||
const fake = fakes.get(`GET ${url.pathname}`)
|
||
if (fake) return route.fulfill(fake(url))
|
||
return route.continue()
|
||
}
|
||
writes.push(`${r.method()} ${url.pathname}${url.search}`)
|
||
const fake = fakes.get(`${r.method()} ${url.pathname}`)
|
||
if (fake) return route.fulfill(fake(url, r))
|
||
return route.abort()
|
||
})
|
||
|
||
const rowsOf = () => page.locator('[data-runs-list] [data-run-id]')
|
||
const settle = async () => {
|
||
await page.waitForLoadState('networkidle')
|
||
await page.waitForTimeout(350)
|
||
}
|
||
const statuses = () => rowsOf().evaluateAll((els) => els.map((e) => e.getAttribute('data-status')))
|
||
/** 做一个会触发列表重查的动作,等到那一次列表请求(limit=50)回来、列表画完 */
|
||
const listAfter = async (act, match) => {
|
||
const done = page.waitForResponse((r) => {
|
||
const u = new URL(r.url())
|
||
return u.pathname === '/api/runs' && u.searchParams.get('limit') === '50' && match(u.searchParams)
|
||
}, { timeout: 8000 }).catch(() => null)
|
||
await act()
|
||
await done
|
||
await page.waitForTimeout(250)
|
||
}
|
||
const search = (text, match) => listAfter(() => page.locator('[data-runs-search]').fill(text), match)
|
||
const ids = () => rowsOf().evaluateAll((els) => els.map((e) => e.getAttribute('data-run-id')))
|
||
/** 页内记下详情 data-run-code 的每一次变化:一闪而过的中间态定时采样会漏掉 */
|
||
const recordCodes = () => page.evaluate(() => {
|
||
const el = document.querySelector('[data-run-detail]')
|
||
const seen = [el?.getAttribute('data-run-code')]
|
||
window.__runCodes = seen
|
||
if (el) new MutationObserver(() => seen.push(el.getAttribute('data-run-code')))
|
||
.observe(el, { attributes: true, attributeFilter: ['data-run-code'] })
|
||
})
|
||
const recordedCodes = () => page.evaluate(() => window.__runCodes ?? [])
|
||
|
||
// ------------------------------------------------------------------ 页签
|
||
|
||
console.log('=== 页签与地址 ===')
|
||
await page.goto(`${WEB}/runs`, { waitUntil: 'networkidle' })
|
||
await rowsOf().first().waitFor({ timeout: 10000 })
|
||
check('默认是「全部」页签', await page.locator('[role=tab][data-tab=all]').getAttribute('aria-selected') === 'true')
|
||
check('页头写着「记录」', (await page.locator('[data-runs-page] header h1').innerText()).includes('记录'))
|
||
const headerH = await page.locator('[data-runs-page] > header').evaluate((el) => el.getBoundingClientRect().height)
|
||
check('页头和管理页一样高(48px,toast 不压工具栏)', headerH === 48, `${headerH}px`)
|
||
|
||
// 键盘:↓ 在行之间移动焦点,不打开
|
||
await rowsOf().first().focus()
|
||
await page.keyboard.press('ArrowDown')
|
||
const focused = await page.evaluate(() => document.activeElement?.getAttribute('data-run-id'))
|
||
check('↓ 把焦点移到下一行', focused === (await ids())[1] && !/\/runs\/./.test(new URL(page.url()).pathname))
|
||
|
||
await page.locator('[role=tab][data-tab=failed]').click()
|
||
await settle()
|
||
check('点「失败」地址变成 ?tab=failed', new URL(page.url()).searchParams.get('tab') === 'failed')
|
||
const failedStatuses = await statuses()
|
||
check('「失败」页签里全是失败的运行', failedStatuses.length > 0 && failedStatuses.every((s) => s === 'failed'),
|
||
`${failedStatuses.length} 行`)
|
||
check('失败行的第二行是原因,不是输入', await page.locator('[data-runs-list] [data-run-reason]').count() === failedStatuses.length)
|
||
|
||
const runningReq = page.waitForRequest((r) => new URL(r.url()).pathname === '/api/runs'
|
||
&& new URL(r.url()).searchParams.get('status') === 'running,queued')
|
||
await page.locator('[role=tab][data-tab=running]').click()
|
||
check('「运行中」页签按 status=running,queued 查服务端', await runningReq.then(() => true, () => false))
|
||
await settle()
|
||
check('「运行中」页签里只有在跑的', (await statuses()).every((s) => s === 'running' || s === 'queued'))
|
||
|
||
await page.goto(`${WEB}/runs?tab=bogus`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(300)
|
||
check('认不出的页签落回「全部」并纠正地址', !new URL(page.url()).searchParams.has('tab'))
|
||
|
||
// ------------------------------------------------------------------ 待审批
|
||
|
||
console.log('\n=== 待审批页签 ===')
|
||
const pending = await getJson('/approvals?status=pending&limit=500')
|
||
// 「别处批掉了」要用的实时流:routeWebSocket 只管注册之后加载的页面,先挂上
|
||
let resumeSocket = false
|
||
if (pending.length) {
|
||
const target = pending[0]
|
||
const lastSeq = (await getJson(`/runs/${target.run_id}/events`)).at(-1)?.seq ?? 0
|
||
await page.routeWebSocket(new RegExp(`/api/runs/${target.run_id}/stream`), (ws) => {
|
||
resumeSocket = true
|
||
const t = Date.now() / 1000
|
||
ws.send(JSON.stringify({ seq: lastSeq + 1, type: 'run.resumed', node_id: target.node_id, ts: t, data: { actor: '张工' } }))
|
||
ws.send(JSON.stringify({ seq: lastSeq + 2, type: 'node.started', node_id: target.node_id, ts: t + 0.05, data: { resumed: true } }))
|
||
})
|
||
}
|
||
await page.goto(`${WEB}/runs?tab=approvals`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(400)
|
||
const approvalRows = page.locator('[data-approvals-list] [data-approval-id]')
|
||
const shownApprovals = await approvalRows.evaluateAll((els) => els.map((e) => e.getAttribute('data-approval-id')))
|
||
check('沙箱里的待审批都在页签里', pending.length > 0 && pending.every((a) => shownApprovals.includes(a.id)),
|
||
`API ${pending.length} 条 / 页面 ${shownApprovals.length} 条`)
|
||
const allRuns = await getJson('/runs?limit=200')
|
||
const deep = pending.filter((a) => allRuns.findIndex((r) => r.id === a.run_id) >= 100)
|
||
check('排在最近 100 条之外的待审批也看得到(原来的死路)', deep.every((a) => shownApprovals.includes(a.id)),
|
||
`${deep.length} 条在第 100 名之后`)
|
||
check('每条都写着等了多久', await page.locator('[data-approvals-list] [data-approval-age]').count() === shownApprovals.length)
|
||
if (pending.length) {
|
||
const rowText = await page.locator(`[data-approval-id="${pending[0].id}"]`).innerText()
|
||
check('每条写着工作流名和节点', rowText.includes(pending[0].workflow_name ?? '') && rowText.includes(pending[0].node_label ?? pending[0].node_id),
|
||
rowText.replace(/\s+/g, ' ').slice(0, 80))
|
||
check('每条有处理入口', rowText.includes('去处理'))
|
||
}
|
||
const badge = await page.locator('[role=tab][data-tab=approvals]').innerText()
|
||
check('页签上的数和待审批条数一致', badge.includes(String(pending.length)), badge.replace(/\s+/g, ' '))
|
||
if (pending.length) {
|
||
const target = pending[0]
|
||
await page.locator(`[data-approval-id="${target.id}"]`).click()
|
||
await page.waitForURL(`**/runs/${target.run_id}**`)
|
||
await page.locator('[data-run-detail]').waitFor()
|
||
await page.waitForTimeout(500)
|
||
check('点进去是那次运行,地址保留 ?tab=approvals', new URL(page.url()).searchParams.get('tab') === 'approvals')
|
||
check('详情显示等审批的横幅', await page.locator('[data-run-banner=waiting]').count() === 1)
|
||
check('审批卡在时间线里', await page.locator(`[data-approval-card="${target.id}"]`).count() === 1)
|
||
check('详情头的状态是「等待审批」', await page.locator('[data-run-detail]').getAttribute('data-run-code') === 'waiting')
|
||
const docScroll = await page.evaluate(() => document.scrollingElement.scrollHeight - innerHeight)
|
||
check('整页不滚动:滚的是时间线自己', docScroll <= 0, `溢出 ${docScroll}px`)
|
||
|
||
// 别处(画布、问数据、另一个标签页)把这张批掉了:catalog 轮询发现审批没了。
|
||
// 这一刻 run 还没重查的话是「interrupted、没有审批」,会被错当成已挂起——
|
||
// 要一起重查,看到它接着跑了,并接上实时流
|
||
// 后端先把审批记成已回复、再把运行改成 running:第一次重查故意落在这个空档里
|
||
// (运行还是 interrupted、审批已经没了),不能就此判成挂起
|
||
const full = await getJson(`/runs/${target.run_id}`)
|
||
let runReads = 0
|
||
fakes.set('GET /api/approvals', () => ({ status: 200, json: [] }))
|
||
fakes.set(`GET /api/runs/${target.run_id}`, () => ({
|
||
status: 200, json: { ...full, status: runReads++ === 0 ? 'interrupted' : 'running' },
|
||
}))
|
||
await recordCodes()
|
||
const resumed = await page.waitForFunction(
|
||
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'running',
|
||
null, { timeout: 9000 },
|
||
).then(() => true, () => false)
|
||
await page.waitForTimeout(300)
|
||
const codes = await recordedCodes()
|
||
check('别处批掉后:接上实时流、状态变成运行中', resumed && resumeSocket, codes.at(-1) ?? '')
|
||
check('中间没有闪成「已挂起」', !codes.includes('held'), [...new Set(codes)].join(' → '))
|
||
fakes.delete('GET /api/approvals')
|
||
fakes.delete(`GET /api/runs/${target.run_id}`)
|
||
|
||
// 别处批掉,剩下的几步在下一轮轮询之前就跑完了(审批靠近末尾的都这样):
|
||
// 重查时运行已经是已完成。头上跟着变不够,别处发生的事件要补进时间线——
|
||
// 原来只在「还在跑」时接流,这种就停在审批卡上,连「放行」那一行都没有
|
||
const other = pending[1] ?? pending[0]
|
||
const otherRun = await getJson(`/runs/${other.run_id}`)
|
||
const otherEvents = await getJson(`/runs/${other.run_id}/events`)
|
||
const lastOther = otherEvents.at(-1)?.seq ?? 0
|
||
const tf = Date.now() / 1000
|
||
const tail = [
|
||
{ seq: lastOther + 1, type: 'human.resolved', node_id: other.node_id, ts: tf, data: { response: { approved: true }, actor: '张工' } },
|
||
{ seq: lastOther + 2, type: 'run.resumed', node_id: null, ts: tf, data: { actor: '张工' } },
|
||
{ seq: lastOther + 3, type: 'node.finished', node_id: other.node_id, ts: tf + 0.01, data: { duration_ms: 1 } },
|
||
{ seq: lastOther + 4, type: 'run.finished', node_id: null, ts: tf + 0.02,
|
||
data: { output: { answer: '已发布' }, usage: {}, timing: { wall_ms: 1000, active_ms: 30, wait_ms: 970 } } },
|
||
]
|
||
await page.goto(`${WEB}/runs/${other.run_id}`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-run-detail][data-run-code=waiting]').waitFor({ timeout: 8000 }).catch(() => {})
|
||
const restPending = pending.filter((a) => a.id !== other.id)
|
||
fakes.set('GET /api/approvals', (url) => ({
|
||
status: 200,
|
||
json: restPending.filter((a) => !url.searchParams.get('run_id') || a.run_id === url.searchParams.get('run_id')),
|
||
}))
|
||
fakes.set(`GET /api/runs/${other.run_id}`, () => ({
|
||
status: 200,
|
||
json: { ...otherRun, status: 'succeeded', output: { answer: '已发布' }, finished_at: new Date().toISOString() },
|
||
}))
|
||
fakes.set(`GET /api/runs/${other.run_id}/events`, () => ({ status: 200, json: [...otherEvents, ...tail] }))
|
||
await recordCodes()
|
||
const finishedFast = await page.waitForFunction(
|
||
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'succeeded',
|
||
null, { timeout: 12000 },
|
||
).then(() => true, () => false)
|
||
await page.waitForTimeout(400)
|
||
const fastCodes = await recordedCodes()
|
||
check('别处批掉、已经跑完:状态变成已完成', finishedFast, [...new Set(fastCodes)].join(' → '))
|
||
check('跑完之前没有闪成「已挂起」', !fastCodes.includes('held'), fastCodes.join(' → '))
|
||
const fastStream = (await page.locator('[data-run-detail] > div').nth(0).innerText()).replace(/\s+/g, ' ')
|
||
check('别处发生的事件补进了时间线(「张工 放行了」)', fastStream.includes('张工 放行了'), fastStream.slice(0, 120))
|
||
const fastCount = (await page.locator('[data-run-detail] > header').innerText()).match(/([\d,]+) 条事件/)?.[1]
|
||
check('详情头的事件数跟着补齐', fastCount === (otherEvents.length + tail.length).toLocaleString('en-US'),
|
||
`${fastCount} / 应为 ${otherEvents.length + tail.length}`)
|
||
fakes.delete('GET /api/approvals')
|
||
fakes.delete(`GET /api/runs/${other.run_id}`)
|
||
fakes.delete(`GET /api/runs/${other.run_id}/events`)
|
||
|
||
// 断开期间别处批掉了:恢复那一刻重拉,同样可能撞上「审批已回复、运行还没改」
|
||
// 的空档。恢复走的是另一条路(useOnReconnect),也不能就此判成挂起
|
||
await page.goto(`${WEB}/runs/${target.run_id}`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-run-detail][data-run-code=waiting]').waitFor({ timeout: 8000 }).catch(() => {})
|
||
await recordCodes()
|
||
healthAborted = 0
|
||
offline = true
|
||
// 等 catalog 的心跳失败、探活也失败,判成断开(之后 4 秒重试 /health)
|
||
for (let i = 0; i < 60 && !healthAborted; i++) await page.waitForTimeout(200)
|
||
await page.waitForTimeout(200)
|
||
let gapReads = 0
|
||
fakes.set('GET /api/approvals', () => ({ status: 200, json: [] }))
|
||
fakes.set(`GET /api/runs/${target.run_id}`, () => ({
|
||
status: 200, json: { ...full, status: gapReads++ === 0 ? 'interrupted' : 'running' },
|
||
}))
|
||
offline = false
|
||
const back = await page.waitForFunction(
|
||
() => document.querySelector('[data-run-detail]')?.getAttribute('data-run-code') === 'running',
|
||
null, { timeout: 15000 },
|
||
).then(() => true, () => false)
|
||
await page.waitForTimeout(300)
|
||
const backCodes = await recordedCodes()
|
||
check('断网恢复时撞上空档:认出它已经接着跑了', healthAborted > 0 && back, `探活失败 ${healthAborted} 次`)
|
||
check('断网恢复时撞上空档:也没有闪成「已挂起」', !backCodes.includes('held'), backCodes.join(' → '))
|
||
fakes.delete('GET /api/approvals')
|
||
fakes.delete(`GET /api/runs/${target.run_id}`)
|
||
}
|
||
|
||
// ------------------------------------------------------------------ 筛选
|
||
|
||
console.log('\n=== 状态筛选 ===')
|
||
await page.goto(`${WEB}/runs`, { waitUntil: 'networkidle' })
|
||
await rowsOf().first().waitFor()
|
||
const listRequests = []
|
||
const onReq = (r) => { if (new URL(r.url()).pathname === '/api/runs') listRequests.push(new URL(r.url()).searchParams) }
|
||
page.on('request', onReq)
|
||
await search('失败', (p) => p.get('status') === 'failed')
|
||
const zh = await statuses()
|
||
check('输入「失败」只出失败的运行', zh.length > 0 && zh.every((s) => s === 'failed'), `${zh.length} 行`)
|
||
check('状态走服务端参数 status=failed,不当名称搜',
|
||
listRequests.some((p) => p.get('status') === 'failed' && !p.get('q')))
|
||
check('提示按状态筛选', (await page.locator('[data-runs-hint]').innerText()).includes('失败'))
|
||
check('「全部」分段里「失败」亮着', await page.locator('[data-segment=failed]').getAttribute('aria-pressed') === 'true')
|
||
|
||
await search('已完成', (p) => p.get('status') === 'succeeded')
|
||
const done = await statuses()
|
||
check('输入「已完成」只出已完成的', done.length > 0 && done.every((s) => s === 'succeeded'))
|
||
|
||
await search('等待审批', (p) => p.get('status') === 'interrupted')
|
||
const waitingIds = await ids()
|
||
check('输入「等待审批」能找到所有停在审批上的运行',
|
||
pending.every((a) => waitingIds.includes(a.run_id)) && (await statuses()).every((s) => s === 'waiting'),
|
||
`${waitingIds.length} 行`)
|
||
|
||
await search('', (p) => !p.get('status'))
|
||
await listAfter(() => page.locator('[data-segment=cancelled]').click(), (p) => p.get('status') === 'cancelled')
|
||
const cancelled = await statuses()
|
||
check('点「已取消」分段只出已取消的', cancelled.length > 0 && cancelled.every((s) => s === 'cancelled'))
|
||
check('分段写进地址 ?status=cancelled', new URL(page.url()).searchParams.get('status') === 'cancelled')
|
||
await page.locator('[data-segment=all]').click()
|
||
await settle()
|
||
|
||
await search('zz一个不存在的工作流zz', (p) => p.get('q') === 'zz一个不存在的工作流zz')
|
||
check('没有匹配时说「没有匹配」,不说「还没有运行记录」',
|
||
await page.getByText('没有匹配的运行').count() === 1 && await page.getByText('还没有运行记录').count() === 0)
|
||
await page.getByRole('button', { name: '清除筛选' }).click()
|
||
await settle()
|
||
check('「清除筛选」之后列表回来了', await rowsOf().count() > 0 && !new URL(page.url()).searchParams.has('q'))
|
||
page.off('request', onReq)
|
||
|
||
// ------------------------------------------------------------------ 分页
|
||
|
||
console.log('\n=== 分页 ===')
|
||
const firstPage = await rowsOf().count()
|
||
check('第一页 50 条', firstPage === 50, String(firstPage))
|
||
const beforeReq = page.waitForRequest((r) => new URL(r.url()).pathname === '/api/runs' && new URL(r.url()).searchParams.has('before'))
|
||
await page.locator('[data-runs-more]').click()
|
||
const req = await beforeReq
|
||
await settle()
|
||
const secondIds = await ids()
|
||
check('「加载更多」带 before 游标', !!new URL(req.url()).searchParams.get('before'))
|
||
check('加载后变成 100 条', secondIds.length === 100, String(secondIds.length))
|
||
check('没有重复的行', new Set(secondIds).size === secondIds.length)
|
||
const apiIds = (await getJson('/runs?limit=100')).map((r) => r.id)
|
||
check('两页拼起来和后端的前 100 条一致', JSON.stringify(apiIds) === JSON.stringify(secondIds))
|
||
|
||
// ------------------------------------------------------------------ 时间
|
||
|
||
console.log('\n=== 时间不再慢 8 小时 ===')
|
||
const sample = (await getJson('/runs?limit=5'))[0]
|
||
const shownTime = await page.locator(`[data-run-id="${sample.id}"] [data-run-time]`).innerText()
|
||
const [expected, naive, offset] = await page.evaluate((iso) => {
|
||
const pad = (n) => String(n).padStart(2, '0')
|
||
const hm = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
// 带不带 Z 都按 UTC 解析;naive 是以前把 UTC 当本地时间的读法
|
||
const utc = new Date(/Z|[+-]\d\d:?\d\d$/.test(iso) ? iso : iso + 'Z')
|
||
const local = new Date(iso.replace(/Z$|[+-]\d\d:?\d\d$/, ''))
|
||
return [hm(utc), hm(local), new Date().getTimezoneOffset()]
|
||
}, sample.created_at)
|
||
check('列表时间按 UTC 换算成本地', shownTime.includes(expected), `显示 ${shownTime},应为 ${expected}(created_at=${sample.created_at})`)
|
||
if (offset !== 0) check('不是把 UTC 当本地的那个错读', !shownTime.includes(naive) || naive === expected, `错读会是 ${naive}`)
|
||
const title = await page.locator(`[data-run-id="${sample.id}"] [data-run-time]`).getAttribute('title')
|
||
check('悬停给完整时间和时区', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \(UTC[+-]\d{2}:\d{2}\)/.test(title ?? ''), title ?? '')
|
||
|
||
// ------------------------------------------------------------------ 失败的排错路径
|
||
|
||
console.log('\n=== 失败:指到节点、原因、下一步 ===')
|
||
const failedRuns = await getJson('/runs?status=failed&limit=200')
|
||
const authFail = failedRuns.find((r) => r.workflow_id && /401|Authentication/i.test(r.error ?? ''))
|
||
if (authFail) {
|
||
const full = await getJson(`/runs/${authFail.id}`)
|
||
// 实时流伪造成「接着跑之后又动起来」。routeWebSocket 只管注册之后加载的页面,
|
||
// 所以要在打开详情之前挂上
|
||
const last = (await getJson(`/runs/${authFail.id}/events`)).at(-1)
|
||
await page.routeWebSocket(new RegExp(`/api/runs/${authFail.id}/stream`), (ws) => {
|
||
const t = Date.now() / 1000
|
||
ws.send(JSON.stringify({ seq: last.seq + 1, type: 'run.resumed', node_id: null, ts: t, data: { from: full.error_node_id } }))
|
||
ws.send(JSON.stringify({ seq: last.seq + 2, type: 'node.started', node_id: full.error_node_id, ts: t + 0.1, data: { resumed: true } }))
|
||
})
|
||
await page.goto(`${WEB}/runs/${authFail.id}`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-run-banner=failed]').waitFor()
|
||
check('横幅指到失败的节点', await page.locator(`[data-failed-node="${full.error_node_id}"]`).count() === 1, full.error_node_id)
|
||
check('原始异常翻成人话', (await page.locator('[data-failed-title]').innerText()).includes('鉴权'))
|
||
check('有「接着跑」', await page.locator('[data-run-banner=failed] [data-action=continue]').count() === 1)
|
||
check('有「去模型接入」', await page.locator('[data-run-banner=failed] [data-action=settings]').count() === 1)
|
||
check('有「复制错误」', await page.locator('[data-run-banner=failed] [data-action=copy-error]').count() === 1)
|
||
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')
|
||
|
||
// 正式运行缺输入:只能跑「当前」发布的版本。之后又发布过新版的话,带着原来
|
||
// 的版本号会被后端 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)
|
||
}
|
||
|
||
// ------------------------------------------------------------------ 封存凭证
|
||
|
||
console.log('\n=== 封存凭证常驻 ===')
|
||
const sealed = allRuns.find((r) => r.status === 'succeeded' && r.manifest_hash && !r.workflow_id)
|
||
await page.goto(`${WEB}/runs/${sealed.id}`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-run-provenance]').waitFor()
|
||
check('凭证条写着「已封存」和清单哈希前缀',
|
||
(await page.locator('[data-run-provenance]').innerText()).includes(sealed.manifest_hash.slice(0, 10)))
|
||
await page.locator('[data-verify-btn]').click()
|
||
await page.locator('[data-verify=ok], [data-verify=mismatch]').first().waitFor({ timeout: 8000 })
|
||
await page.waitForTimeout(4500)
|
||
check('核对结果 4 秒后还在(不是 toast)', await page.locator('[data-verify=ok]').count() === 1)
|
||
check('写明几条事件与清单一致', /\d+ 条事件与清单一致/.test(await page.locator('[data-verify=ok]').innerText()))
|
||
await page.locator('[data-prov-toggle]').click()
|
||
check('展开能看到完整清单哈希', (await page.locator('[data-prov-detail]').innerText()).includes(sealed.manifest_hash))
|
||
await page.goto(`${WEB}/runs/${allRuns[1].id}`, { waitUntil: 'networkidle' })
|
||
await page.goto(`${WEB}/runs/${sealed.id}`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-run-provenance]').waitFor()
|
||
check('切走再回来,核对结果还在', await page.locator('[data-verify=ok]').count() === 1)
|
||
|
||
// ------------------------------------------------------------------ 时长
|
||
|
||
console.log('\n=== 三种时长 ===')
|
||
const timed = allRuns.find((r) => r.usage?.wall_ms != null && r.usage?.wait_ms > 0)
|
||
if (timed) {
|
||
await page.goto(`${WEB}/runs/${timed.id}`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-run-telemetry]').waitFor()
|
||
const wall = await page.locator('[data-telemetry=wall]').innerText()
|
||
const active = await page.locator('[data-telemetry=active]').innerText()
|
||
const wait = await page.locator('[data-telemetry=wait]').innerText()
|
||
check('墙钟、执行、等人三项都有数', ![wall, active, wait].includes('—'), `${wall} / ${active} / ${wait}`)
|
||
check('列表那一行的 title 分项写了三种时长',
|
||
/墙钟 .+ · 执行 .+ · 等人 .+/.test(await page.locator(`[data-run-id="${timed.id}"] [data-run-duration]`).getAttribute('title') ?? ''))
|
||
}
|
||
|
||
// ------------------------------------------------------------------ 提取模板
|
||
|
||
console.log('\n=== 提取模板 ===')
|
||
check('已完成的探索运行有「提取模板」', await page.locator('[data-action=extract]').count() === 1)
|
||
const wfs = await getJson('/workflows')
|
||
fakes.set('POST /api/copilot/from-run', () => ({
|
||
status: 200, json: { workflow_id: wfs[0].id, name: '检查用草稿', nodes: 3, edges: 2, dropped_nodes: 1, source_run: timed?.id },
|
||
}))
|
||
await page.locator('[data-action=extract]').click()
|
||
await page.waitForURL(`**/studio/${wfs[0].id}`, { timeout: 5000 }).catch(() => {})
|
||
check('提取成功后跳到新草稿', new URL(page.url()).pathname === `/studio/${wfs[0].id}`)
|
||
fakes.delete('POST /api/copilot/from-run')
|
||
|
||
// ------------------------------------------------------------------ 运行中:接流、停止、不能删
|
||
|
||
console.log('\n=== 运行中:实时接流、停止、不能删 ===')
|
||
const base = allRuns.find((r) => r.status === 'succeeded' && !r.workflow_id)
|
||
const FAKE = 'fake0running0000000000000000000'
|
||
const t0 = Date.now() / 1000 - 3
|
||
const liveRun = {
|
||
...base, id: FAKE, status: 'running', output: {}, usage: {}, manifest_hash: null, manifest_seq: null,
|
||
created_at: new Date(t0 * 1000).toISOString(), started_at: new Date(t0 * 1000).toISOString(), finished_at: null,
|
||
}
|
||
const graph = { nodes: [
|
||
{ id: 'a', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: {} } },
|
||
{ id: 'b', type: 'llm', position: { x: 200, y: 0 }, data: { label: '总结', config: {} } },
|
||
], edges: [{ source: 'a', target: 'b' }] }
|
||
const liveEvents = [
|
||
{ seq: 1, type: 'run.started', node_id: null, ts: t0, data: { nodes: 2 } },
|
||
{ seq: 2, type: 'node.started', node_id: 'a', ts: t0 + 0.1, data: {} },
|
||
{ seq: 3, type: 'node.finished', node_id: 'a', ts: t0 + 0.2, data: { duration_ms: 100 } },
|
||
{ seq: 4, type: 'node.started', node_id: 'b', ts: t0 + 0.3, data: {} },
|
||
]
|
||
let liveStatus = 'running'
|
||
// 停下之后重读到的是后端收尾过的那一份:带结束时间和用量
|
||
fakes.set(`GET /api/runs/${FAKE}`, () => ({
|
||
status: 200,
|
||
json: liveStatus === 'running' ? liveRun
|
||
: { ...liveRun, status: liveStatus, finished_at: new Date().toISOString(), usage: { wall_ms: 3000, active_ms: 3000, wait_ms: 0 } },
|
||
}))
|
||
fakes.set(`GET /api/runs/${FAKE}/events`, () => ({ status: 200, json: liveEvents }))
|
||
fakes.set(`GET /api/runs/${FAKE}/graph`, () => ({ status: 200, json: { graph, workflow_id: null, version: null } }))
|
||
fakes.set(`POST /api/runs/${FAKE}/cancel`, () => ({ status: 200, json: { ok: true } }))
|
||
// 左边列表里也放上这一条:跑完那一刻它要闪一下,而且闪完要摘掉
|
||
const listTop = await getJson('/runs?limit=50')
|
||
fakes.set('GET /api/runs', () => ({ status: 200, json: [{ ...liveRun, status: liveStatus }, ...listTop] }))
|
||
let socket = null
|
||
await page.routeWebSocket(new RegExp(`/api/runs/${FAKE}/stream`), (ws) => { socket = ws })
|
||
await page.goto(`${WEB}/runs/${FAKE}`, { waitUntil: 'domcontentloaded' })
|
||
await page.locator('[data-run-detail]').waitFor()
|
||
await page.waitForTimeout(600)
|
||
check('运行中的记录接上了实时流', socket != null)
|
||
check('详情头有「停止」', await page.locator('[data-action=stop]').count() === 1)
|
||
const clockA = await page.locator('[data-telemetry=wall]').innerText()
|
||
await page.waitForTimeout(700)
|
||
const clockB = await page.locator('[data-telemetry=wall]').innerText()
|
||
check('墙钟在走(mm:ss.s)', /^\d\d:\d\d\.\d$/.test(clockB) && clockA !== clockB, `${clockA} → ${clockB}`)
|
||
check('状态格写着当前节点', (await page.locator('[data-run-telemetry]').innerText()).includes('当前「总结」'))
|
||
// 在跑的运行看航迹:游标跟着现在走,「此刻」列出在跑的节点
|
||
await page.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')
|
||
|
||
// ------------------------------------------------------------------ 等了很久之后接着跑
|
||
|
||
console.log('\n=== 等了几天的审批批掉之后:计时读得懂 ===')
|
||
// 第一次开始在 8 天前,挂在审批上 8 天,刚被批掉、正接着跑。从第一次开始算的
|
||
// 秒表会读成「192:00:05.3」;墙钟要写跨度,执行和轮次头只数执行的部分
|
||
const LONG = 'fake0longwait00000000000000000'
|
||
const tl = Date.now() / 1000 - 8 * 86400 - 5
|
||
const tr = Date.now() / 1000 - 5
|
||
const longRun = {
|
||
...liveRun, id: LONG, created_at: new Date(tl * 1000).toISOString(), started_at: new Date(tl * 1000).toISOString(),
|
||
}
|
||
const longGraph = { nodes: [
|
||
{ id: 'a', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: {} } },
|
||
{ id: 'h', type: 'human', position: { x: 200, y: 0 }, data: { label: '人工把关', config: {} } },
|
||
{ id: 'b', type: 'llm', position: { x: 400, y: 0 }, data: { label: '总结', config: {} } },
|
||
], edges: [{ source: 'a', target: 'h' }, { source: 'h', target: 'b' }] }
|
||
const longEvents = [
|
||
{ seq: 1, type: 'run.started', node_id: null, ts: tl, data: { nodes: 3 } },
|
||
{ seq: 2, type: 'node.started', node_id: 'a', ts: tl + 0.1, data: {} },
|
||
{ seq: 3, type: 'node.finished', node_id: 'a', ts: tl + 0.2, data: { duration_ms: 100 } },
|
||
{ seq: 4, type: 'node.started', node_id: 'h', ts: tl + 0.3, data: {} },
|
||
{ seq: 5, type: 'human.requested', node_id: 'h', ts: tl + 0.4, data: { mode: 'approve', prompt: '放行吗?' } },
|
||
{ seq: 6, type: 'run.interrupted', node_id: null, ts: tl + 0.5, data: {} },
|
||
{ seq: 7, type: 'human.resolved', node_id: 'h', ts: tr, data: { response: { approved: true }, actor: '张工' } },
|
||
{ seq: 8, type: 'run.resumed', node_id: null, ts: tr, data: { actor: '张工' } },
|
||
{ seq: 9, type: 'node.finished', node_id: 'h', ts: tr + 0.1, data: { duration_ms: 1 } },
|
||
{ seq: 10, type: 'node.started', node_id: 'b', ts: tr + 0.2, data: {} },
|
||
]
|
||
fakes.set(`GET /api/runs/${LONG}`, () => ({ status: 200, json: longRun }))
|
||
fakes.set(`GET /api/runs/${LONG}/events`, () => ({ status: 200, json: longEvents }))
|
||
fakes.set(`GET /api/runs/${LONG}/graph`, () => ({ status: 200, json: { graph: longGraph, workflow_id: null, version: null } }))
|
||
fakes.set('GET /api/runs', () => ({ status: 200, json: [longRun, ...listTop] }))
|
||
let longSocket = null
|
||
await page.routeWebSocket(new RegExp(`/api/runs/${LONG}/stream`), (ws) => { longSocket = ws })
|
||
await page.goto(`${WEB}/runs/${LONG}`, { waitUntil: 'domcontentloaded' })
|
||
await page.locator('[data-run-detail]').waitFor()
|
||
await page.waitForTimeout(800)
|
||
check('接上了实时流', longSocket != null)
|
||
const HMS = /\d+:\d\d:\d\d\.\d/
|
||
const longWall = await page.locator('[data-telemetry=wall]').innerText()
|
||
check('墙钟写跨度(8 天),不是秒表', /8 天/.test(longWall) && !HMS.test(longWall), longWall)
|
||
const activeA = await page.locator('[data-telemetry=active]').innerText()
|
||
await page.waitForTimeout(500)
|
||
const activeB = await page.locator('[data-telemetry=active]').innerText()
|
||
check('执行仍是走动的秒表,从批掉那一刻数起', /^00:0\d\.\d$/.test(activeB) && activeA !== activeB, `${activeA} → ${activeB}`)
|
||
const turnClock = await page.locator('[data-run-detail] > div span[title="已运行"]').first().innerText().catch(() => '')
|
||
check('轮次头的秒表只数执行的部分', /^00:0\d\.\d$/.test(turnClock), turnClock)
|
||
const rowClock = await page.locator(`[data-runs-list] [data-run-id="${LONG}"] [data-run-duration]`).innerText().catch(() => '')
|
||
check('列表那一行写「8 天」,不是秒表', /8 天/.test(rowClock) && !HMS.test(rowClock), rowClock)
|
||
fakes.delete('GET /api/runs')
|
||
fakes.delete(`GET /api/runs/${LONG}`)
|
||
fakes.delete(`GET /api/runs/${LONG}/events`)
|
||
fakes.delete(`GET /api/runs/${LONG}/graph`)
|
||
|
||
// ------------------------------------------------------------------ 删除保护
|
||
|
||
console.log('\n=== 删除保护 ===')
|
||
const FORMAL = 'fake0formal00000000000000000000'
|
||
const formalRun = { ...base, id: FORMAL, run_class: 'formal', version: 3, version_hash: 'f'.repeat(64) }
|
||
fakes.set(`GET /api/runs/${FORMAL}`, () => ({ status: 200, json: formalRun }))
|
||
fakes.set(`GET /api/runs/${FORMAL}/events`, () => ({ status: 200, json: liveEvents.slice(0, 3) }))
|
||
let deleteCalls = []
|
||
fakes.set(`DELETE /api/runs/${FORMAL}`, (url) => {
|
||
deleteCalls.push(url.search)
|
||
return url.searchParams.get('force') === 'true'
|
||
? { status: 204, body: '' }
|
||
: { status: 409, json: { detail: '这是一次已封存的正式运行,是出具结果的追溯凭证。删除后它的事件和工件会一并删掉,封存清单再也无法核对。确认要删的话请再确认一次(强制删除)' } }
|
||
})
|
||
await page.goto(`${WEB}/runs/${FORMAL}`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-run-detail]').waitFor()
|
||
check('头部没有常驻的删除按钮', await page.locator('[data-run-detail] header button[title*="删除"]').count() === 0)
|
||
await page.getByRole('button', { name: '更多操作' }).click()
|
||
await page.locator('[data-menu=delete]').click()
|
||
const dialog = page.getByRole('dialog')
|
||
await dialog.waitFor()
|
||
check('确认框写清后果(清单无法再核对)', (await dialog.innerText()).includes('封存清单再也无法核对'))
|
||
await dialog.getByRole('button', { name: '删除记录' }).click()
|
||
await page.waitForTimeout(400)
|
||
const second = page.getByRole('dialog')
|
||
await second.waitFor()
|
||
check('后端 409 的 detail 显示在二次确认里', (await second.innerText()).includes('已封存的正式运行'))
|
||
const forceBtn = second.getByRole('button', { name: '强制删除' })
|
||
check('强制删除要照抄 id 才能点', await forceBtn.isDisabled())
|
||
await second.locator('input').fill(FORMAL.slice(0, 6))
|
||
await forceBtn.click()
|
||
await page.waitForURL((u) => u.pathname === '/runs', { timeout: 5000 }).catch(() => {})
|
||
check('先不带 force、确认后再带 force=true', deleteCalls.length === 2 && deleteCalls[0] === '' && deleteCalls[1] === '?force=true',
|
||
JSON.stringify(deleteCalls))
|
||
check('删掉后回到列表', new URL(page.url()).pathname === '/runs')
|
||
|
||
// 普通 409(比如后端说还在执行):原话提示,不静默
|
||
const BUSY = 'fake0busy0000000000000000000000'
|
||
fakes.set(`GET /api/runs/${BUSY}`, () => ({ status: 200, json: { ...base, id: BUSY } }))
|
||
fakes.set(`GET /api/runs/${BUSY}/events`, () => ({ status: 200, json: liveEvents.slice(0, 3) }))
|
||
fakes.set(`DELETE /api/runs/${BUSY}`, () => ({ status: 409, json: { detail: '这次运行还在执行,现在删除会留下没人管的任务。先停止它,再删除' } }))
|
||
await page.goto(`${WEB}/runs/${BUSY}`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-run-detail]').waitFor()
|
||
await page.getByRole('button', { name: '更多操作' }).click()
|
||
await page.locator('[data-menu=delete]').click()
|
||
await page.getByRole('dialog').getByRole('button', { name: '删除记录' }).click()
|
||
check('409 时把后端的话原样告诉人', await page.getByText('先停止它,再删除').first().waitFor({ timeout: 4000 }).then(() => true, () => false))
|
||
check('409 时停在原地', new URL(page.url()).pathname === `/runs/${BUSY}`)
|
||
|
||
// ------------------------------------------------------------------ 航迹
|
||
|
||
console.log('\n=== 航迹:按时间摊开,拖到哪一刻读到哪一刻 ===')
|
||
// 一次等过 5 分钟审批的运行:查询 → 审批 → 汇总。名字都是编的通用示例
|
||
const wfHost = (await getJson('/workflows'))[0]
|
||
const iso = (s) => new Date(s * 1000).toISOString()
|
||
const TRACE = 'fake0trace00000000000000000000'
|
||
const TRACE_OFF = 'fake0traceoff00000000000000000'
|
||
const tt = Date.now() / 1000 - 3600
|
||
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 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 }))
|
||
|
||
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(() => '')
|
||
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'))
|
||
|
||
// 模型把工具调用写成了文字、一次都没真正查(引擎判失败):结局是失败,而且说得出为什么
|
||
console.log('\n=== 航迹:模型没有真正调用工具 ===')
|
||
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'))
|
||
|
||
// 失败在「汇总」上,而工作流之后把「汇总」删了:画布上对准它什么也对不到
|
||
console.log('\n=== 失败的节点在工作流现在的图里已经没了 ===')
|
||
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)
|
||
|
||
// ------------------------------------------------------------------ 工件
|
||
|
||
console.log('\n=== 工件:按节点分组,就地打开 ===')
|
||
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} 件`)
|
||
}
|
||
|
||
// ------------------------------------------------------------------ 回画布
|
||
|
||
console.log('\n=== 在画布中回放 ===')
|
||
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')
|
||
|
||
// ------------------------------------------------------------------ 列表:缩略条、取消原因
|
||
|
||
console.log('\n=== 列表:每行一条缩略条 ===')
|
||
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, ' ')
|
||
check('已取消写原因,不重复「用户取消」', cancelText.includes('在等审批时放弃了这次运行') && !cancelText.includes('用户取消'), cancelText.slice(0, 80))
|
||
fakes.delete('GET /api/runs')
|
||
|
||
console.log('\n=== 工作流筛选:未保存的工作流 ===')
|
||
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')
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ 等了几天、窄屏
|
||
|
||
console.log('\n=== 等了几天的审批、1024 宽 ===')
|
||
// 停在审批上十天多:等人要写「10 天 05 小时」,而不是被截成「245 小时 3…」;游标写「T+10 天 05:30:07」,
|
||
// 不写「T+245:30:07.3」(同一行的墙钟、等人写的是「10 天」,而且放不下)。等人的时长在涨,游标要走
|
||
const STALE = 'fake0stalewait0000000000000000'
|
||
const tw = Date.now() / 1000 - 10 * 86400 - 5.5 * 3600
|
||
const 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 fits = (sel) => page.locator(sel).evaluateAll((els) => els.length > 0 && els.every((e) => e.scrollWidth <= e.clientWidth + 0.5))
|
||
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, ' '))
|
||
|
||
// 去审批卡:审批卡在时间线里。从航迹、工件、原始事件点过来,要切回时间线、描一下卡片,
|
||
// 并且焦点进到卡片里——键盘和读屏用户才接得着。视图经地址切,提交比点击晚好几帧
|
||
console.log('\n=== 去审批卡:从哪个视图点都能把焦点送进卡片 ===')
|
||
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 天之后。关键时刻里时刻那一栏按最宽的
|
||
// 一条排齐,不压到后面的徽标上;终态的游标放得下
|
||
console.log('\n=== 跨天的关键时刻 ===')
|
||
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'))
|
||
}
|
||
|
||
// 屏幕矮、节点多:九个节点的失败运行,横幅占去一截。读数区先拿够它要的,默认选中的失败
|
||
// 节点卡、关键时刻都得在首屏;坞拿剩下的,不矮过能拖到的最矮,泳道在坞里滚
|
||
console.log('\n=== 屏幕矮、节点多:读数区不被航迹坞压住 ===')
|
||
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),
|
||
}
|
||
})
|
||
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')
|
||
|
||
console.log('\n=== 收尾 ===')
|
||
// 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 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)
|