第三波前两批:六个「看着成功其实没干活」的平台漏洞堵上,记录页能回放,管理页与画布收尾

起因是一次探索运行: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 更新。
This commit is contained in:
ThearchyHelios
2026-09-27 10:16:32 +08:00
parent e10c4f23fc
commit 587bf7e27c
134 changed files with 20140 additions and 2709 deletions
+414 -9
View File
@@ -19,7 +19,11 @@
// - 定位 / 去审批取景半路被拉远,落地卡在 compact 档(错误正文被藏);
// - 「只看执行路径」带进下一次运行;正式运行横幅盖住「恢复」;
// - 同一刻的等人时长几处写几个数;只是点一下画布就暂停跟随;
// - Esc 在右栏里按也清掉整次运行;泳道、节点格、坞高只能用鼠标。
// - Esc 在右栏里按也清掉整次运行;泳道、节点格、坞高只能用鼠标;
// - 运行中点节点,属性面板整块盖住右栏的运行视图(应当先请右栏滚到它的步骤);
// - 等审批时只能批不能放弃;回放时回边的 ×N、面板的用量拿终值冒充那一刻;
// - Copilot 搭图时新节点落在视口外看不见;双击空白处只会缩放;1024 宽打开只看得见两张半卡;
// - Copilot 删掉的节点一声不响就没了,看不出删的是哪个、原来在哪。
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
@@ -187,6 +191,13 @@ async function openStudio({ id = FX_ID, theme = 'dark', ...extra } = {}) {
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
page.on('console', (m) => { if (m.type() === 'error' && !m.text().includes('404') && !m.text().includes('409')) errors.push(m.text()) })
await fakeBackend(page)
// 主题:设置接口里存的偏好会在启动时盖过本地缓存,不改它的话「暗色」那一遍画出来是亮的
await page.route('**/api/settings', async (route) => {
if (route.request().method() !== 'GET') return route.continue()
const res = await route.fetch()
const body = await res.json().catch(() => ({}))
await route.fulfill({ response: res, json: { ...body, ui: { ...(body.ui ?? {}), theme } } })
})
await page.goto(`${WEB}/studio/${id}`, { waitUntil: 'networkidle' })
await page.waitForFunction((wid) => window.__studio?.getState().workflow?.id === wid, id, { timeout: 15000 })
await page.waitForTimeout(700)
@@ -207,6 +218,18 @@ const seedRun = (page, extra = {}) => page.evaluate((x) => {
}, extra)
const count = (page, sel) => page.locator(sel).count()
/** 轮询到条件成立或超时。取景有动画、热更新可能插一脚,固定等几百毫秒会偶发失败 */
async function until(fn, { timeout = 3000, every = 100 } = {}) {
const end = Date.now() + timeout
for (;;) {
const v = await fn().catch(() => false)
if (v || Date.now() > end) return v
await new Promise((r) => setTimeout(r, every))
}
}
/** 开发服务器热更新会把页面整个重载(别的代理恰好在改代码),store 里灌的运行就没了 */
const markBoot = (page) => page.evaluate(() => { window.__fxBoot = true })
const reloaded = (page) => page.evaluate(() => !window.__fxBoot).catch(() => true)
const capsule = (page) => page.locator('.sf-capsule')
/** 工具栏上的「运行」发起按钮(胶囊的名字也以「运行中」开头,按全名找) */
const launcher = (page) => page.locator('[data-run-control]').getByRole('button', { name: /^运行$/ })
@@ -330,6 +353,7 @@ console.log('=== 运行中:胶囊、光点、计时 ===')
console.log('=== 等人:入边闸门,不流光点;去审批代替停止 ===')
{
const { browser, page } = await openStudio()
await markBoot(page)
await seedRun(page)
await feed(page, [...PARALLEL, ...TEAM_DONE, ...TO_REVIEW])
await page.waitForTimeout(400)
@@ -351,12 +375,34 @@ console.log('=== 等人:入边闸门,不流光点;去审批代替停止 ==
check('等人时全图 0 个光点', await count(page, '.edge-packet') === 0, `${await count(page, '.edge-packet')} 个`)
check('小地图上等人的节点放大了一圈', await count(page, '.sf-mm-ring.sf-mm-waiting') === 1)
// 去审批:属性面板盖在右栏上时先让开,画布取景到等人的节点
await page.evaluate(() => window.__studio.setState({ focusRequest: null, selectedId: 'gate' }))
// 去审批:属性面板盖在右栏上时先让开,画布取景到等人的节点。右栏(助手面板)接
// agentlab:goto-approval 事件切到运行层、滚到审批卡;它接了(preventDefault)胶囊就不再自己找
await page.evaluate(() => {
window.__goto = []
window.addEventListener('agentlab:goto-approval', (e) => { window.__goto.push(e.detail); e.preventDefault() })
window.__studio.setState({ focusRequest: null, selectedId: 'gate' })
// 胶囊自己兜底时在整页里找审批卡(document.querySelector);右栏接手时只在自己的
// 滚动区里找。数一下前者,就知道胶囊有没有在右栏接手之后还去找
window.__slotLookups = 0
const find = Document.prototype.querySelector
window.__restoreQuery = () => { Document.prototype.querySelector = find }
Document.prototype.querySelector = function (sel) {
if (String(sel).includes('data-approval-slot')) window.__slotLookups += 1
return find.call(this, sel)
}
})
await capsule(page).getByRole('button', { name: /去审批/ }).click()
await page.waitForTimeout(200)
const went = await page.evaluate(() => ({ sel: window.__studio.getState().selectedId, focus: window.__studio.getState().focusRequest?.id }))
check('去审批:属性面板让开、取景到等人的节点', went.sel === null && went.focus === 'review', JSON.stringify(went))
const goto = await page.evaluate(() => window.__goto)
check('去审批:广播给右栏(带运行和节点)', goto.length === 1 && goto[0].nodeId === 'review' && goto[0].runId === 'fxrun000001',
JSON.stringify(goto))
// 描边、滚动都不能拿来判断——右栏接手时自己也描同一圈、滚到同一张卡
await page.waitForTimeout(600)
const lookups = await page.evaluate(() => { window.__restoreQuery(); return window.__slotLookups })
check('右栏接手之后胶囊不再自己找卡片、不报「还没出现」', lookups === 0
&& !(await page.locator('[aria-live]').allInnerTexts()).join('').includes('审批卡还没出现'), `整页找了 ${lookups} 次`)
// F:在需要处理的队列里逐个定位
await page.evaluate(() => window.__studio.setState({ focusRequest: null }))
@@ -365,6 +411,32 @@ console.log('=== 等人:入边闸门,不流光点;去审批代替停止 ==
await page.waitForTimeout(200)
const focus = await page.evaluate(() => window.__studio.getState().focusRequest?.id)
check('F 定位到等人的节点', focus === 'review', String(focus))
// 放弃这次运行:等审批时的次级动作。关掉的审批找不回来,先问一句;确认后才调 /cancel
let cancels = 0
await page.route(/\/api\/runs\/fxrun000001\/cancel$/, (route) => {
cancels += 1
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, status: 'cancelled' }) })
})
const abandon = capsule(page).getByRole('button', { name: '放弃这次运行' })
check('等审批时有「放弃这次运行」', await abandon.count() === 1)
await abandon.click()
const ask = page.getByRole('dialog').filter({ hasText: '放弃这次运行?' })
await ask.waitFor({ timeout: 3000 }).catch(() => {})
const askText = (await ask.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('放弃之前先问一句,写清后果', askText.includes('审批卡一并关闭') && cancels === 0, askText.slice(0, 80))
await ask.getByRole('button', { name: '放弃这次运行' }).click().catch(() => {})
await until(async () => cancels === 1, { timeout: 2000 })
check('确认后才调 /cancel', cancels === 1, String(cancels))
await feed(page, [ev(19, 'run.cancelled', null, {
timing: { wall_ms: 20000, active_ms: 14000, wait_ms: 6000 }, actor: '检查员', message: '放弃了这次运行,1 条待审批一并关闭',
}, 20)])
await page.waitForTimeout(300)
// 胶囊不见了多半是别的代理改代码触发了整页热更新(灌进 store 的运行没了):报出来,别卡 30 秒崩掉
const opened = await capsule(page).locator('.sf-cap-main').click({ timeout: 5000 }).then(() => true).catch(() => false)
const line = opened ? await page.locator('.sf-hud-line').innerText().catch(() => '') : ''
check('取消之后说清谁放弃的、关了什么', line.includes('检查员 放弃了这次运行,1 条待审批一并关闭'),
opened ? line : `胶囊不见了${await reloaded(page) ? ':页面被热更新重载过,重跑确认' : ''}`)
await browser.close()
}
@@ -691,6 +763,17 @@ console.log('=== 正式运行:入口看 published_version;运行期间画布
const formal = page.getByRole('button', { name: /正式运行 v2/ })
check('草稿状态下正式运行入口还在(看 published_version)', await formal.count() === 1)
check('画布有未保存改动时也能点', await formal.isEnabled())
// 运行类别的记号:正式运行实心圆点、探索运行空心;盾牌只留给发布等级,不在发起入口上混用
const marks = (sel) => page.evaluate((sel) => {
const root = document.querySelector(sel)
return {
formal: root?.querySelectorAll('svg.sf-dot[data-class="formal"]').length ?? 0,
exploratory: root?.querySelectorAll('svg.sf-dot[data-class="exploratory"]').length ?? 0,
shield: root?.querySelectorAll('.lucide-shield-check, svg[class*="shield"]').length ?? 0,
}
}, sel)
const bar = await marks('[data-run-control]')
check('正式运行入口是实心圆点,没有盾牌', bar.formal === 1 && bar.shield === 0, JSON.stringify(bar))
await formal.click()
await page.waitForSelector('.sf-pop', { timeout: 5000 })
await page.waitForTimeout(400)
@@ -699,6 +782,16 @@ console.log('=== 正式运行:入口看 published_version;运行期间画布
check('表单字段取已发布版本的(topic),不取画布的(q)',
await page.locator('.sf-pop textarea#run-field-topic').count() === 1
&& await page.locator('.sf-pop textarea#run-field-q').count() === 0)
const formalPop = await marks('.sf-pop')
check('正式运行浮层的标题和确认按钮都是实心圆点', formalPop.formal === 2 && formalPop.exploratory === 0 && formalPop.shield === 0,
JSON.stringify(formalPop))
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
await launcher(page).click()
await page.waitForSelector('.sf-pop', { timeout: 5000 })
const explorePop = await marks('.sf-pop')
check('探索运行浮层是空心圆点', explorePop.exploratory === 1 && explorePop.formal === 0 && explorePop.shield === 0,
JSON.stringify(explorePop))
await page.keyboard.press('Escape')
await page.evaluate(() => window.__studio.setState({ dirty: false }))
@@ -782,13 +875,30 @@ console.log('=== 打开宽图:以入口为左锚,缩放不低于可读下限
const vp = () => page.evaluate(() => document.querySelector('.react-flow__viewport').style.transform)
const t0 = Date.now() / 1000
const wev = (seq, type, node_id, dt) => ({ seq, type, node_id, data: {}, ts: t0 + dt })
await markBoot(page)
await seedRun(page, { workflow_id: WIDE_ID })
await feed(page, [wev(1, 'run.started', null, 0), wev(2, 'node.started', 'n0', 0.1), wev(3, 'node.finished', 'n0', 0.2)])
// 航迹坞升起来、画布变矮之后再量:坞出现时保持中心的那一下也会挪视口
await page.waitForSelector('.tl', { timeout: 3000 }).catch(() => {})
await page.waitForTimeout(300)
check('n9 起初在视口外', !(await inView('n9')))
const vpFrom = await vp()
await feed(page, [wev(4, 'node.started', 'n9', 0.3)])
await page.waitForTimeout(700)
check('跟随执行:视口外的节点开跑,镜头跟过去', await inView('n9'))
// 等视口真的动了、并且停在 n9 上:取景有 320ms 的动画,固定等一段时间会偶发落空
const followed = await until(async () => (await vp()) !== vpFrom && await inView('n9'))
const why = followed ? '' : JSON.stringify({
reloaded: await reloaded(page),
phase: await page.evaluate(() => window.__studio?.getState().runPhase).catch(() => null),
follow: await page.evaluate(() => window.__studio?.getState().follow).catch(() => null),
moved: (await vp()) !== vpFrom,
})
check('跟随执行:视口外的节点开跑,镜头跟过去', !!followed, why)
// 等这一趟飞完,再让下一个远处的节点开跑,趁镜头飞到一半时人抓住画布拖一下。
// React Flow 把被打断的那段动画的收尾推迟一拍才报,报到时人的拖动已经开始——
// 以前它把人的起点吃掉,拖完跟随照样不暂停
await until(async () => { const a = await vp(); await new Promise((r) => setTimeout(r, 120)); return a === await vp() })
await feed(page, [wev(5, 'node.finished', 'n9', 0.4), wev(6, 'node.started', 'n1', 0.5)])
await page.waitForTimeout(110)
const pane = await page.locator('.react-flow__pane').boundingBox()
await page.mouse.move(pane.x + 300, pane.y + 60)
await page.mouse.down()
@@ -796,14 +906,21 @@ console.log('=== 打开宽图:以入口为左锚,缩放不低于可读下限
await page.mouse.up()
await page.waitForTimeout(200)
const paused = await vp()
check('手动拖过画布:显示「已暂停跟随」', (await page.locator('.sf-follow').innerText()).includes('已暂停跟随'))
await feed(page, [wev(5, 'node.finished', 'n9', 0.4), wev(6, 'node.started', 'n2', 0.5)])
check('镜头飞到一半被人拖住:显示「已暂停跟随」',
(await page.locator('.sf-follow').innerText({ timeout: 3000 }).catch(() => '')).includes('已暂停跟随'))
await feed(page, [wev(7, 'node.finished', 'n1', 0.6), wev(8, 'node.started', 'n2', 0.7)])
await page.waitForTimeout(700)
check('暂停期间不再抢镜头', await vp() === paused)
// 点名取景:已暂停也照做(是人要看它)
await page.evaluate(() => window.__studio.getState().focusNode('n11'))
await page.waitForTimeout(700)
check('focusNode:画布取景到点名的节点', await inView('n11'))
check('focusNode:画布取景到点名的节点', !!(await until(() => inView('n11'))))
// 画布尺寸恰好在取景半路变了(航迹坞收起、升起):保持中心那一下会打断动画,
// 打断之后要重新飞到目标,不能停在半路
await page.evaluate(() => window.__studio.getState().focusNode('n0'))
await page.waitForTimeout(90)
await page.locator('.tl').getByRole('button', { name: '收起航迹' }).click().catch(() => {})
check('取景半路画布变高:照样落到目标上', !!(await until(() => inView('n0'))))
await page.locator('.tl').getByRole('button', { name: '展开' }).click().catch(() => {})
await page.evaluate(() => window.__studio.getState().clearRun())
await page.waitForTimeout(200)
@@ -820,6 +937,294 @@ console.log('=== 打开宽图:以入口为左锚,缩放不低于可读下限
await browser.close()
}
// ---------------------------------------------------------------- 运行中点节点
console.log('=== 运行中点节点:先看它的步骤,属性面板是次级入口 ===')
{
const { browser, page, errors } = await openStudio()
await seedRun(page)
await feed(page, PARALLEL_NOW)
await page.waitForTimeout(400)
await page.evaluate(() => {
window.__reveal = []
window.addEventListener('agentlab:reveal-step', (e) => window.__reveal.push(e.detail))
})
const sheet = () => count(page, '[data-inspector-sheet]')
const head = (id) => page.locator(`.react-flow__node[data-id="${id}"] .nc-head`)
await head('start').click()
await page.waitForTimeout(250)
const picked = await page.evaluate(() => ({ sel: window.__studio.getState().selectedId, reveal: window.__reveal }))
check('点跑过的节点:选中它', picked.sel === 'start', String(picked.sel))
check('属性面板不自动盖上右栏', await sheet() === 0)
check('请右栏滚到它的步骤(agentlab:reveal-step)', picked.reveal.length === 1 && picked.reveal[0].nodeId === 'start'
&& picked.reveal[0].runId === 'fxrun000001', JSON.stringify(picked.reveal))
const peek = page.locator('.sf-peek')
check('画布上给出「…的配置」入口', await peek.count() === 1 && (await peek.innerText()).includes('问题'), await peek.innerText().catch(() => ''))
await peek.click()
await page.waitForTimeout(250)
check('点入口才打开属性面板', await sheet() === 1 && await count(page, '.sf-peek') === 0)
check('打开之后底部留着回到运行的那一条', await count(page, '[data-inspector-sheet] [data-run-phase]') === 1)
// 没跑过的节点在右栏里没有步骤可看:照常打开属性面板
await page.locator('.react-flow__pane').click({ position: { x: 20, y: 300 } })
await page.waitForTimeout(150)
await page.evaluate(() => { window.__reveal = [] })
await head('gate').click()
await page.waitForTimeout(250)
check('点没跑过的节点:照常打开属性面板,不请右栏滚动', await sheet() === 1
&& await page.evaluate(() => window.__reveal.length) === 0)
// 双击跑过的节点:直接看配置
await page.locator('.react-flow__pane').click({ position: { x: 20, y: 300 } })
await page.waitForTimeout(150)
await head('team').dblclick()
await page.waitForTimeout(250)
check('双击节点直接打开属性面板', await sheet() === 1 && await page.evaluate(() => window.__studio.getState().selectedId) === 'team')
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 收起属性面板、取消选中,运行还在', await sheet() === 0
&& await page.evaluate(() => window.__studio.getState().runPhase) === 'running')
// 先看了步骤、运行随后结束:节点还选中着、面板没开。再点它(选中没变)也得打开面板
await head('start').click()
await page.waitForTimeout(200)
await page.evaluate(() => window.__studio.getState().clearRun())
await page.waitForTimeout(200)
const kept = await page.evaluate(() => window.__studio.getState().selectedId)
check('运行清掉之后仍选中的节点留着「…的配置」入口', kept !== 'start' || await count(page, '.sf-peek') === 1, String(kept))
await head('start').click()
await page.waitForTimeout(250)
check('没有运行时再点这个节点:打开属性面板', await sheet() === 1)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- 回放:回边圈数、用量跟着游标
console.log('=== 回放:回边的 ×N 和面板的用量都是那一刻的 ===')
{
const { browser, page, errors } = await openStudio()
await seedRun(page)
// 两次模型调用,一次在第 5 秒、一次在第 12 秒;终态的后端累计和它们对得上
const usage = [
ev(30, 'llm.end', 'team', { agent: '检索员', model: 'm', input_tokens: 600, output_tokens: 200, cost_usd: 0.002 }, 5),
ev(31, 'llm.end', 'team', { agent: '分析员', model: 'm', input_tokens: 400, output_tokens: 200, cost_usd: 0.001 }, 12),
]
const finished = TO_LOOP.map((e) => (e.type === 'run.finished'
? { ...e, data: { ...e.data, usage: { input_tokens: 1000, output_tokens: 400, cost_usd: 0.003 } } } : e))
const all = [...PARALLEL, ...usage, ...TEAM_DONE, ...finished].sort((a, b) => a.ts - b.ts)
.map((e, i) => ({ ...e, seq: i + 1, replay: true }))
await feed(page, all)
await page.waitForTimeout(400)
const badge = () => page.locator('.sf-loop-badge').innerText().catch(() => '')
check('跑完:回边写兜回去几次(↺ ×1)', (await badge()).replace(/\s+/g, ' ') === '↺ ×1', await badge())
const at = (t) => page.evaluate((ms) => window.__studio.getState().setReplayAt(ms), (BASE + t) * 1000)
// 返工的第一轮还在跑:还没兜回去过
await at(18)
await page.waitForTimeout(250)
check('回放到第一轮:回边还没兜回去,不写次数', (await badge()).trim() === '↺', await badge())
// 协作团队跑到一半:只有第一次模型调用
await at(8)
await page.waitForTimeout(250)
await capsule(page).locator('.sf-cap-main').click()
const usageOf = async () => (await page.locator('.sf-metric', { hasText: '用量' }).innerText().catch(() => '')).replace(/\s+/g, ' ')
const mid = await usageOf()
check('回放:面板的用量是那一刻的(800 tok),不是终值', /800 tok/.test(mid) && !/1\.4k/.test(mid), mid)
const side = await page.locator('.react-flow__node[data-id="team"] .nc-tele-side').innerText().catch(() => '')
check('回放:卡片上的 token 也是那一刻的', /800 tok/.test(side), side)
await page.keyboard.press('Escape')
await page.evaluate(() => window.__studio.getState().setReplayAt(null))
await page.waitForTimeout(250)
await capsule(page).locator('.sf-cap-main').click()
const live = await usageOf()
check('回到实时:用量是整次运行的(1.4k tok)', /1\.4k tok/.test(live), live)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- Copilot 搭图时的镜头
console.log('=== Copilot 搭图:新节点在视口外时镜头跟过去,人一动手这一轮就不跟 ===')
for (const reducedMotion of ['no-preference', 'reduce']) {
const { browser, page, errors } = await openStudio({ id: WIDE_ID, reducedMotion })
const inView = (id) => page.evaluate((nid) => {
const pane = document.querySelector('.react-flow').getBoundingClientRect()
const r = document.querySelector(`.react-flow__node[data-id="${nid}"]`).getBoundingClientRect()
return r.left >= pane.left && r.right <= pane.right && r.top >= pane.top && r.bottom <= pane.bottom
}, id)
const vp = () => page.evaluate(() => document.querySelector('.react-flow__viewport').style.transform)
const building = (on, cursor) => page.evaluate(({ on, cursor }) => {
const st = window.__studio
st.setState({ copilot: { ...st.getState().copilot, active: on }, copilotCursor: cursor })
}, { on, cursor })
const label = reducedMotion === 'reduce' ? '(减少动效)' : ''
check(`n11 起初在视口外${label}`, !(await inView('n11')))
await building(true, 'n11')
if (reducedMotion === 'reduce') {
// 减少动效时不做动画:下一帧就到位
await page.waitForTimeout(120)
check('减少动效:镜头直接到位,不飞', await inView('n11'))
} else {
check('助手刚放下的节点在视口外:镜头跟过去', !!(await until(() => inView('n11'))))
const z0 = await page.evaluate(() => document.querySelector('.react-flow').style.getPropertyValue('--zoom'))
// 人拖了一下画布:这一轮不再跟
const pane = await page.locator('.react-flow__pane').boundingBox()
await page.mouse.move(pane.x + 200, pane.y + 200)
await page.mouse.down()
await page.mouse.move(pane.x + 380, pane.y + 240, { steps: 5 })
await page.mouse.up()
await page.waitForTimeout(250)
const held = await vp()
await building(true, 'n0')
await page.waitForTimeout(700)
check('人动过镜头之后,这一轮不再抢', await vp() === held)
// 下一轮重新跟
await building(false, null)
await building(true, 'n0')
check('下一轮又跟上', !!(await until(() => inView('n0'))))
const z1 = await page.evaluate(() => document.querySelector('.react-flow').style.getPropertyValue('--zoom'))
check('跟镜头只平移,不改缩放', z0 === z1, `${z0} → ${z1}`)
}
await building(false, null)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- Copilot 删掉的节点:原地虚线框
console.log('=== Copilot 这一轮删掉的节点:原地留一道虚线框,停一下再淡出 ===')
/** 照 store 的真实顺序走一轮:开始 → 节点没了 → 这一轮落定(diff 写着删了谁)→ 结束 */
const removeInTurn = (page, id) => page.evaluate((id) => {
const st = window.__studio
const turn = { id: 'fx-turn', instruction: '去掉返工那一步', ops: [], phase: 'running', explanation: '', error: '' }
st.setState({ copilot: { ...st.getState().copilot, active: true } })
st.setState({ copilotTurns: [...st.getState().copilotTurns, turn] })
st.setState({
nodes: st.getState().nodes.filter((n) => n.id !== id),
edges: st.getState().edges.filter((e) => e.source !== id && e.target !== id),
})
st.setState({ copilotTurns: st.getState().copilotTurns.map((t) => (t.id === turn.id ? {
...t, phase: 'done', outcome: 'applied',
diff: { added: [], removed: [id], changed: [], edgesAdded: 0, edgesRemoved: 2, total: 3 },
} : t)) })
st.setState({ copilot: { ...st.getState().copilot, active: false } })
}, id)
for (const reducedMotion of ['no-preference', 'reduce']) {
const { browser, page, errors } = await openStudio({ reducedMotion })
const was = await page.locator('.react-flow__node[data-id="fix"]').boundingBox()
await removeInTurn(page, 'fix')
await page.waitForTimeout(400)
const ghost = page.locator('.sf-ghost[data-ghost="fix"]')
if (reducedMotion === 'reduce') {
check('减少动效:不画虚线框', await ghost.count() === 0)
} else {
const box = await ghost.boundingBox()
const tag = await ghost.innerText().catch(() => '')
check('删掉的节点原地留一道虚线框,写着删的是谁', !!box && tag.includes('已删除 · 返工'), tag)
check('框就在它原来的位置、一样大', !!box && !!was && Math.abs(box.x - was.x) < 4 && Math.abs(box.y - was.y) < 4
&& Math.abs(box.width - was.width) < 4, JSON.stringify({ was, box }))
const tagPx = await ghost.locator('.sf-ghost-tag').evaluate((el) => el.getBoundingClientRect().height)
check('标签在屏幕上读得清', tagPx >= 14, `${tagPx.toFixed(1)}px`)
check('虚线框不挡操作', await ghost.evaluate((el) => getComputedStyle(el).pointerEvents) === 'none')
await page.screenshot({ path: '/tmp/agentlab-canvas-fx-ghost.png' })
check('停一下就淡出、收掉', !!(await until(async () => await ghost.count() === 0, { timeout: 4000 })))
}
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// 窄画布打开就是精简档(缩放 0.4 上下):标签的字照样补回 11px,不跟着缩成 7px
{
const { browser, page, errors } = await openStudio({ viewport: { width: 1024, height: 768 } })
await page.waitForTimeout(400)
const zoom = Number(await page.evaluate(() => document.querySelector('.react-flow').style.getPropertyValue('--zoom')))
// 质量门在前三列里,这个缩放下在视口内:截图看得到
await removeInTurn(page, 'gate')
await page.waitForTimeout(400)
const tag = await page.locator('.sf-ghost[data-ghost="gate"] .sf-ghost-tag').evaluate((el) => ({
h: el.getBoundingClientRect().height, font: parseFloat(getComputedStyle(el).fontSize),
clipped: el.scrollWidth > el.clientWidth + 1,
})).catch(() => null)
const px = tag ? tag.font * zoom : 0
check(`精简档(缩放 ${zoom.toFixed(2)}):虚线框的标签照样读得清(屏幕上 ≥ 11px、高 ≥ 14px)`,
zoom < 0.5 && !!tag && px >= 10.9 && tag.h >= 14 && !tag.clipped,
tag ? `字 ${px.toFixed(1)}px · 高 ${tag.h.toFixed(1)}px${tag.clipped ? ' · 被截断' : ''}` : '没画虚线框')
await page.screenshot({ path: '/tmp/agentlab-canvas-fx-ghost-compact.png' })
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- 双击快速添加
console.log('=== 双击空白处:在光标处快速添加节点 ===')
{
const { browser, page, errors } = await openStudio()
const spot = await emptySpot(page)
const zoomOf = () => page.evaluate(() => document.querySelector('.react-flow').style.getPropertyValue('--zoom'))
const z0 = await zoomOf()
const n0 = await page.evaluate(() => window.__studio.getState().nodes.length)
await page.mouse.dblclick(spot.x, spot.y)
await page.waitForTimeout(250)
const quick = page.locator('.sf-quick')
check('双击空白处打开快速添加', await quick.count() === 1)
check('双击不再缩放画布', await zoomOf() === z0, `${z0} → ${await zoomOf()}`)
check('焦点直接在搜索框里', await page.evaluate(() => !!document.activeElement?.closest('.sf-quick input')))
const panel = await quick.boundingBox()
check('面板贴着双击处', !!panel && Math.abs(panel.x - spot.x) < 260 && Math.abs(panel.y - spot.y) < 320,
panel ? `${Math.round(panel.x)},${Math.round(panel.y)} / ${Math.round(spot.x)},${Math.round(spot.y)}` : '没有面板')
// 只打一个字:名字里有它的排在只是说明里提到它的前面(协作节点的说明里有「分派」)
await page.keyboard.type('分')
await page.waitForTimeout(100)
const first = await quick.locator('[role="option"][aria-selected="true"]').innerText().catch(() => '')
check('按名字搜,第一个就是要的', first.includes('条件分支'), first)
await page.keyboard.press('Enter')
await page.waitForTimeout(300)
const added = await page.evaluate(() => {
const st = window.__studio.getState()
const n = st.nodes[st.nodes.length - 1]
const el = document.querySelector(`.react-flow__node[data-id="${n.id}"]`)?.getBoundingClientRect()
return { total: st.nodes.length, type: n.data.nodeType, cx: el ? el.left + el.width / 2 : null, cy: el ? el.top + el.height / 2 : null }
})
check('回车放下一个条件分支', added.total === n0 + 1 && added.type === 'branch', JSON.stringify(added))
check('落在双击处(卡片中心对着光标)', added.cx != null && Math.abs(added.cx - spot.x) < 40 && Math.abs(added.cy - spot.y) < 40,
JSON.stringify({ spot, at: [added.cx, added.cy] }))
check('放下之后面板收起', await quick.count() === 0)
await page.evaluate(() => window.__studio.getState().select(null))
const spot2 = await emptySpot(page)
await page.mouse.dblclick(spot2.x, spot2.y)
await page.waitForTimeout(200)
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 收起、不加节点', await quick.count() === 0
&& await page.evaluate(() => window.__studio.getState().nodes.length) === n0 + 1)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- 窄画布的打开取景
console.log('=== 1024 宽打开:可读缩放下放不下三列时退到精简档,多看几列 ===')
for (const theme of ['dark', 'light']) {
const { browser, page } = await openStudio({ theme, viewport: { width: 1024, height: 768 } })
await page.waitForTimeout(500)
// 两遍真的是两套主题:不然「亮暗都看过」只是同一张图看了两次
const shown = await page.evaluate(() => ({
attr: document.documentElement.dataset.theme ?? null,
scheme: getComputedStyle(document.documentElement).colorScheme,
}))
check(`${theme}: 画出来的确实是这一套主题`, shown.attr === theme && shown.scheme.includes(theme), JSON.stringify(shown))
const view = await page.evaluate(() => {
const rf = document.querySelector('.react-flow')
const pane = rf.getBoundingClientRect()
const nodes = [...document.querySelectorAll('.react-flow__node')]
const whole = nodes.filter((n) => {
const r = n.getBoundingClientRect()
return r.left >= pane.left && r.right <= pane.right && r.top >= pane.top && r.bottom <= pane.bottom
}).length
return { zoom: Number(rf.style.getPropertyValue('--zoom')), lod: rf.dataset.lod, whole, total: nodes.length, w: Math.round(pane.width) }
})
check(`${theme}: 1024 宽打开至少看得全四张卡`, view.whole >= 4, JSON.stringify(view))
check(`${theme}: 缩放不低于精简档下限 0.4,档位是精简卡`, view.zoom >= 0.4 - 1e-3 && view.lod === 'compact', JSON.stringify(view))
await page.screenshot({ path: `/tmp/agentlab-canvas-fx-open-1024-${theme}.png` })
await browser.close()
}
// ---------------------------------------------------------------- 关掉动效
console.log('=== 系统关了动效 ===')
+760 -45
View File
@@ -13,9 +13,19 @@
// 3. 服务重启:stream.end status=interrupted 且没有待审批,这一轮收成
// 「服务重启,这一轮中断了」,给接着跑,不再转圈;
// 4. 加载中 / 加载失败 / 真的是空的 三种状态分开,加载失败时不能在空态上提问;
// 5. 已取消的轮次没有「接着跑」,续跑被拒时说人话;
// 5. 已取消的、人驳回这类原样续上还会失败的轮次没有「接着跑」,续跑被拒时说人话;
// 6. 1440×900 下长会话的输入框和发送键完整可见,整页不多滚;
// 7. 首屏例句不拿系统表造句;可信度信息刷新后还在。
// 7. 首屏例句不拿系统表造句;可信度信息刷新后还在,且写进单独的 meta,不夹在 review 里;
// 8. 从库里恢复的半路轮次:核对撞上 500 有出路、补交付不走实时计时、查到的终态落库;
// 9. 长答案没取全要记进 meta,刷新后补全;2000 字按码点数,不按 UTF-16;
// 10. 发起运行失败:报错不糊成一整行粗体,不许诺不存在的自动重试;重跑挂起的轮次
// 顺手取消旧运行;断在建图阶段按最后一次落库算有没有动静,发起运行时 meta 跟上新运行;
// 11. 左栏:没打开过的会话也标得出状态;筛选框在有筛选词时不消失;删掉的会话进回收站,
// 能预览、恢复、彻底删除(要确认);回收站里只能看,不发起任何运行,正在跑的不给
// 彻底删除(清空也跳过它);
// 12. 限定数据源:点一下只查这个库,请求里带上 datasource_ids,库不在了说人话、补救是放开范围;
// 库列表没取回来时,从上一问接过来的范围照样看得见;
// 13. 画布助手的「在现有图上改 | 从头生成」分段控件;助手出错时同一句报错只说一遍。
//
// 所有写请求都被拦在浏览器里,不碰任何库。
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
@@ -45,12 +55,17 @@ const check = (name, cond, detail = '') => {
// ---------------------------------------------------------------- 伪造的数据
const iso = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString()
const conv = (id, title, turns, minsAgo) => ({
const YESTERDAY_NOON = Math.round((Date.now() - new Date(new Date().setHours(12, 0, 0, 0) - 86_400_000).getTime()) / 60_000)
const conv = (id, title, turns, minsAgo, extra = {}) => ({
id, title, kind: 'chat', workflow_id: null, archived: false,
created_at: iso(minsAgo + 5), last_active_at: iso(minsAgo), turn_count: turns, last_question: title,
last_status: turns ? 'done' : null, last_run_id: null, ...extra,
})
const LONG = (n) => Array.from({ length: n }, (_, i) =>
`${i + 1}. 这一段是很长的结论正文,用来把会话撑到超过一屏,检查输入框会不会被挤出视口。`).join('\n')
const FULL = '这是完整答案的开头。' + '正文'.repeat(1920) + '【完整结尾】' // 3,850 字左右
// 后端按码点截在 2000:带 BMP 之外的字符时,JS 的 length 会比 2000 多
const ASTRAL_2000 = '旧'.repeat(1995) + '📊'.repeat(5)
const turn = (id, question, extra = {}) => ({
id, seq: 0, question, answer: '', explanation: '', graph: null, run_id: null,
status: 'done', error: '', review: null, created_at: iso(30), ...extra,
@@ -70,16 +85,40 @@ const CONVS = {
longC: conv('c0longc', '会话 C:长会话,检查布局', 6, 60),
slowD: conv('c0slowd', '会话 D:历史取得慢', 2, 90),
failE: conv('c0faile', '会话 E:历史取失败', 3, 120),
histF: conv('c0histf', '会话 F:取消过、失败过、不可用的历史', 4, 60 * 30),
// 「昨天」按日历算:写死 30 小时前的话,凌晨 6 点前跑会落进前天,分组检查就挂了
histF: conv('c0histf', '会话 F:取消过、失败过、不可用的历史', 4, YESTERDAY_NOON),
emptyG: conv('c0emptg', '新对话', 0, 60 * 24 * 3),
truncH: conv('c0trunc', '会话 H:长答案', 0, 60 * 24 * 10),
restI: conv('c0resti', '会话 I:跑到一半服务重启', 0, 60 * 24 * 12),
buildJ: conv('c0buildj', '会话 J:建流程就失败', 0, 60 * 24 * 13),
waitK: conv('c0waitk', '会话 K:停在审批上', 0, 60 * 24 * 14),
writeL: conv('c0writel', '会话 L:模型正在出字', 0, 60 * 24 * 15),
// 下面四个在「列表」那段里没打开过:状态只能从列表接口的 last_status 来
restI: conv('c0resti', '会话 I:跑到一半服务重启', 0, 60 * 24 * 12, { last_status: 'suspended' }),
buildJ: conv('c0buildj', '会话 J:建流程就失败', 0, 60 * 24 * 13, { last_status: 'error' }),
waitK: conv('c0waitk', '会话 K:停在审批上', 0, 60 * 24 * 14, { last_status: 'waiting', last_run_id: 'run-wait' }),
writeL: conv('c0writel', '会话 L:模型正在出字', 0, 60 * 24 * 15, { last_status: 'running' }),
metaM: conv('c0metam', '会话 M:刷新之后的可信度', 1, 60 * 24 * 16),
killN: conv('c0killn', '会话 N:服务被强杀', 0, 60 * 24 * 17),
suspO: conv('c0suspo', '会话 O:三天前被重启打断的', 1, 60 * 24 * 3),
suspO: conv('c0suspo', '会话 O:三天前被重启打断的', 1, 60 * 24 * 3, { last_status: 'suspended' }),
unkP: conv('c0unkp', '会话 P:核对时后端出错', 1, 60 * 24 * 18),
lateQ: conv('c0lateq', '会话 Q:跑完了、没来得及交付', 1, 60 * 24 * 3),
termR: conv('c0termr', '会话 R:停在半路的几轮', 5, 60 * 24 * 19),
clipS: conv('c0clips', '会话 S:长答案没取全', 0, 60 * 24 * 20),
clipT: conv('c0clipt', '会话 T:刷新之后补全长答案', 3, 60 * 24 * 21),
launchU: conv('c0launu', '会话 U:发起运行失败', 0, 60 * 24 * 22),
scopeV: conv('c0scopv', '会话 V:限定数据源', 1, 60 * 24 * 23),
scopeW: conv('c0scopw', '会话 W:上一问限定过数据源', 1, 60 * 24 * 24),
buildX: conv('c0buildx', '会话 X:两天前的问题,别处刚又跑了一遍', 2, 60 * 24 * 25),
}
/** 回收站里的:列表接口只有带 include_archived 才给 */
const TRASHED = {
trash1: conv('c0trash1', '删掉的对话甲', 3, 60 * 24 * 2, { archived: true }),
trash2: conv('c0trash2', '删掉的对话乙', 1, 60 * 24 * 40, { archived: true }),
}
/** 只在「回收站里只能看」那段出现,免得别的段数回收站里有几个时数错 */
const TRASH_MORE = {
// 各种能补救的轮次都有一个:没跑的、停在审批上的、跑挂了的、结论不可用的
trash3: conv('c0trash3', '删掉的对话丙:各种没跑完的', 4, 60 * 24 * 4, { archived: true, last_status: 'done' }),
// 删掉之后又跑起来了(比如在「记录」里通过了审批):它底下有一个真在跑的运行
trash4: conv('c0trash4', '删掉的对话丁:还在跑', 1, 60 * 24 * 5,
{ archived: true, last_status: 'running', last_run_id: 'run-trash-live' }),
}
const DETAIL = {
c0busya: [],
@@ -93,6 +132,8 @@ const DETAIL = {
turn('tf1', '被我停下的那一轮', { status: 'error', error: '已取消', graph: GRAPH, run_id: 'run-cancelled' }),
// 运行失败了:可以接着跑,但这次后端会拒绝(比如别处已经把它续跑完了)
turn('tf2', '跑挂了的那一轮', { status: 'error', error: 'KeyError: 查询超时,数据库没有在 30 秒内返回', graph: GRAPH, run_id: 'run-failed' }),
// 人工驳回:原样接着跑只会再被驳回一次(lib/explain 的 continuable=false),不给「接着跑」
turn('tf5', '被人驳回的那一轮', { status: 'error', error: '人工驳回:数字对不上', graph: GRAPH, run_id: 'run-rejected' }),
// 没有运行、没有图,却有答案:reply,没查库。老数据没有 meta,靠推断
turn('tf3', '上一轮的数字是多少', { answer: '上一轮查到的是 42。' }),
// 复核判为不可用,原因是步数用满
@@ -101,8 +142,8 @@ const DETAIL = {
review: {
verdict: 'annotated', note: 'agent 用满了 12 步还没给出结论。', answer: null, retry: true, severity: 'broken',
signals: [{ kind: 'step_limit', detail: 'agent 用满了 12 步', severity: 'broken' }],
meta: { v: 1, runId: 'run-broken', runClass: 'exploratory', runStatus: 'succeeded', queries: 3, ms: 41000 },
},
meta: { v: 1, runId: 'run-broken', runClass: 'exploratory', runStatus: 'succeeded', queries: 3, ms: 41000 },
}),
],
c0emptg: [],
@@ -115,37 +156,129 @@ const DETAIL = {
// 三天前服务重启打断、跑了 42 秒的那一轮
c0suspo: [turn('to1', '三天前没跑完的问题', {
status: 'error', error: '服务重启,这一轮中断了', graph: GRAPH, run_id: 'run-susp', created_at: iso(60 * 24 * 3),
review: { meta: { v: 1, runId: 'run-susp', runStatus: 'interrupted', outcome: 'suspended', ms: 42000 } },
meta: { v: 1, runId: 'run-susp', runStatus: 'interrupted', outcome: 'suspended', ms: 42000 },
})],
// 出具档位、运行类别、耗时、查库次数只存在 review.meta 里:刷新后得从这里读回来,
// 出具档位、运行类别、耗时、查库次数只存在 meta 里:刷新后得从这里读回来,
// 而不是再去问一遍运行
c0metam: [turn('tm1', '上个月各产线的出勤率', {
answer: '一线 96.2%,二线 91.0%。', graph: GRAPH, run_id: 'run-meta',
review: { meta: {
meta: {
v: 1, runId: 'run-meta', runClass: 'exploratory', runStatus: 'succeeded', ms: 12300, queries: 2,
issuance: { tier: 'degraded', gaps: ['叙述模板渲染为空'], matched_numbers: 2, metrics_checked: 3, unmatched_numbers: [] },
} },
},
})],
// 上次停在半路(轮次还是 running),核对时后端回 500
c0unkp: [turn('tp1', '核对时撞上后端出错的那一轮', {
status: 'running', graph: GRAPH, run_id: 'run-unk', created_at: iso(60 * 24 * 2),
})],
// 运行三天前就跑完了,交付没赶上:补交付期间不能按实时运行从三天前起算计时
c0lateq: [turn('tq1', '三天前跑完、没交付的问题', {
status: 'running', graph: GRAPH, run_id: 'run-late', created_at: iso(60 * 24 * 3),
})],
// 轮次都还是 running,运行早已各有结局:查到之后要落库,不能每次进来都重新核对
c0termr: [
turn('tr1', '在别处被取消的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-t-cancel' }),
turn('tr2', '在别处失败的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-t-failed' }),
turn('tr3', '服务重启挂起的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-t-susp' }),
turn('tr4', '运行记录被删掉的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-404-t' }),
turn('tr5', '还停在审批上的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-t-wait' }),
],
c0clips: [],
c0clipt: [
// 上次交付时 GET 运行失败,只拿到事件里截断的那份:meta 记着 partial,刷新后要补全。
// 多个键拼起来比 2000 长,光看长度是认不出来的
turn('tt1', '多个成果键的长答案', {
answer: `${FULL.slice(0, 2000)}\n附注:另一个成果键`, graph: GRAPH, run_id: 'run-multi',
meta: { v: 1, runId: 'run-multi', runStatus: 'succeeded', clipped: 'partial' },
}),
// 恰好 2000 个码点(UTF-16 是 2005)、运行也没了:补不回来,但要标出来
turn('tt2', '旧版本被截断、运行没了的长答案', { answer: ASTRAL_2000, graph: GRAPH }),
turn('tt3', '旧版本被截断、运行记录删掉了的长答案', { answer: ASTRAL_2000, graph: GRAPH, run_id: 'run-404-clip' }),
],
c0launu: [],
c0scopv: [turn('tv1', '上一问', { answer: '上一问的答案', graph: GRAPH, run_id: 'run-b1' })],
c0scopw: [turn('tw0', '上一问只查了一个库', {
answer: '上一问的答案 W', graph: GRAPH, run_id: 'run-b1',
meta: { v: 1, runId: 'run-b1', runStatus: 'succeeded', scope: [{ id: 'ds-plant', name: 'factory' }] },
})],
// 两天前问的、刚被重试过:run_id 列还指着上一次的运行(清不掉),meta.runId 是 null。
// 有没有动静按 meta.at 算——取的那一刻才定,脚本跑得再久也是「2 分钟前」「20 分钟前」
c0buildx: () => [
turn('tx7', '两分钟前在别的页面里重试的问题', {
status: 'running', graph: GRAPH, run_id: 'run-failed', created_at: iso(60 * 24 * 2),
meta: { v: 1, runId: null, at: Date.now() - 2 * 60_000 },
}),
turn('tx8', '重试之后二十分钟没动静的问题', {
status: 'running', graph: GRAPH, run_id: 'run-failed', created_at: iso(60 * 24 * 2),
meta: { v: 1, runId: null, at: Date.now() - 20 * 60_000 },
}),
],
c0trash1: [turn('tx1', '回收站里的问题', { answer: '回收站里的答案', graph: GRAPH, run_id: 'run-b1' })],
c0trash2: [],
c0trash3: [
turn('ty0', '搭好了没跑的问题', { graph: GRAPH }),
turn('ty1', '停在审批上的问题', { status: 'running', graph: GRAPH, run_id: 'run-trash-wait' }),
turn('ty2', '跑挂了的问题', {
status: 'error', error: '查询超时', graph: GRAPH, run_id: 'run-failed',
meta: { v: 1, runId: 'run-failed', runStatus: 'failed' },
}),
turn('ty3', '步数用满、结论不可用的问题', {
answer: '只查了一半的结论', graph: GRAPH, run_id: 'run-broken',
review: {
verdict: 'annotated', note: 'agent 用满了 12 步还没给出结论。', answer: null, retry: true, severity: 'broken',
signals: [{ kind: 'step_limit', detail: 'agent 用满了 12 步', severity: 'broken' }],
},
meta: { v: 1, runId: 'run-broken', runStatus: 'succeeded' },
}),
],
c0trash4: [turn('tz1', '删掉之后还在跑的问题', { status: 'running', graph: GRAPH, run_id: 'run-trash-live', created_at: iso(10) })],
}
const RUNS = {
'run-cancelled': { status: 'cancelled', output: {}, error: null },
'run-failed': { status: 'failed', output: {}, error: '查询超时' },
'run-rejected': { status: 'failed', output: {}, error: '人工驳回:数字对不上' },
'run-broken': { status: 'succeeded', output: { answer: '只查了一半的结论' }, error: null },
'run-late': { status: 'succeeded', output: { answer: '补交付的答案:三条记录。' }, error: null,
started_at: iso(60 * 24 * 3 - 1), finished_at: iso(60 * 24 * 3 - 1.5) },
'run-t-cancel': { status: 'cancelled', output: {}, error: null },
'run-t-failed': { status: 'failed', output: {}, error: '鉴权没通过(401):对方拒绝了这把密钥' },
'run-multi': { status: 'succeeded', output: { answer: FULL, note: '附注:另一个成果键' }, error: null },
'run-trash-live': { status: 'running', output: {}, error: null },
}
const FULL = '这是完整答案的开头。' + '正文'.repeat(1920) + '【完整结尾】' // 3,850 字左右
const SOURCES = [
{ id: 'ds-mig', name: 'shop', kind: 'mysql', description: '' },
{ id: 'ds-bi', name: 'bi', kind: 'oracle', description: '示例 BI 库:采购、物料、生产、设备' },
{ id: 'ds-plant', name: 'factory', kind: 'oracle', description: '示例工厂库:采购、物料、生产、设备' },
]
const TABLES = {
'ds-mig': ['__drizzle_migrations', '_migrations', 'admin_audit_log', 'app_log', 'test_sales', 'user', 'orders'],
'ds-bi': ['ANALYTICS.test_sales', 'ANALYTICS.job_etl_audit', 'ANALYTICS.v_demo_table'],
'ds-mig': ['__prisma_migrations', 'schema_migrations', 'admin_audit_log', 'app_log', 'test_sales', 'user', 'orders'],
'ds-plant': ['ANALYTICS.test_sales', 'ANALYTICS.job_etl_audit', 'ANALYTICS.v_demo_table'],
}
// ---------------------------------------------------------------- 伪造的后端
const log = { patches: [], cancels: [], continues: [], runStarts: [], archived: [], posts: [], approvalGets: [], runGets: [] }
const ctl = { slowMs: 0, failE: true, wsClosed: {}, approvals: [], thinkGo: false, writingGo: false, writingAt: 0 }
const log = {
patches: [], cancels: [], continues: [], runStarts: [], archived: [], posts: [], approvalGets: [], runGets: [],
deleted: [], gens: [],
}
const ctl = {
slowMs: 0, failE: true, wsClosed: {}, approvals: [], thinkGo: false, writingGo: false, writingAt: 0,
/** 列表只给前 8 个:筛选框刚好出现,删一个就不够 8 个了 */
few: false, run500: true, reviewDelay: {}, launchFail: true, scope400: false,
/** 回收站里多摆两个(TRASH_MORE) */
trashMore: false,
/** 数据源列表取不回来 */
sourcesFail: false,
}
/** 会话的归档 / 删除状态。各段各自 resetDb,免得上一段删掉的会话影响下一段 */
const db = { archived: new Set(), purged: new Set() }
const resetDb = () => {
db.archived = new Set([...Object.values(TRASHED), ...Object.values(TRASH_MORE)].map((c) => c.id))
db.purged = new Set()
}
resetDb()
const allConvs = () => [...Object.values(CONVS), ...Object.values(TRASHED), ...(ctl.trashMore ? Object.values(TRASH_MORE) : [])]
.filter((c) => !db.purged.has(c.id))
.map((c) => ({ ...c, archived: db.archived.has(c.id) }))
let runSeq = 0
async function fakeApi(route) {
@@ -156,18 +289,36 @@ async function fakeApi(route) {
const json = (body, status = 200) => route.fulfill({ status, json: body })
const body = () => { try { return req.postDataJSON() } catch { return {} } }
if (path === '/conversations' && method === 'GET') return json(Object.values(CONVS))
if (path === '/conversations' && method === 'GET') {
if (url.searchParams.get('kind') === 'canvas') return json([])
const rows = allConvs()
const live = rows.filter((c) => !c.archived)
const shown = ctl.few ? live.slice(0, 8) : live
return json(url.searchParams.get('include_archived') === 'true' ? [...shown, ...rows.filter((c) => c.archived)] : shown)
}
if (path === '/conversations' && method === 'POST') return json({ ...conv('c0new', '新对话', 0, 0), turns: [] }, 201)
let m = path.match(/^\/conversations\/([^/]+)$/)
if (m && method === 'GET') {
const id = m[1]
if (id === 'c0slowd') await new Promise((r) => setTimeout(r, ctl.slowMs))
if (id === 'c0faile' && ctl.failE) return json({ detail: '数据库暂时不可用' }, 500)
const known = Object.values(CONVS).find((c) => c.id === id)
const known = allConvs().find((c) => c.id === id)
if (!known && id !== 'c0new') return json({ detail: '这个对话不存在,可能已经被删了' }, 404)
return json({ ...known, turns: DETAIL[id] ?? [] })
const turns = typeof DETAIL[id] === 'function' ? DETAIL[id]() : DETAIL[id]
return json({ ...(known ?? conv('c0new', '新对话', 0, 0)), turns: turns ?? [] })
}
if (m && method === 'PATCH') {
const b = body()
log.archived.push([m[1], b])
if (b.archived === true) db.archived.add(m[1])
if (b.archived === false) db.archived.delete(m[1])
return json({ ...allConvs().find((c) => c.id === m[1]), ...b })
}
if (m && method === 'DELETE') {
log.deleted.push(m[1])
db.purged.add(m[1])
return route.fulfill({ status: 204, body: '' })
}
if (m && method === 'PATCH') { log.archived.push([m[1], body()]); return json({ ...CONVS[m[1]], ...body() }) }
m = path.match(/^\/conversations\/([^/]+)\/turns$/)
if (m && method === 'POST') {
log.posts.push(m[1])
@@ -177,7 +328,12 @@ async function fakeApi(route) {
if (m && method === 'PATCH') { log.patches.push({ conv: m[1], turn: m[2], body: body() }); return json(turn(m[2], '', body())) }
if (path === '/copilot/generate-stream' && method === 'POST') {
if (String(body().instruction ?? '').includes('建流程就失败')) {
const b = body()
log.gens.push(b)
if (ctl.scope400 && b.datasource_ids?.length) {
return json({ detail: '限定的数据源都不在了:可能已经被删掉或停用。去掉限定再问,或者到「数据」页确认它还在、而且是启用的' }, 400)
}
if (String(b.instruction ?? '').includes('建流程就失败')) {
// 后端的 error 操作是三段:人话、怎么办、原始异常
const ops = [
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 400 },
@@ -199,12 +355,16 @@ async function fakeApi(route) {
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
if (path === '/copilot/review' && method === 'POST') {
const wait = ctl.reviewDelay[body().run_id]
if (wait) await new Promise((r) => setTimeout(r, wait))
return json({ verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [] })
}
if (path === '/runs' && method === 'POST') {
const b = body()
const conversation = String(b.input?.question ?? '')
const id = conversation.includes('长答案') ? 'run-long'
// 发起请求根本没到后端(网断了、后端正在重启)
if (conversation.includes('启动失败') && ctl.launchFail) return route.abort('connectionrefused')
const id = conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long')
: conversation.includes('审批') ? 'run-wait'
: conversation.includes('重启') ? 'run-restart'
: conversation.includes('正在写') ? 'run-writing'
@@ -226,6 +386,9 @@ async function fakeApi(route) {
const id = m[1]
log.runGets.push(id)
if (id === 'run-long') return json({ id, status: 'succeeded', run_class: 'exploratory', output: { answer: FULL }, usage: {} })
if (id === 'run-noget' || (id === 'run-unk' && ctl.run500)) return json({ detail: '后端出错了,详情看服务日志' }, 500)
if (id === 'run-unk') return json({ id, status: 'cancelled', run_class: 'exploratory', output: {}, usage: {} })
if (id.startsWith('run-404')) return json({ detail: '运行不存在' }, 404)
if (RUNS[id]) return json({ id, run_class: 'exploratory', usage: {}, ...RUNS[id] })
if (id.startsWith('run-c') || id === 'run-b1') return json({ id, status: 'succeeded', run_class: 'exploratory', output: {}, usage: {} })
return json({ id, status: 'interrupted', run_class: 'exploratory', output: {}, usage: {} })
@@ -240,7 +403,9 @@ async function fakeApi(route) {
return json([ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.started', 'ag', {}),
ev(3, 'node.finished', 'ag', { duration_ms: 700 }), ev(4, 'run.finished', null, { output: {} })])
}
if (path === '/datasources' && method === 'GET') return json(SOURCES)
if (path === '/datasources' && method === 'GET') {
return ctl.sourcesFail ? json({ detail: '数据源列表暂时取不到' }, 500) : json(SOURCES)
}
m = path.match(/^\/datasources\/([^/]+)\/schema$/)
if (m) return json({ tables: TABLES[m[1]] ?? [] })
@@ -251,6 +416,17 @@ async function fakeApi(route) {
/** 每条运行的事件剧本。done=true 时剧本放完就发 stream.end */
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, data, ts: Date.now() / 1000 })
function script(runId, after = 0) {
if (runId === 'run-noget') {
// 事件里的成果被切过,交付时 GET 运行又失败了:只能先交这一份,并且要记下来
return {
events: [
ev(1, 'run.started', null, { nodes: 3 }),
ev(2, 'node.finished', 'ag', { duration_ms: 900 }),
ev(3, 'run.finished', null, { output: { answer: FULL.slice(0, 2000), note: '附注' }, output_truncated: true }),
],
end: 'succeeded',
}
}
if (runId === 'run-long') {
return {
events: [
@@ -315,7 +491,7 @@ function script(runId, after = 0) {
ev(1, 'run.started', null, { nodes: 3 }),
ev(2, 'node.started', 'ag', {}),
ev(3, 'llm.start', 'ag', { model: 'm' }),
ev(4, 'llm.thinking.delta', 'ag', { delta: '先看看 bi 里有哪些表。' }),
ev(4, 'llm.thinking.delta', 'ag', { delta: '先看看 factory 里有哪些表。' }),
ev(5, 'llm.thinking.delta', 'ag', { delta: '然后按月汇总出勤' }),
{ gate: 'thinkGo' },
ev(6, 'llm.thinking.delta', 'ag', { delta: '率,再和上个月比。' }),
@@ -414,8 +590,8 @@ for (const theme of THEMES) {
const hints = await page.locator('button[title="填进输入框,改一改再发"]').allInnerTexts()
check('有例句', hints.length > 0, hints.join(' | '))
check('例句里没有系统表',
hints.every((h) => !/__drizzle|_migrations|test_|etl_audit|_log\b/.test(h)), hints.join(' | '))
check('描述里的业务词造了句', hints.some((h) => /bi:/.test(h)), hints.join(' | '))
hints.every((h) => !/__prisma|_migrations|test_|etl_audit|_log\b/.test(h)), hints.join(' | '))
check('描述里的业务词造了句', hints.some((h) => /factory:/.test(h)), hints.join(' | '))
const ph = await page.getByRole('textbox', { name: '向数据提问' }).getAttribute('placeholder')
check('占位符简短', !!ph && ph.length <= 12, ph ?? '')
const box = await page.getByRole('textbox', { name: '向数据提问' }).boundingBox()
@@ -550,11 +726,16 @@ for (const theme of THEMES) {
check('落库的也是完整答案', saved.length > 0 && saved.every((p) => p.body.answer.length === FULL.length),
saved.map((p) => p.body.answer.length).join(','))
// 落库是排队异步发的,交付之后再等它一下
await until(() => log.patches.slice(patchesBefore).some((p) => p.body.review?.meta))
const metas = log.patches.slice(patchesBefore).map((p) => p.body.review?.meta).filter(Boolean)
await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta))
const metas = log.patches.slice(patchesBefore).map((p) => p.body.meta).filter(Boolean)
const meta = metas.at(-1)
check('可信度信息跟着落库(运行、类别、查库次数)', meta?.runId === 'run-long' && meta?.runClass === 'exploratory',
JSON.stringify(metas))
// 后端有了单独的 meta 列:review 只放复核结论,不再夹带
check('meta 单独写,不再塞进 review', log.patches.slice(patchesBefore)
.every((p) => !p.body.review || !('meta' in p.body.review)),
JSON.stringify(log.patches.slice(patchesBefore).map((p) => Object.keys(p.body.review ?? {}))))
check('完整答案取到了,meta 里没有「没取全」', !meta?.clipped, JSON.stringify(meta?.clipped))
await page.getByText('展开全部').first().click().catch(() => {})
check('完整结尾在页面上', await shows(page, '【完整结尾】'))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
@@ -569,8 +750,9 @@ for (const theme of THEMES) {
await page.waitForTimeout(300)
const t = (await chatState(page)).byConversation.c0resti.at(-1)
check('这一轮收成挂起', t.phase === 'suspended', t.phase)
const spinning = await page.locator('main .animate-spin, [role="main"] .animate-spin').count()
+ await page.locator('.animate-spin').count()
// 只数这一轮自己和它在左栏的那一行:左栏里夹具的会话 L 本来就标着运行中,它转是对的
const spinning = await page.locator('[data-turn] .animate-spin').count()
+ await page.locator('aside[aria-label="对话列表"] div.group', { hasText: '会话 I' }).locator('.animate-spin').count()
check('不再转圈', spinning === 0, `${spinning} 个转圈`)
check('给「接着跑」', await page.getByRole('button', { name: '接着跑' }).count() === 1)
check('给「重跑这一轮」', await page.getByRole('button', { name: '重跑这一轮' }).count() === 1)
@@ -627,10 +809,14 @@ for (const theme of THEMES) {
check('失败那一轮按运行状态给「接着跑」', turns[1].runStatus === 'failed'
&& await page.locator('[data-remedy="failed"]').getByRole('button', { name: '接着跑' }).count() === 1)
const failedCard = await page.locator('[data-turn="tf2"]').innerText()
check('失败原因说人话、不露 Python 类名', failedCard.includes('查询超时') && !failedCard.includes('KeyError'),
failedCard.split('\n').slice(0, 4).join(' / '))
// 说法和记录页同一份(lib/explain):它把超时归成「等待超时」,原话收进技术细节
check('失败原因说人话、不露 Python 类名', /超时/.test(failedCard) && !failedCard.includes('KeyError'),
failedCard.split('\n').slice(0, 6).join(' / '))
// 人话只翻一遍:翻好的「操作超时:查询超时…」再翻一遍,标题和原因会各写一次「操作超时」
check('报错标题和原因不重复', !failedCard.includes('操作超时:'), failedCard.split('\n').slice(2, 6).join(' / '))
const rejected = page.locator('[data-turn="tf5"] + * [data-remedy="failed"]')
check('人驳回的失败:不给「接着跑」(原样续上只会再被驳回)', await rejected.count() === 1
&& !(await rejected.getByRole('button', { name: '接着跑' }).count()), (await rejected.innerText().catch(() => '')).replace(/\n/g, ' / '))
check('没查库的老答案标出来了', await shows(page, '这一条没有查库'))
const stepBtn = page.getByRole('button', { name: /放宽步数重跑(12 → 24 步)/ })
check('步数用满给出「放宽步数重跑」并写明目标值', await stepBtn.count() === 1)
@@ -734,7 +920,7 @@ for (const theme of THEMES) {
if (reducedMotion === 'no-preference') {
const said = (text) => head.getByText(text, { exact: true }).first()
.waitFor({ timeout: 8000 }).then(() => true, () => false)
check('思考时头部写出最后一句完整的话', await said('正在思考:先看看 bi 里有哪些表'))
check('思考时头部写出最后一句完整的话', await said('正在思考:先看看 factory 里有哪些表'))
await page.waitForTimeout(400)
check('还在写的半句不上头部', !(await head.getByText('然后按月汇总出勤').count()))
await page.screenshot({ path: `${SHOTS}/thinking-${theme}.png` })
@@ -782,7 +968,8 @@ for (const theme of THEMES) {
await ctx.close()
}
if (section('list', '会话列表:分组、相对时间、完整标题、筛选')) {
if (section('list', '会话列表:分组、相对时间、完整标题、筛选、没打开过的也标状态')) {
resetDb()
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0idleb')
const aside = page.locator('aside[aria-label="对话列表"]')
@@ -793,6 +980,16 @@ for (const theme of THEMES) {
check('写相对时间', /分钟前|刚刚/.test(await rowA.innerText()), (await rowA.innerText()).replace(/\n/g, ' / '))
const tip = await aside.locator('div.group', { hasText: '会话 F' }).locator('button').first().getAttribute('title')
check('悬停能看到完整标题', !!tip && tip.startsWith('会话 F:取消过、失败过、不可用的历史'), tip ?? '')
// 这几条这次都没打开过,状态只能来自列表接口的 last_status
const rowText = async (t) => (await aside.locator('div.group', { hasText: t }).innerText()).replace(/\n/g, ' / ')
check('没打开过的:停在审批上的标「待审批」', (await rowText('会话 K')).includes('待审批'), await rowText('会话 K'))
check('没打开过的:失败的标「失败」', (await rowText('会话 J')).includes('失败'), await rowText('会话 J'))
check('没打开过的:服务重启挂起的标「已中断」', (await rowText('会话 I')).includes('已中断'), await rowText('会话 I'))
check('没打开过的:在跑的标「运行中」', (await rowText('会话 L')).includes('运行中'), await rowText('会话 L'))
check('正常完成的不标', !/待审批|失败|已中断|运行中/.test(await rowText('会话 M')), await rowText('会话 M'))
const rowL = aside.locator('div.group', { hasText: '会话 L' })
await rowL.hover()
check('列表说它在跑的,也不能删', await rowL.getByRole('button', { name: /删除/ }).isDisabled())
const box = aside.getByRole('searchbox', { name: '按标题或问过的话找对话' })
await box.fill('长会话')
const hits = await aside.locator('div.group').allInnerTexts()
@@ -802,24 +999,69 @@ for (const theme of THEMES) {
check('Esc 清掉筛选', (await aside.locator('div.group').count()) === Object.keys(CONVS).length)
await box.fill('没有这样的对话')
check('没有匹配时说一声', await aside.getByText('没有标题或问题里带「没有这样的对话」的对话').count() === 1)
await box.fill('')
await page.screenshot({ path: `${SHOTS}/list-status-${theme}.png` })
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
if (section('delete', '删除:确认,且可以撤销')) {
if (section('filter', '筛选框:有筛选词时不跟着列表变短一起消失')) {
resetDb()
ctl.few = true
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0busya')
const aside = page.locator('aside[aria-label="对话列表"]')
const box = aside.getByRole('searchbox', { name: '按标题或问过的话找对话' })
check('8 个对话时有筛选框', await box.waitFor({ timeout: 6000 }).then(() => true, () => false))
await box.fill('会话 B')
const row = aside.locator('div.group', { hasText: '会话 B' })
await row.hover()
await row.getByRole('button', { name: /删除/ }).click()
// 老版本删除前还要确认一次
if (await page.getByRole('dialog').waitFor({ timeout: 800 }).then(() => true, () => false)) {
await page.getByRole('dialog').getByRole('button', { name: '删除' }).click()
}
await until(() => db.archived.has('c0idleb'))
await page.waitForTimeout(300)
check('删到 7 个:筛选词还在,筛选框也还在', await box.isVisible().catch(() => false)
&& (await box.inputValue().catch(() => '')) === '会话 B')
await page.screenshot({ path: `${SHOTS}/filter-shrunk-${theme}.png` })
// 老版本框已经没了,清不掉:不在这里卡 30 秒,下面几条照样判红
await box.fill('', { timeout: 2000 }).catch(() => {})
await page.waitForTimeout(200)
check('清掉筛选词:剩下 7 个都回来了', (await aside.locator('div.group').count()) === 7,
String(await aside.locator('div.group').count()))
check('没有筛选词、不够 8 个时,筛选框收起', !(await box.isVisible().catch(() => false)))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
ctl.few = false
resetDb()
await ctx.close()
}
if (section('delete', '删除:先进回收站,可以撤销')) {
resetDb()
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0idleb')
await shows(page, 'B 的答案')
const aside = page.locator('aside')
const trashBtn = aside.getByRole('button', { name: /^打开回收站/ })
check('回收站入口写着里面有几个', /2/.test(await trashBtn.innerText().catch(() => '')),
await trashBtn.innerText().catch(() => '没有入口'))
const row = page.locator('aside[aria-label="对话列表"] div.group', { hasText: '会话 B' })
await row.hover()
await row.getByRole('button', { name: /删除/ }).click()
check('删除前先确认', await page.getByRole('dialog').waitFor({ timeout: 3000 }).then(() => true, () => false))
await page.getByRole('dialog').getByRole('button', { name: '删除' }).click()
// 回收站能找回来:删除不再先弹一次确认,撤销和回收站就是后悔药
const asked = await page.getByRole('dialog').waitFor({ timeout: 800 }).then(() => true, () => false)
check('删除不再先弹确认', !asked)
if (asked) await page.getByRole('dialog').getByRole('button', { name: '删除' }).click()
await until(() => log.archived.some(([id, b]) => id === 'c0idleb' && b.archived === true))
check('删除是归档,不是真删', log.archived.some(([id, b]) => id === 'c0idleb' && b.archived === true))
check('给了撤销', await page.getByRole('button', { name: '撤销' }).first()
.waitFor({ timeout: 4000 }).then(() => true, () => false))
await page.getByRole('button', { name: '撤销' }).first().click()
check('删除是归档,不是真删', log.archived.some(([id, b]) => id === 'c0idleb' && b.archived === true)
&& !log.deleted.includes('c0idleb'))
const undo = page.getByRole('button', { name: '撤销' }).first()
check('给了撤销', await undo.waitFor({ timeout: 4000 }).then(() => true, () => false))
check('提示里说去了回收站', await page.getByText(/已移到回收站/).count() > 0)
check('回收站的数跟着变成 3', /3/.test(await trashBtn.innerText().catch(() => '')), await trashBtn.innerText().catch(() => ''))
await undo.click().catch(() => {})
await until(() => log.archived.some(([id, b]) => id === 'c0idleb' && b.archived === false))
await page.waitForTimeout(200)
check('撤销放回去了', log.archived.some(([id, b]) => id === 'c0idleb' && b.archived === false)
@@ -827,6 +1069,479 @@ for (const theme of THEMES) {
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
if (section('trash', '回收站:预览、恢复、彻底删除(要确认)')) {
resetDb()
const deletedBefore = log.deleted.length
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0idleb')
await shows(page, 'B 的答案')
await page.locator('aside').getByRole('button', { name: /^打开回收站/ }).click()
const bin = page.locator('aside[aria-label="回收站"]')
check('打开回收站', await bin.waitFor({ timeout: 3000 }).then(() => true, () => false))
const rows = bin.locator('div.group')
check('列出回收站里的对话', (await rows.count()) === 2, (await rows.allInnerTexts()).map((t) => t.split('\n')[0]).join(' | '))
check('顶行和页头一样高(48px)', await bin.evaluate((el) => Math.round(el.firstElementChild.getBoundingClientRect().height)) === 48)
await page.screenshot({ path: `${SHOTS}/trash-${theme}.png` })
// 点开看一眼:内容照常显示,但不能接着问
await rows.filter({ hasText: '删掉的对话甲' }).locator('button').first().click()
await page.waitForURL(/\/chat\/c0trash1$/, { timeout: 5000 }).catch(() => {})
check('能打开回收站里的对话看内容', await shows(page, '回收站里的答案'))
check('说清它在回收站里', await shows(page, '这个对话在回收站里'))
await page.getByRole('textbox', { name: '向数据提问' }).fill('回收站里还能问吗')
check('在回收站里不能接着问', await page.getByRole('button', { name: '发送' }).isDisabled())
await page.waitForTimeout(800) // 轮次有入场动画,等它落定再截
await page.screenshot({ path: `${SHOTS}/trash-preview-${theme}.png` })
await page.locator('[data-trash-banner]').getByRole('button', { name: '恢复' }).click()
await until(() => log.archived.some(([id, b]) => id === 'c0trash1' && b.archived === false))
await page.waitForTimeout(300)
check('恢复:放回列表,提示条收起,可以接着问', !(await page.locator('[data-trash-banner]').count())
&& !(await page.getByRole('button', { name: '发送' }).isDisabled()))
check('恢复之后回到对话列表,它在里面', await page.locator('aside[aria-label="对话列表"]')
.getByText('删掉的对话甲').count() > 0)
// 彻底删除:要确认,确认后才发 DELETE
await page.locator('aside').getByRole('button', { name: /^打开回收站/ }).click()
const left = bin.locator('div.group', { hasText: '删掉的对话乙' })
await left.hover()
await left.getByRole('button', { name: /彻底删除/ }).click()
const dialog = page.getByRole('dialog')
check('彻底删除前先确认', await dialog.waitFor({ timeout: 3000 }).then(() => true, () => false))
check('确认框说清找不回来', (await dialog.innerText().catch(() => '')).includes('找不回来'))
await page.waitForTimeout(300)
await page.screenshot({ path: `${SHOTS}/trash-purge-${theme}.png` })
await dialog.getByRole('button', { name: '彻底删除' }).click()
await until(() => log.deleted.length > deletedBefore)
check('确认后才真删', log.deleted.slice(deletedBefore).join() === 'c0trash2', log.deleted.slice(deletedBefore).join())
check('回收站空了就说一声', await shows(page, '回收站是空的'))
await bin.getByRole('button', { name: /返回/ }).click()
check('返回对话列表', await page.locator('aside[aria-label="对话列表"]').waitFor({ timeout: 3000 }).then(() => true, () => false))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
resetDb()
await ctx.close()
}
if (section('readonly', '回收站里只能看:不发起运行,正在跑的不给彻底删除')) {
resetDb()
ctl.trashMore = true
ctl.approvals = [{ id: 'ap-bin', run_id: 'run-trash-wait', node_id: 'ag', mode: 'approve', title: '确认一下再查',
payload: {}, status: 'pending', response: {}, created_at: new Date().toISOString() }]
const { page, ctx, errors } = await open(theme)
const counts = () => ({ starts: log.runStarts.length, continues: log.continues.length, gens: log.gens.length, posts: log.posts.length })
const before = counts()
await goto(page, 'c0trash3')
await page.locator('[data-trash-banner]').waitFor({ timeout: 6000 }).catch(() => {})
// 停在审批上的那一轮要核对完、成了「等待审批」,四种补救才都摆出来
await page.waitForFunction(() => window.__chat.getState().byConversation.c0trash3?.find((t) => t.id === 'ty1')?.phase === 'waiting',
null, { timeout: 6000 }).catch(() => {})
await page.waitForTimeout(400)
const STARTERS = /接着跑|重试这一轮|重跑这一轮|重新问一次|不限数据源重试|放宽步数|跑一下/
// 补救动作排在轮次卡片外面(AssistantStream 的动作槽),整页找
const starters = page.locator('button', { hasText: STARTERS })
const labels = await starters.evaluateAll((bs) => bs.map((b) => `${b.textContent.trim()}${b.disabled ? '' : '(可点)'}`))
const has = (re) => labels.some((l) => re.test(l))
check('会发起运行的按钮照样摆着(恢复之后能做什么看得到)',
has(/跑一下/) && has(/接着跑/) && has(/重新问一次|重试这一轮/) && has(/放宽步数/), labels.join(' | '))
check('……但一个都点不了', labels.length > 0 && labels.every((l) => !l.endsWith('(可点)')), labels.join(' | '))
const why = await starters.evaluateAll((bs) => [...new Set(bs.map((b) => b.title))])
check('按钮上说清为什么点不了', why.length === 1 && /回收站/.test(why[0]), why.join(' | '))
check('换个说法照常能用(它不发起运行)',
await page.locator('[data-remedy]').getByRole('button', { name: '换个说法' }).first().isEnabled().catch(() => false))
check('看执行过程照常能用', await page.getByRole('button', { name: '看执行过程' }).first().isEnabled().catch(() => false))
check('停在审批上的:不摆能点「通过」的审批卡,说清恢复之后再处理',
!(await page.getByRole('button', { name: '通过' }).count())
&& (await page.locator('[data-pending-approval]').innerText().catch(() => '')).includes('恢复这个对话之后'))
// 按钮之外再绕一道:绕过禁用直接点、直接调 store,也一次运行都发不出去
await starters.evaluateAll((bs) => bs.forEach((b) => b.click()))
await page.evaluate(() => {
const s = window.__chat.getState()
s.retryTurn('c0trash3', 'ty3')
s.retryTurn('c0trash3', 'ty2', { rerun: true })
s.runNow('c0trash3', 'ty0')
void s.continueTurn('c0trash3', 'ty2')
s.ask('c0trash3', '回收站里偷偷问一句')
})
await page.waitForTimeout(900)
const after = counts()
check('一次运行都没发起:不建图、不开轮、不 POST /runs、不接着跑',
JSON.stringify(after) === JSON.stringify(before), `${JSON.stringify(before)} → ${JSON.stringify(after)}`)
await page.locator('[data-turn="ty2"]').scrollIntoViewIfNeeded().catch(() => {})
await page.waitForTimeout(300)
await page.screenshot({ path: `${SHOTS}/trash-readonly-${theme}.png` })
// 删掉之后又跑起来的:和列表里一样不给彻底删除
await page.locator('aside').getByRole('button', { name: /^打开回收站/ }).click()
const bin = page.locator('aside[aria-label="回收站"]')
const liveRow = bin.locator('div.group', { hasText: '删掉的对话丁' })
await liveRow.waitFor({ timeout: 3000 }).catch(() => {})
check('回收站里标出它在运行', (await liveRow.innerText().catch(() => '')).includes('运行中'),
(await liveRow.innerText().catch(() => '')).replace(/\n/g, ' / '))
await liveRow.hover()
const rowPurge = liveRow.getByRole('button', { name: /彻底删除/ })
check('正在跑的:行上的彻底删除点不了,说清为什么', await rowPurge.isDisabled().catch(() => false)
&& (await rowPurge.getAttribute('title')) === '这个对话正在运行,先停下来再删', await rowPurge.getAttribute('title').catch(() => ''))
await liveRow.locator('button').first().click()
const live = await page.waitForFunction(() => window.__chat.getState().byConversation.c0trash4?.[0]?.phase === 'running',
null, { timeout: 6000 }).then(() => true, () => false)
check('打开它:核对发现运行还活着,接了回去', live, (await chatState(page)).byConversation.c0trash4?.[0]?.phase)
const bannerPurge = page.locator('[data-trash-banner]').getByRole('button', { name: '彻底删除' })
check('提示条上的彻底删除也点不了', await bannerPurge.isDisabled().catch(() => false))
const stop = page.getByRole('button', { name: '停止这一轮' })
check('在回收站里也停得下来', await stop.count() === 1)
await page.waitForTimeout(300)
await page.screenshot({ path: `${SHOTS}/trash-running-${theme}.png` })
// 清空:只删没在跑的;在跑的留下,确认框里先说
const deletedBefore = log.deleted.length
await bin.getByRole('button', { name: '清空回收站' }).click()
const dialog = page.getByRole('dialog')
await dialog.waitFor({ timeout: 3000 }).catch(() => {})
const said = await dialog.innerText().catch(() => '')
check('清空前说清在跑的这次不删', said.includes('删掉的对话丁') && said.includes('正在运行'), said.replace(/\n/g, ' / '))
await dialog.getByRole('button', { name: '全部删除' }).click().catch(() => {})
await until(() => log.deleted.length - deletedBefore >= 3, 4000)
await page.waitForTimeout(300)
const gone = log.deleted.slice(deletedBefore)
check('清空:没在跑的都删了,在跑的没删', ['c0trash1', 'c0trash2', 'c0trash3'].every((id) => gone.includes(id))
&& !gone.includes('c0trash4'), gone.join(','))
check('在跑的那条事件流没被关掉、运行没被丢下', ctl.wsClosed['run-trash-live'] === false
&& (await chatState(page)).byConversation.c0trash4?.[0]?.phase === 'running')
check('它还在回收站里', await bin.locator('div.group', { hasText: '删掉的对话丁' }).count() === 1)
// 停下来之后就能删了
await stop.click().catch(() => {})
await until(() => log.cancels.includes('run-trash-live'), 3000)
await page.waitForTimeout(200)
check('停下之后:彻底删除可以点了', await bannerPurge.isEnabled().catch(() => false))
await bannerPurge.click().catch(() => {})
await dialog.waitFor({ timeout: 3000 }).catch(() => {})
await dialog.getByRole('button', { name: '彻底删除' }).click().catch(() => {})
await until(() => log.deleted.slice(deletedBefore).includes('c0trash4'), 3000)
check('确认之后删掉了', log.deleted.slice(deletedBefore).includes('c0trash4'))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
ctl.trashMore = false
ctl.approvals = []
resetDb()
await ctx.close()
}
if (section('recheck', '恢复的半路轮次:核对撞上 500 有出路、补交付不计实时、终态落库')) {
const { page, ctx, errors } = await open(theme)
ctl.run500 = true
await goto(page, 'c0unkp')
// 先等这一轮取回来:取回来之前「不在核对」是空洞地成立的
const settled = await page.waitForFunction(() => {
const t = window.__chat.getState().byConversation.c0unkp?.[0]
return !!t && t.hydrated !== 'pending' && t.phase !== 'checking'
}, null, { timeout: 5000 }).then(() => true, () => false)
check('核对撞上 500:不再一直停在「正在核对」', settled, (await chatState(page)).byConversation.c0unkp?.[0]?.phase)
check('说清没核对上', await shows(page, '没核对上这一轮现在的状态'))
const recheck = page.locator('[data-remedy]').getByRole('button', { name: '重新核对' })
check('给「重新核对」', await recheck.count() === 1)
check('不给会另起运行的按钮', !(await page.getByRole('button', { name: /重试这一轮|重跑这一轮|接着跑/ }).count()))
check('这时不转圈', !(await page.locator('[data-turn="tp1"] .animate-spin').count()))
await page.screenshot({ path: `${SHOTS}/recheck-failed-${theme}.png` })
ctl.run500 = false
await recheck.click().catch(() => {})
check('重新核对:按运行的真实状态收尾', await page.waitForFunction(
() => window.__chat.getState().byConversation.c0unkp?.[0]?.phase === 'cancelled', null, { timeout: 5000 })
.then(() => true, () => false), (await chatState(page)).byConversation.c0unkp?.[0]?.phase)
ctl.run500 = true
// 三天前跑完、交付没赶上:补交付(等复核)期间不按实时运行计时
ctl.reviewDelay['run-late'] = 1800
await goto(page, 'c0lateq')
await page.waitForFunction(() => window.__chat.getState().byConversation.c0lateq?.[0]?.hydrated === 'done', null, { timeout: 5000 }).catch(() => {})
await page.waitForTimeout(300)
const mid = (await chatState(page)).byConversation.c0lateq?.[0]
check('补交付期间不算「运行中」', mid?.phase !== 'running' && mid?.phase !== 'done', mid?.phase)
const clock = await page.locator('[data-turn="tq1"] [title="已运行"]').allInnerTexts()
check('补交付期间头部不走实时计时(不会从三天前算起)', clock.length === 0, clock.join(','))
await page.screenshot({ path: `${SHOTS}/late-delivery-${theme}.png` })
check('补交付完成', await shows(page, '补交付的答案:三条记录。', 5000))
delete ctl.reviewDelay['run-late']
// 停在半路的几轮:各自的终态查到之后要落库,下次进来不再核对
ctl.approvals = [{ id: 'ap-t', run_id: 'run-t-wait', node_id: 'ag', mode: 'approve', title: '确认一下',
payload: {}, status: 'pending', response: {}, created_at: new Date().toISOString() }]
const patchesBefore = log.patches.length
await goto(page, 'c0termr')
await until(() => ['tr1', 'tr2', 'tr3', 'tr4'].every((id) =>
log.patches.slice(patchesBefore).some((p) => p.turn === id && p.body.status === 'error')), 6000)
await page.waitForTimeout(300)
const saved = (id) => log.patches.slice(patchesBefore).filter((p) => p.turn === id).at(-1)?.body
check('在别处取消的:落库成已取消', saved('tr1')?.status === 'error' && saved('tr1')?.error === '已取消'
&& saved('tr1')?.meta?.outcome === 'cancelled', JSON.stringify(saved('tr1')))
check('在别处失败的:落库成失败,原因跟着写', saved('tr2')?.status === 'error' && /鉴权/.test(saved('tr2')?.error ?? ''),
JSON.stringify(saved('tr2'))?.slice(0, 160))
check('服务重启挂起的:落库成中断', saved('tr3')?.status === 'error' && saved('tr3')?.meta?.outcome === 'suspended',
JSON.stringify(saved('tr3'))?.slice(0, 160))
check('运行被删掉的:落库成「上次没有跑完」', saved('tr4')?.status === 'error' && saved('tr4')?.error === '上次没有跑完',
JSON.stringify(saved('tr4'))?.slice(0, 160))
check('还在等审批的不落库(它还没结束)', !saved('tr5'), JSON.stringify(saved('tr5')))
ctl.approvals = []
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
if (section('clipped', '长答案没取全:记进 meta、刷新后补全,2000 字按码点数')) {
const { page, ctx, errors } = await open(theme)
const patchesBefore = log.patches.length
await goto(page, 'c0clips')
await send(page, '一份取不到运行的长答案')
await page.waitForFunction(() => window.__chat.getState().byConversation.c0clips?.at(-1)?.phase === 'done', null, { timeout: 10000 }).catch(() => {})
const t = (await chatState(page)).byConversation.c0clips.at(-1)
check('GET 运行失败:先交截断的那份并标出来', t.clipped === 'partial', t.clipped)
await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta?.clipped === 'partial'))
check('「没取全」写进 meta,刷新后还认得出来', log.patches.slice(patchesBefore).some((p) => p.body.meta?.clipped === 'partial'),
JSON.stringify(log.patches.slice(patchesBefore).map((p) => p.body.meta?.clipped)))
const runGetsBefore = log.runGets.length
await goto(page, 'c0clipt')
await shows(page, '多个成果键的长答案')
await until(() => log.runGets.slice(runGetsBefore).includes('run-multi'), 4000)
check('meta 说没取全:一进来就去补,不等滚进视口', log.runGets.slice(runGetsBefore).includes('run-multi'),
log.runGets.slice(runGetsBefore).join(','))
await page.waitForFunction(() => String(window.__chat.getState().byConversation.c0clipt?.[0]?.output?.answer ?? '').includes('【完整结尾】'),
null, { timeout: 4000 }).catch(() => {})
const tt1 = (await chatState(page)).byConversation.c0clipt[0]
check('多个成果键的长答案补全了', String(tt1.output?.answer ?? '').includes('【完整结尾】') && !tt1.clipped,
`${String(tt1.output?.answer ?? '').length} 字 · ${tt1.clipped}`)
await page.waitForTimeout(300)
const lost = await page.locator('[data-remedy]', { hasText: '截在了 2000 字' }).count()
check('恰好 2000 个码点(UTF-16 不是 2000)也认得出被截断、补不回来', lost === 2, `${lost} 处`)
await page.locator('[data-turn="tt2"]').scrollIntoViewIfNeeded().catch(() => {})
await page.screenshot({ path: `${SHOTS}/clipped-${theme}.png` })
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
if (section('launch', '发起运行失败:报错分得清标题和原因,补救说真话')) {
const { page, ctx, errors } = await open(theme)
ctl.launchFail = true
await goto(page, 'c0launu')
await send(page, '一个启动失败的问题')
const alert = page.locator('[data-turn] [role="alert"]').first()
await alert.waitFor({ timeout: 8000 }).catch(() => {})
const title = await alert.locator('.font-medium').first().innerText().catch(() => '')
check('标题只是「连不上后端服务」,原因不糊进粗体', title === '连不上后端服务', title)
const text = await alert.innerText().catch(() => '')
check('不许诺页面上不存在的自动重试', !text.includes('自动重试'), text.replace(/\n/g, ' / '))
const rerun = page.locator('[data-remedy="failed"]').getByRole('button', { name: '重跑这一轮' })
check('流程已经搭好:给「重跑这一轮」,不让它重新搭', await rerun.count() === 1)
check('说出怎么办:连上之后点「重跑这一轮」', text.includes('重跑这一轮'), text.replace(/\n/g, ' / '))
await page.screenshot({ path: `${SHOTS}/launch-failed-${theme}.png` })
ctl.launchFail = false
const gensBefore = log.gens.length
const startsBefore = log.runStarts.length
await rerun.click().catch(() => {})
await until(() => log.runStarts.length > startsBefore, 5000)
check('重跑:原图直接发起,不再去问 Copilot', log.runStarts.length > startsBefore && log.gens.length === gensBefore,
`${log.runStarts.length - startsBefore} 次发起 · ${log.gens.length - gensBefore} 次建图`)
await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {})
ctl.launchFail = true
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
if (section('rerun', '重跑服务重启挂起的那一轮:旧运行顺手取消')) {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0suspo')
await shows(page, '服务重启,这一轮中断了')
const before = log.cancels.length
const startsBefore = log.runStarts.length
await page.locator('[data-remedy="suspended"]').getByRole('button', { name: '重跑这一轮' }).click()
await until(() => log.runStarts.length > startsBefore, 5000)
await until(() => log.cancels.slice(before).includes('run-susp'), 2000)
check('挂起的旧运行被取消,不会一直挂在记录里', log.cancels.slice(before).includes('run-susp'), log.cancels.slice(before).join(','))
check('新的一次照常发起', log.runStarts.length > startsBefore)
await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {})
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
if (section('stale', '断在建图阶段:按最后一次落库算有没有动静;发起运行时 meta 跟上新运行')) {
const { page, ctx, errors } = await open(theme)
const patchesBefore = log.patches.length
await goto(page, 'c0buildx')
await shows(page, '重试之后二十分钟没动静的问题')
await until(() => log.patches.slice(patchesBefore).some((p) => p.turn === 'tx8'), 3000)
await page.waitForTimeout(400)
const mine = () => log.patches.slice(patchesBefore)
check('两天前问的、两分钟前刚重试过:不当成断了写回 error',
!mine().some((p) => p.turn === 'tx7' && p.body.status === 'error'), JSON.stringify(mine().filter((p) => p.turn === 'tx7')))
check('重试之后二十分钟没动静的:认定断了,写回 error',
mine().some((p) => p.turn === 'tx8' && p.body.status === 'error'), JSON.stringify(mine().filter((p) => p.turn === 'tx8')).slice(0, 200))
// 重试一次:搭好图时连 meta 一起写(有动静的凭据),发起运行时 meta.runId 换成新运行
const startsBefore = log.runStarts.length
const retryAt = log.patches.length
await page.locator('[data-remedy="failed"]').getByRole('button', { name: '重试这一轮' }).click()
await until(() => log.runStarts.length > startsBefore, 6000)
const newRun = log.runStarts.at(-1)?.id
await until(() => log.patches.slice(retryAt).some((p) => p.turn === 'tx8' && p.body.run_id === newRun), 3000)
const ps = log.patches.slice(retryAt).filter((p) => p.turn === 'tx8').map((p) => p.body)
const graphSave = ps.find((b) => b.graph)
check('搭好图时连 meta 一起写,带上此刻', !!graphSave?.meta && Math.abs(Date.now() - (graphSave.meta.at ?? 0)) < 30_000,
JSON.stringify(graphSave?.meta ?? null)?.slice(0, 120))
const started = ps.find((b) => b.run_id === newRun)
check('发起运行:run_id 列和 meta.runId 一起换成新运行,状态是 running',
!!newRun && started?.meta?.runId === newRun && started?.status === 'running', JSON.stringify(started ?? null)?.slice(0, 160))
await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {})
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
if (section('scope', '限定数据源:点一下只查这个库')) {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0emptg')
await shows(page, '问你的数据')
const chip = page.locator('[data-scope-chip="ds-plant"]')
check('首屏的数据源标签可以点', await chip.waitFor({ timeout: 5000 }).then(() => true, () => false))
check('默认不限', (await chip.getAttribute('aria-pressed')) === 'false')
await chip.click()
check('点一下就限定成它', (await chip.getAttribute('aria-pressed')) === 'true')
await page.waitForTimeout(300) // 颜色有过渡,等它换完再截
await page.screenshot({ path: `${SHOTS}/scope-hero-${theme}.png` })
const gensBefore = log.gens.length
await send(page, '只查这个库的问题')
await until(() => log.gens.length > gensBefore, 5000)
check('请求里带上 datasource_ids', JSON.stringify(log.gens.at(-1)?.datasource_ids) === '["ds-plant"]',
JSON.stringify(log.gens.at(-1)?.datasource_ids))
check('这一轮上写着只查了哪个库', await shows(page, '只查 factory'))
await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {})
await page.waitForTimeout(200)
// 在对话里:输入框左边的范围选择
await goto(page, 'c0scopv')
await shows(page, '上一问的答案')
const picker = page.getByRole('button', { name: /数据源范围/ })
check('输入框旁有数据源范围', await picker.count() === 1)
check('默认写「全部数据源」', (await picker.innerText().catch(() => '')).includes('全部数据源'), await picker.innerText().catch(() => ''))
await picker.click()
await page.getByRole('checkbox', { name: 'shop' }).check()
check('选了之后按钮写「只查 shop」', (await picker.innerText().catch(() => '')).includes('只查 shop'), await picker.innerText().catch(() => ''))
await page.screenshot({ path: `${SHOTS}/scope-picker-${theme}.png` })
await page.keyboard.press('Escape')
const before2 = log.gens.length
await send(page, '在对话里限定的问题')
await until(() => log.gens.length > before2, 5000)
check('对话里限定的也带上', JSON.stringify(log.gens.at(-1)?.datasource_ids) === '["ds-mig"]',
JSON.stringify(log.gens.at(-1)?.datasource_ids))
await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {})
await page.waitForTimeout(200)
// 限定的库被删了、停用了:后端 400,把原因原样说出来
ctl.scope400 = true
await send(page, '限定的库已经不在了')
check('库不在了:说清原因', await shows(page, '限定的数据源都不在了'))
await page.screenshot({ path: `${SHOTS}/scope-400-${theme}.png` })
// 带着原范围的「重试这一轮」只会再撞一次 400:补救得是放开范围
const loose = page.locator('[data-remedy="failed"]').getByRole('button', { name: '不限数据源重试' })
check('库不在了:补救是「不限数据源重试」', await loose.count() === 1)
const before3 = log.gens.length
await loose.click().catch(() => {})
await until(() => log.gens.length > before3, 5000)
check('不限数据源重试:请求里不再带 datasource_ids', log.gens.length > before3 && !log.gens.at(-1)?.datasource_ids?.length,
JSON.stringify(log.gens.at(-1)?.datasource_ids))
check('输入框旁的范围也一起放开', (await picker.innerText().catch(() => '')).includes('全部数据源'),
await picker.innerText().catch(() => ''))
await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {})
ctl.scope400 = false
// 库列表没取回来:从上一问接过来的范围照样会发出去,所以照样得摆在眼前
ctl.sourcesFail = true
await goto(page, 'c0scopw')
await shows(page, '上一问的答案 W')
const picker2 = page.getByRole('button', { name: /数据源范围/ })
await picker2.waitFor({ timeout: 3000 }).catch(() => {})
check('库列表没取回来:范围照样摆在输入框旁', (await picker2.innerText().catch(() => '')).includes('只查 factory'),
await picker2.innerText().catch(() => '没有范围按钮'))
await picker2.click().catch(() => {})
const stray = page.locator('[data-scope-stray]')
check('范围里的库照样列出来,注明没取到状态', (await stray.innerText().catch(() => '')).includes('没取到状态')
&& await stray.getByRole('checkbox').isChecked().catch(() => false), await stray.innerText().catch(() => ''))
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/scope-unconfirmed-${theme}.png` })
await stray.getByRole('checkbox').uncheck().catch(() => {})
check('取消勾选之后它还留在原处,按钮写回「全部数据源」', await stray.count() === 1
&& (await picker2.innerText().catch(() => '')).includes('全部数据源'), await picker2.innerText().catch(() => ''))
await stray.getByRole('checkbox').check().catch(() => {})
await page.keyboard.press('Escape')
const before4 = log.gens.length
await send(page, '库列表没取回来时的问题')
await until(() => log.gens.length > before4, 5000)
check('发出去的范围和看得见的一致', JSON.stringify(log.gens.at(-1)?.datasource_ids) === '["ds-plant"]',
JSON.stringify(log.gens.at(-1)?.datasource_ids))
await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {})
ctl.sourcesFail = false
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
if (section('composer', '画布助手:「在现有图上改 | 从头生成」分段控件')) {
const { page, ctx, errors } = await open(theme)
const WF = {
id: 'wf-chat-check', name: '__chat_check_composer__', description: '检查脚本伪造的工作流', graph: GRAPH,
tags: [], version: 1, is_template: false, status: 'draft', published_version: null, published_by: null,
run_count: 0, created_at: iso(60), updated_at: iso(60),
}
await page.route(/\/api\/workflows(\?.*)?$/, (route) => (route.request().method() === 'GET'
? route.fulfill({ json: [WF] }) : route.fallback()))
await page.route(/\/api\/workflows\/wf-chat-check(\/.*)?(\?.*)?$/, (route) => {
const url = new URL(route.request().url())
if (route.request().method() !== 'GET') return route.abort()
if (url.pathname.endsWith('/versions')) return route.fulfill({ json: [] })
return route.fulfill({ json: WF })
})
await page.goto(`${WEB}/studio/wf-chat-check`)
await page.waitForFunction(() => window.__studio?.getState().workflow?.id === 'wf-chat-check', null, { timeout: 15000 })
.catch(() => {})
const group = page.getByRole('radiogroup', { name: '生成方式' })
check('有「生成方式」分段控件', await group.waitFor({ timeout: 6000 }).then(() => true, () => false))
const base = group.getByRole('radio', { name: '在现有图上改' })
const fresh = group.getByRole('radio', { name: '从头生成' })
check('默认「在现有图上改」', (await base.getAttribute('aria-checked')) === 'true'
&& (await fresh.getAttribute('aria-checked')) === 'false')
const size = await fresh.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)).catch(() => 0)
check('字不小于 11px', size >= 11, `${size}px`)
await base.focus()
await page.keyboard.press('ArrowRight')
check('方向键切到「从头生成」', (await fresh.getAttribute('aria-checked')) === 'true'
&& await fresh.evaluate((el) => el === document.activeElement))
await page.waitForTimeout(300) // 底色有过渡,等它换完再截
await page.screenshot({ path: `${SHOTS}/composer-fresh-${theme}.png` })
const gensBefore = log.gens.length
const box = page.getByRole('textbox', { name: '告诉助手要画什么流程' })
await box.fill('重新画一张三步的图')
await box.press('Enter')
await until(() => log.gens.length > gensBefore, 5000)
check('从头生成:不带现有的图', log.gens.at(-1)?.base_graph === null, JSON.stringify(log.gens.at(-1)?.base_graph)?.slice(0, 60))
await page.waitForFunction(() => !window.__studio.getState().copilot.active, null, { timeout: 8000 }).catch(() => {})
await base.click()
await box.fill('在最后加一步人工审批')
await box.press('Enter')
await until(() => log.gens.length > gensBefore + 1, 5000)
check('在现有图上改:带上现有的图', !!log.gens.at(-1)?.base_graph?.nodes?.length)
// 助手出错:出错的那一轮自己有报错块(怎么办、原文都在),输入框上面不再重复一条
await page.waitForFunction(() => !window.__studio.getState().copilot.active, null, { timeout: 8000 }).catch(() => {})
await box.fill('这一句会让建流程就失败')
await box.press('Enter')
const card = page.locator('[data-turn-error]', { hasText: '模型交回的工作流结构不对' })
const shown = await card.first().waitFor({ timeout: 6000 }).then(() => true, () => false)
const said = await card.first().innerText().catch(() => '')
check('助手出错:那一轮写出发生了什么和怎么办', shown && said.includes('再试一次,或者把需求说得更具体些'),
said.replace(/\n/g, ' / '))
check('同一句报错只说一遍(输入框上面不再摆一条)', await card.count() === 1
&& await page.locator('[data-copilot-error]').count() === 0,
`${await card.count()} 块 · 错误条 ${await page.locator('[data-copilot-error]').count()} 条`)
await page.waitForTimeout(300)
await page.screenshot({ path: `${SHOTS}/composer-error-${theme}.png` })
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
}
await browser.close()
+182
View File
@@ -592,6 +592,14 @@ console.log('\n=== 长运行:按轮折叠(runs-5)===')
check('相邻重复行并成一行', rows.length === 2, rows.map((r) => r.title).join(' | '))
check('并后的行写出次数、中位和最慢', rows[0].meta === '×3 · 中位 12 ms · 最慢 63 ms', rows[0].meta)
check('警告并完还是警告', rows[1].level === 'warn' && rows[1].repeat?.count === 2)
// 出了状况的行,副标题说的是为什么:两次修复凑出来的值不一样,并成一行就只剩第一次的
const why = mod.compactSteps([
{ id: 'r1', seq: 1, kind: 'llm', title: '让模型修复格式', level: 'warn', status: 'done', sub: '出现了原文没有的值(total_count=0)' },
{ id: 'r2', seq: 2, kind: 'llm', title: '让模型修复格式', level: 'warn', status: 'done', sub: '出现了原文没有的值(region=华东)' },
{ id: 'r3', seq: 3, kind: 'llm', title: '让模型修复格式', level: 'warn', status: 'done', sub: '出现了原文没有的值(region=华东)' },
])
check('原因不同的警告不并成一行,原因相同的照并', why.length === 2 && why[1].repeat?.count === 2,
why.map((r) => `${r.sub} ${r.meta ?? ''}`).join(' | '))
// 恢复后的重放(resumed)不算新的一轮;循环体真的又跑一次才算
const replay = mod.decodeRun([
@@ -654,6 +662,180 @@ console.log('\n=== Copilot:心跳穿插、回话、少了一步、报错 ===')
check('结局里的报错分开带着 hint 和 detail', eo.kind === 'error' && eo.error?.hint === 'h' && eo.error?.raw === 'd')
}
console.log('\n=== 协作团队用完轮数:最后那次判定不是新的一轮(REQ-3A-3、NI-5)===')
{
const team = (evts) => mod.decodeRun(evts).find((s) => s.nodeId === 'team')
const routes = (evts) => flatten(mod.decodeRun(evts)).filter((s) => s.kind === 'branch' && s.nodeId === 'team')
// 以前 closing 判定被写成「第 3 轮:调度者在想下一步…」→「第 3 轮:结束协作」,done=false 时
// 读起来像团队正常收尾了;泳道还多出一列空的第 3 轮
const failRoutes = routes(synthetic.exhaustedTeam('fail'))
const closing = failRoutes[failRoutes.length - 1]
check('判定没完成:说「轮数用完 · 调度者判定:未完成」并带理由',
closing?.title === '轮数用完 · 调度者判定:未完成(还没有查到任何订单数据)', closing?.title)
check('判定没完成的那一行是提醒色', closing?.level === 'warn', closing?.level)
check('不再把判定写成「第 3 轮」', !failRoutes.some((s) => s.title.includes('第 3 轮')),
failRoutes.map((s) => s.title).join(' | '))
const judged = routes(synthetic.exhaustedTeam('judged'))
check('判定完成:说「轮数用完 · 调度者判定:已完成」',
judged[judged.length - 1]?.title === '轮数用完 · 调度者判定:已完成', judged[judged.length - 1]?.title)
const live = routes(synthetic.exhaustedTeam('closing'))
const pending = live[live.length - 1]
check('还在判定时有一行进行中,不说「第 3 轮」',
pending?.status === 'running' && pending.title === '轮数用完 · 调度者在做最后判定…', `${pending?.status} ${pending?.title}`)
for (const mode of ['fail', 'judged', 'degrade']) {
const t = team(synthetic.exhaustedTeam(mode))?.team
check(`${mode}:泳道只有派过活的 2 轮,判定不加列`, t?.rounds.length === 2, `${t?.rounds.length} 轮`)
}
// reduceTeam 是画布协作矩阵和右栏共用的那一份:直接喂事件也不能多出一列
let direct
for (const e of synthetic.exhaustedTeam('fail')) {
const next = mod.reduceTeam(direct, e)
if (next && e.node_id === 'team') direct = next
}
check('reduceTeam 直接喂也只有 2 轮', direct?.rounds.length === 2, `${direct?.rounds.length}`)
check('reduceTeam 记下了判定结论', direct?.verdict?.closing === true && direct.verdict.done === false
&& direct.verdict.reason === '还没有查到任何订单数据', JSON.stringify(direct?.verdict))
// 还在判定:泳道据此写「调度者在判定」,而不是空着或多一列
let judging
for (const e of synthetic.exhaustedTeam('closing')) {
const next = mod.reduceTeam(judging, e)
if (next && e.node_id === 'team') judging = next
}
check('判定进行中:记下在判定、还没有结论,也不加列', judging?.verdict?.closing === true && judging.verdict.done === undefined
&& judging.rounds.length === 2, JSON.stringify(judging?.verdict))
const judgedTeam = team(synthetic.exhaustedTeam('judged'))?.team
check('判定完成时团队算收尾了', judgedTeam?.finished === true && judgedTeam.verdict?.done === true)
// 成员把工具调用写成文字:这一步是失败,不是一个写着失败原因的「完成」格子
const r0 = direct?.rounds[0]?.members.find((m) => m.agent === '取数员')
check('失败的成员格子是 failed', r0?.status === 'failed', r0?.status)
check('失败的成员带着原因', !!r0?.error?.includes('没有真正调用工具'), r0?.error)
const r1 = direct?.rounds[1]?.members.find((m) => m.agent === '分析员')
check('同一轮做完的成员照常是 done', r1?.status === 'done', r1?.status)
const memberRow = flatten(mod.decodeRun(synthetic.exhaustedTeam('fail')))
.find((s) => s.title.startsWith('取数员:') && s.status === 'failed')
check('失败的成员那一行也画成失败', !!memberRow && memberRow.level === 'error', memberRow?.status)
check('失败的成员那一行说清为什么', !!memberRow?.sub?.includes('没有真正调用工具'), memberRow?.sub)
check('一轮里有人失败时不算「省下」', direct?.savedMs === 0, `${direct?.savedMs}`)
// 判失败:矩阵和右栏都要说「用完 N 轮未完成」,点出从没派到的成员
check('判失败:团队的结局是 failed', direct?.verdict?.outcome === 'failed', JSON.stringify(direct?.verdict))
check('判失败:认出轮数', direct?.verdict?.rounds === 2, `${direct?.verdict?.rounds}`)
check('判失败:点出从没派到的成员', direct?.verdict?.never?.join(',') === '汇总员', direct?.verdict?.never?.join(','))
// 降档交付:节点「完成」了,但不能是一个安静的勾
const degraded = mod.decodeRun(synthetic.exhaustedTeam('degrade'))
const node = degraded.find((s) => s.nodeId === 'team')
check('降档:团队节点行是提醒色', node?.status === 'done' && node.level === 'warn', `${node?.status} ${node?.level}`)
check('降档:团队节点行说清是降档交付', !!node?.sub?.includes('用完 2 轮') && node.sub.includes('降档'), node?.sub)
check('降档:泳道的结局是 degraded,点出没派到的成员',
node?.team?.verdict?.outcome === 'degraded' && node.team.verdict.never?.join(',') === '汇总员',
JSON.stringify(node?.team?.verdict))
const note = flatten(degraded).find((s) => s.code === 'team_exhausted')
check('降档那条提醒说人话', note?.title === '协作团队用完 2 轮仍未完成 · 按降档交付', note?.title)
check('降档那条提醒给出下一步', !!note?.next && note.fix === 'canvas', `${note?.next} / ${note?.fix}`)
// 认不出轮数(老后端、措辞变了)时照实说「用完了轮数」,不拿「?」凑一个数
const vague = flatten(mod.decodeRun([
{ seq: 1, type: 'node.started', node_id: 'team', ts: 1, data: { node_type: 'supervisor', label: '团队' } },
{ seq: 2, type: 'log', node_id: 'team', ts: 2, data: { level: 'warn', code: 'team_exhausted', message: '协作团队没做完,按降档交付' } },
{ seq: 3, type: 'node.finished', node_id: 'team', ts: 3, data: { duration_ms: 5, preview: { text: 'x', exhausted: true } } },
]))
const vagueNote = vague.find((s) => s.code === 'team_exhausted')
const vagueNode = vague.find((s) => s.kind === 'node' && s.nodeId === 'team')
check('认不出轮数时不写「?」', !vagueNote?.title.includes('?') && !vagueNode?.sub?.includes('?')
&& !!vagueNote?.title.includes('用完了轮数'), `${vagueNote?.title} / ${vagueNode?.sub}`)
}
console.log('\n=== 模型把工具调用写成了文字(NI-4)===')
{
const all = flatten(mod.decodeRun(synthetic.markupRun()))
const warn = all.find((s) => s.code === 'tool_markup_leak')
check('提醒行说人话,不贴原始标记', warn?.title === '模型把工具调用写成了文字,没有真正执行'
&& !warn.title.includes('DSML'), warn?.title)
check('说清已经提醒它重试', !!warn?.sub?.includes('重试一次'), warn?.sub)
check('给出下一步:去画布绑定工具', !!warn?.next?.includes('绑定') && warn.fix === 'canvas', `${warn?.next} / ${warn?.fix}`)
check('原始标记留在展开区给排查', !!warn?.detail?.includes('DSML'))
const team = flatten(mod.decodeRun(synthetic.exhaustedTeam('fail'))).find((s) => s.code === 'tool_markup_leak')
check('成员写成文字时说是哪个成员', team?.title === '取数员把工具调用写成了文字,没有真正执行', team?.title)
const node = mod.decodeRun(synthetic.markupRun()).find((s) => s.nodeId === 'query')
check('失败的节点照常是失败,原因留在详情', node?.status === 'failed' && !!node.detail?.includes('原始标记'))
// 收尾轮那种:真调过工具,只是步数用完了还想接着查。节点照常完成,不能说成「没有真正执行」
const settle = flatten(mod.decodeRun([
{ seq: 1, type: 'node.started', node_id: 'q', ts: 1, data: { node_type: 'agent', label: '数据查询' } },
{ seq: 2, type: 'log', node_id: 'q', ts: 2, data: { level: 'warn', code: 'tool_markup_leak',
message: '模型把工具调用写成了文字(<tool_call>{"name": "db_query__shop"…),没有真正调用工具:步数用完后的收尾轮仍想调用工具,结论只基于之前查到的部分' } },
{ seq: 3, type: 'node.finished', node_id: 'q', ts: 3, data: { duration_ms: 9, preview: { text: '华东 1,204 单' } } },
])).find((s) => s.code === 'tool_markup_leak')
check('收尾轮还想查:说步数用完,不说没有真正执行', !!settle?.title.includes('步数用完') && !settle.title.includes('没有真正执行'),
settle?.title)
check('收尾轮还想查:下一步是调大最多步数', !!settle?.next?.includes('最多步数'), settle?.next)
}
console.log('\n=== 校验修复想凑数(NI-3)===')
{
const all = flatten(mod.decodeRun(synthetic.repairRun()))
const repairs = all.filter((s) => s.code === 'repair')
check('每次修复是一行模型调用', repairs.length === 2 && repairs.every((s) => s.kind === 'llm' && s.title === '让模型修复格式'),
repairs.map((s) => s.title).join(' | '))
check('修复调用带着耗时', repairs[0]?.meta === '1.3 s', repairs[0]?.meta)
check('被作废的修复折进那一行,不另起孤行', !all.some((s) => s.code === 'repair_invented'),
all.filter((s) => s.code === 'repair_invented').map((s) => s.title).join(','))
check('作废的修复画成提醒,说出凑出来的值', repairs.every((s) => s.level === 'warn' && s.sub?.includes('total_count=0')),
repairs.map((s) => `${s.level} ${s.sub}`).join(' | '))
check('作废的修复给出下一步', !!repairs[0]?.next?.includes('上游'), repairs[0]?.next)
check('第一次校验失败那条照常显示', all.some((s) => s.code === 'validate_retry' && s.title.includes('第 1 次校验失败')))
// 老数据:没有 purpose=repair 的 llm.end,作废说明也不能丢
const legacy = flatten(mod.decodeRun(synthetic.repairRun().filter((e) => e.type !== 'llm.end')))
const orphan = legacy.find((s) => s.code === 'repair_invented')
check('没有修复行可折时单独成一行说人话', !!orphan?.title.includes('修复被作废') && orphan.level === 'warn', orphan?.title)
}
console.log('\n=== 工具时限(timeout_s / timed_out)===')
{
const live = flatten(mod.decodeRun(synthetic.timeoutRun('live'))).find((s) => s.kind === 'query')
check('进行中的查询带着时限', live?.limitS === 30 && live.status === 'running', `${live?.limitS} ${live?.status}`)
const done = flatten(mod.decodeRun(synthetic.timeoutRun('done'))).find((s) => s.kind === 'query')
check('超时的查询是失败', done?.status === 'failed' && done.level === 'error', done?.status)
check('超时的查询说清超了多少上限、已放弃等待', done?.sub === '超过 30 s 上限,已放弃等待', done?.sub)
check('超时的原话留在结果里', !!done?.result?.includes('缩小范围'))
}
console.log('\n=== 放弃等审批的运行 ===')
{
const steps = mod.decodeRun(synthetic.abandonedRun())
const row = steps.find((s) => s.kind === 'lifecycle' && s.status === 'cancelled')
check('取消那一行说清谁放弃的、一并关了什么', !!row?.sub?.includes('张工') && row.sub.includes('1 条待审批一并关闭'), row?.sub)
check('放弃之后审批不再是待办', !flatten(steps).some((s) => s.status === 'waiting' || s.status === 'running'))
const plain = mod.decodeRun(synthetic.cancelledRun()).find((s) => s.kind === 'lifecycle' && s.status === 'cancelled')
check('老数据的取消行不硬编副标题', plain && !plain.sub, plain?.sub)
}
console.log('\n=== 出具那一行记着档位 ===')
{
const is = mod.decodeRun(synthetic.mixedRun()).find((s) => s.kind === 'issuance')
check('出具步骤带 tier,头部能据此提醒', is?.tier === 'degraded', is?.tier)
}
console.log('\n=== Copilot 改图:工具绑定变化(NI-1)===')
{
const steps = mod.decodeCopilot(synthetic.COPILOT_TOOLS_DROPPED, { context: 'chat' })
const change = steps.find((s) => s.code === 'tool_changes')
check('工具绑定变化单独成一行', !!change, steps.map((s) => s.title).join(' | '))
check('工具少了用提醒色,说清是哪个节点', change?.level === 'warn' && change.title.includes('数据查询'), change?.title)
check('逐项列出前后', !!change?.detail?.includes('db_query__shop') && change.detail.includes('→'), change?.detail)
const selfCheck = steps.find((s) => s.title.startsWith('自查通过'))
check('自查通过但有工具被去掉:自查那一行是提醒,不是安静的通过', selfCheck?.level === 'warn'
&& selfCheck.title.includes('工具'), `${selfCheck?.level} ${selfCheck?.title}`)
const out = mod.copilotOutcome(synthetic.COPILOT_TOOLS_DROPPED)
check('结局里带着工具绑定变化', out.toolChanges?.length === 1 && out.toolChanges[0].removed.length === 2,
JSON.stringify(out.toolChanges))
check('结局里带着「工具被去掉」的警告', out.dropped?.length === 1, JSON.stringify(out.dropped))
const plain = mod.copilotOutcome(synthetic.COPILOT_STUCK)
check('没有变化时两样都是空的', !plain.toolChanges?.length && !plain.dropped?.length)
}
console.log('\n=== 术语 ===')
{
// 没起名的节点退到类型名,类型名跟全站同一张表
+16 -2
View File
@@ -1,5 +1,5 @@
// 几道"坏了就是整页事故"的护栏:输入法回车、未知节点类型、页面级错误边界、
// 全局快捷键不抢输入框、未知地址不是白板。
// 几道"坏了就是整页事故"的护栏:输入法回车、未知节点类型、页面级和外壳级的
// 错误边界、全局快捷键不抢输入框、未知地址不是白板。
//
// 出事时的样子都很重:中文用户每选一个词就把半句话发去建图、跑图;
// Copilot 编出一个不存在的节点类型,整站白屏、没保存的编辑一起丢;任何组件渲染
@@ -90,6 +90,20 @@ await page.waitForURL(/\/chat/)
check('换一页就恢复', await page.getByText('这一页出错了').waitFor({ state: 'detached', timeout: 8000 })
.then(() => true, () => false))
console.log('\n=== 外壳自己出错:落在中文错误页,不是路由库的英文报错页 ===')
// 导航、后台信号不在页面那一层错误边界里。路由换成 data router 之后,它们一出错,
// 没人接的话 react-router 会画它自带的「Unexpected Application Error!」和一屏堆栈。
// 导航上的「有运行在跑」要读画布的 trace:跑着却没有 trace,导航渲染就会抛
await page.evaluate(() => {
window.__savedTrace = window.__studio.getState().trace
window.__studio.setState({ runPhase: 'running', trace: null })
})
check('给出中文的错误页', await shows('这一页出错了'))
check('不是 react-router 的英文报错页', !(await page.locator('body').innerText()).includes('Unexpected Application Error'))
await page.evaluate(() => window.__studio.setState({ runPhase: 'idle', trace: window.__savedTrace }))
await page.getByRole('button', { name: '重试' }).first().click()
check('点「重试」外壳就回来', await page.locator('nav[aria-label="主导航"]').waitFor({ timeout: 8000 }).then(() => true, () => false))
console.log('\n=== 全局快捷键:不抢输入框 ===')
// 外壳在 window 的捕获阶段听键盘,比谁都先拿到。判错一次,问题框里打个问号就
// 弹出一层面板、⌥3 直接把人带离写了一半的问题
+465 -56
View File
@@ -41,15 +41,55 @@ try {
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 上传进度靠浏览器的 xhr.upload 事件,而 page.route 拦下的请求 Chrome 一个进度事件
* 都不报(真请求会报,已实测)。所以在页面里替 XHR 报:发 /upload 时先报 40%,
* 0.7 秒后报发完,再真正 send(被 page.route 接住)。发之前 abort 的,照浏览器的
* 样子报一次 abort。只测界面怎么接这些事件,不碰后端
*/
function fakeUploadProgress() {
const open = XMLHttpRequest.prototype.open
const send = XMLHttpRequest.prototype.send
const abort = XMLHttpRequest.prototype.abort
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__url = String(url)
return open.call(this, method, url, ...rest)
}
XMLHttpRequest.prototype.abort = function () {
if (this.__pending) {
this.__pending = false
this.dispatchEvent(new ProgressEvent('abort'))
return
}
return abort.call(this)
}
XMLHttpRequest.prototype.send = function (body) {
if (!/\/upload(\?|$)/.test(this.__url ?? '')) return send.call(this, body)
const total = 2_000_000
const up = this.upload
this.__pending = true
const fire = (type, loaded) => up.dispatchEvent(new ProgressEvent(type, { lengthComputable: true, loaded, total }))
setTimeout(() => { if (this.__pending) fire('progress', 800_000) }, 50)
setTimeout(() => {
if (!this.__pending) return
this.__pending = false
fire('progress', total)
fire('load', total)
send.call(this, body)
}, 700)
}
}
/**
* 开一页:GET 放行,写请求交给 handlers(按「METHOD 路径正则」匹配),没配的一律
* 拦成 503——既不写库,也顺带检验「写失败有反馈」。所有原生对话框都算失败。
*/
async function open(path, { handlers = [], theme = THEME, viewport = { width: 1280, height: 860 } } = {}) {
async function open(path, { handlers = [], theme = THEME, viewport = { width: 1280, height: 860 }, uploadProgress = false } = {}) {
const ctx = await browser.newContext({ viewport, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
await ctx.addInitScript((t) => {
try { localStorage.setItem('agentlab.theme', t); localStorage.removeItem('agentlab.health') } catch { /* noop */ }
}, theme)
if (uploadProgress) await ctx.addInitScript(fakeUploadProgress)
const page = await ctx.newPage()
const sent = []
const errors = []
@@ -85,6 +125,15 @@ const goto = async (page, path) => {
await page.waitForTimeout(300)
}
const json = (data, status = 200) => (route) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) })
/** 多久之前的服务器时间(后端列表里的 last_checked_at 就是这种带时区的 ISO) */
const ago = (ms) => new Date(Date.now() - ms).toISOString()
/** 等到条件成立或超时,返回最后一次的值 */
const until = async (fn, ms = 8000, step = 200) => {
const end = Date.now() + ms
let v = await fn()
while (!v && Date.now() < end) { await new Promise((r) => setTimeout(r, step)); v = await fn() }
return v
}
const delayed = (ms, data, status = 200) => async (route) => { await new Promise((r) => setTimeout(r, ms)); return json(data, status)(route) }
const shot = async (page, name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png` }) }
const text = (page) => page.locator('main').innerText()
@@ -135,8 +184,14 @@ for (const [path, title] of [['/tools', '工具'], ['/knowledge', '知识'], ['/
console.log('\n=== 设置 · 模型接入 ===')
{
const p0 = providers[0]
// 后端记着上次测连接的结果(last_check_*):第一张卡 3 小时前测过、没通过,其余没测过
const listed = providers.map((p, i) => (i === 0 ? {
...p, last_checked_at: ago(3 * 3600_000), last_check_ok: false, last_latency_ms: null,
last_error: '鉴权没通过(401):对方拒绝了这把密钥',
} : { ...p, last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null }))
const { page, sent, natives, errors, close } = await open('/settings/providers', {
handlers: [
[/^GET \/providers$/, json(listed)],
[/^POST \/providers\/[^/]+\/test$/, delayed(300, { ok: true, latency_ms: 128, model: p0?.default_model ?? 'm', reply: 'pong' })],
[/^POST \/providers\/test$/, json({ ok: false, error: '测试没通过:连不上对方的服务', hint: '核对地址和端口,确认服务已经启动', detail: 'OpenAIConnectionError: Connection error.' })],
[/^POST \/providers\/models$/, json({ ok: true, models: ['qwen-max', 'qwen-plus'], url: 'x' })],
@@ -144,7 +199,14 @@ console.log('\n=== 设置 · 模型接入 ===')
})
if (p0) {
const card = page.locator(`[data-provider="${p0.name}"]`)
check('卡片上的状态点说的是连通,没测过写「未测试」', (await card.locator('[data-health]').getAttribute('data-health')) === 'idle')
const first = await card.locator('[data-health]').innerText()
check('卡片初值是后端记着的上次结果:连不上 · 3 小时前测(换了浏览器也在)', /连不上.*3 小时前测/.test(first), first)
check('……原因也写在卡片上', (await card.innerText()).includes('对方拒绝了这把密钥'))
const p1 = providers[1]
if (p1) {
check('后端没测过的卡片写「未测试」',
(await page.locator(`[data-provider="${p1.name}"] [data-health]`).getAttribute('data-health')) === 'idle')
}
const disabled = providers.find((p) => !p.enabled)
if (disabled) {
check('停用单独标「已停用」,不再借圆点表达',
@@ -155,7 +217,7 @@ console.log('\n=== 设置 · 模型接入 ===')
check('测试中状态点在转、写已用时间', (await card.locator('[data-health]').getAttribute('data-health')) === 'checking')
await page.waitForTimeout(600)
const pill = await card.locator('[data-health]').innerText()
check('测完留在卡片上,写明是什么时候测的:已连通 · 128 ms · 刚测过', /已连通.*128 ms.*刚测过/.test(pill), pill)
check('本机刚测的比后端记的新,用本机的:已连通 · 128 ms · 刚测过', /已连通.*128 ms.*刚测过/.test(pill), pill)
await shot(page, 'providers-tested')
await page.getByRole('tab', { name: '运行环境' }).click()
await page.waitForTimeout(300)
@@ -214,27 +276,45 @@ console.log('\n=== 设置 · 模型接入 ===')
console.log('\n=== 设置 · 偏好 ===')
{
const settings = await get('/settings')
// 设置读写都走假的一份:PUT 写进去,GET 读回来(主题的「已保存」是读回来核对过才写的)
let stored = await get('/settings')
const { page, sent, natives, close } = await open('/settings/prefs', {
handlers: [[/^PUT \/settings$/, (route, { body }) => json({ ...settings, ...body.values })(route)]],
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => { stored = { ...stored, ...body.values }; return json(stored)(route) }],
],
})
const themeRadio = (label) => page.getByRole('radiogroup', { name: '主题' }).getByRole('radio', { name: label })
const checkedTheme = () => page.getByRole('radiogroup', { name: '主题' }).locator('[aria-checked="true"]').innerText()
// 选一个和这次要截图的主题一致的值(浅色跑选「跟随系统」,上下文的 colorScheme
// 是浅色);它已经是选中的就先绕一下别的,点选中项不会发请求
const [label, value] = THEME === 'dark' ? ['深色', 'dark'] : ['跟随系统', 'system']
const radio = page.getByRole('radio', { name: label })
if ((await radio.getAttribute('aria-checked')) === 'true') {
await page.getByRole('radio', { name: '浅色' }).click()
await page.waitForTimeout(300)
if ((await themeRadio(label).getAttribute('aria-checked')) === 'true') {
await themeRadio('浅色').click()
await page.waitForTimeout(400)
}
await radio.click()
await page.waitForTimeout(400)
await themeRadio(label).click()
await page.waitForTimeout(500)
const put = sent.filter((s) => s.key === 'PUT /settings').at(-1)
check('主题选中即保存,只 PUT ui 一组', !!put && Object.keys(put.body?.values ?? {}).join() === 'ui' && put.body.values.ui.theme === value,
JSON.stringify(put?.body ?? {}).slice(0, 100))
check('主题旁显示「已保存」', (await page.locator('[data-theme-save]').innerText()).includes('已保存'))
check('主题旁显示「已保存」(读回来核对过)', (await page.locator('[data-theme-save]').innerText()).includes('已保存'))
await page.waitForTimeout(1600)
check('……约 1.6 秒后淡出,不一直挂着', await page.getByText('已保存', { exact: true }).count() === 0
&& await page.locator('[data-theme-save="saved"]').count() === 0)
// REQ:从导航(或 ⌘K)换主题时偏好页开着,选项要跟着变,不能还显示旧值
const resolvedBefore = await page.evaluate(() => {
const a = document.documentElement.getAttribute('data-theme')
return a === 'light' || a === 'dark' ? a : (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
})
await page.getByRole('button', { name: /^切换到(浅色|深色)主题$/ }).click()
await page.waitForTimeout(500)
const synced = await checkedTheme()
check('从导航换主题:偏好页的主题选项跟着变', synced.includes(resolvedBefore === 'dark' ? '浅色' : '深色'), synced)
await themeRadio(label).click()
await page.waitForTimeout(500)
check('默认知识库是下拉,选项来自已有知识库',
await page.locator('#pref-collection').evaluate((el) => el.tagName) === 'SELECT'
&& (await page.locator('#pref-collection option').allInnerTexts()).some((t) => t.startsWith(collections[0]?.collection ?? 'default')))
@@ -252,23 +332,71 @@ console.log('\n=== 设置 · 偏好 ===')
await page.waitForTimeout(200)
check('选「留下」就还在偏好页', page.url().endsWith('/settings/prefs'), page.url())
const putsBefore = sent.filter((s) => s.key === 'PUT /settings').length
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(250)
check('只改署名:存在本机就行,不发 PUT', sent.filter((s) => s.key === 'PUT /settings').length === putsBefore)
check('保存后说「已保存」', (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).includes('已保存'))
check('署名写进本机', await page.evaluate(() => localStorage.getItem('agentlab_actor')) === '检查脚本')
const initial = await page.locator('nav a[href="/settings/prefs"]').first().innerText().catch(() => '')
check('……导航底部的署名首字立刻换成「检」(同一个标签页也收得到)', initial.trim() === '检', initial)
await page.locator('#pref-confirm-hint').locator('xpath=ancestor::label//input').click()
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(400)
const put2 = sent.filter((s) => s.key === 'PUT /settings').at(-1)
check('保存设置只 PUT run 一组', Object.keys(put2?.body?.values ?? {}).join() === 'run', JSON.stringify(put2?.body ?? {}).slice(0, 100))
check('保存后说「已保存」', (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).includes('已保存'))
check('署名跟着保存写进本机', await page.evaluate(() => localStorage.getItem('agentlab_actor')) === '检查脚本')
check('改了运行默认值:保存只 PUT run 一组', Object.keys(put2?.body?.values ?? {}).join() === 'run', JSON.stringify(put2?.body ?? {}).slice(0, 100))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
await close()
// 保存失败要看得见(以前 pageerror 一条,界面毫无变化)
const failing = await open('/settings/prefs')
await failing.page.locator('#pref-actor').fill('x')
await failing.page.locator('#pref-confirm-hint').locator('xpath=ancestor::label//input').click()
await failing.page.getByRole('button', { name: /保存设置/ }).click()
await failing.page.waitForTimeout(600)
check('保存失败:条上写「没存上」', (await failing.page.locator('[data-prefs-bar]').innerText()).includes('没存上'))
const bar = await failing.page.locator('[data-prefs-bar]').innerText()
check('保存失败:条上写「没存上」', bar.includes('没存上'))
check('……署名只在本机,已经存上了;条上只剩危险工具审批一项', bar.includes('有 1 项未保存') && bar.includes('危险工具审批') && !bar.includes('署名'), bar.replace(/\s+/g, ' '))
check('保存失败不抛未捕获异常', failing.errors.length === 0, failing.errors[0] ?? '')
await failing.close()
// 断线时在偏好页换的主题:先在本机生效,恢复后自动补存,而不是被服务端的旧值翻回去。
// 断线要断得像真的:/health 也够不着,catalog 才会判断开、恢复时才算「重连」
let offline = false
let puts = 0
let stored2 = await get('/settings')
const cut = (route) => route.abort('internetdisconnected')
const off = await open('/settings/prefs', {
handlers: [
[/^GET \/settings$/, (route) => (offline ? cut(route) : json(stored2)(route))],
[/^PUT \/settings$/, (route, { body }) => {
puts++
if (offline) return cut(route)
stored2 = { ...stored2, ...body.values }
return json(stored2)(route)
}],
[/^GET \//, (route) => (offline ? cut(route) : route.continue())],
],
})
const offRadio = (l) => off.page.getByRole('radiogroup', { name: '主题' }).getByRole('radio', { name: l })
const target = (await offRadio('浅色').getAttribute('aria-checked')) === 'true' ? ['深色', 'dark'] : ['浅色', 'light']
offline = true
await offRadio(target[0]).click()
await off.page.waitForTimeout(800)
check('断线时换主题:说清先在本机生效、连上后自动存', (await off.page.locator('body').innerText()).includes('连上后端后自动存进设置'))
check('……选项和页面都已经是新主题', (await offRadio(target[0]).getAttribute('aria-checked')) === 'true'
&& await off.page.evaluate(() => document.documentElement.getAttribute('data-theme')) === target[1])
const down = await until(() => off.page.locator('[data-offline-banner]').count().then((n) => n > 0), 6000)
check('……整站也判了断开(横幅出来了)', !!down)
const putsOffline = puts
offline = false
await until(() => stored2?.ui?.theme === target[1], 20000)
await off.page.waitForTimeout(400)
check('……后端恢复后自动补存这个主题', puts > putsOffline && stored2?.ui?.theme === target[1], `PUT ${puts} 次,存的是 ${stored2?.ui?.theme}`)
check('……没有被服务端的旧值翻回去', await off.page.evaluate(() => document.documentElement.getAttribute('data-theme')) === target[1]
&& (await offRadio(target[0]).getAttribute('aria-checked')) === 'true')
await off.close()
}
console.log('\n=== 数据 · 数据库 ===')
@@ -276,22 +404,50 @@ console.log('\n=== 数据 · 数据库 ===')
const dbs = sources.filter((s) => !/[\\/]uploads[\\/]tables[\\/]/.test(s.database ?? ''))
const first = dbs.find((s) => s.table_count > 0) ?? dbs[0]
const oracle = dbs.find((s) => s.kind === 'oracle')
// 一个配置里的 schema 探不出对象的假库:只有这种库才给「换个 schema 探查」。
// 顺带是个免密库(has_password:false),验证密码不再拦保存
const noCheck = { last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null }
// 假库一:配置里的 schema 探不出对象;后端记着 2 小时前测连接没通过。顺带是个免密库
// (has_password:false),验证密码不再拦保存
const empty = {
id: 'check-manage-empty', name: 'zz_probe', kind: 'postgres', host: '10.0.0.9', port: null, database: 'mes',
username: 'reader', options: { schema: 'ods' }, readonly: true, description: '检查脚本的假库', enabled: true,
password_masked: '', has_password: false, table_count: 0, schema_synced_at: null,
username: 'reader', options: { schema: 'ods', sslmode: 'disable' }, readonly: true, description: '检查脚本的假库', enabled: true,
password_masked: '', has_password: false, table_count: 0, schema_synced_at: null, cached_schema: 'ods',
schema_error: 'NoSuchTableError: ods', available_schemas: ['mes', 'ods', 'public'], tools: ['db_query__zz_probe'],
last_checked_at: ago(2 * 3600_000), last_check_ok: false, last_latency_ms: null, last_error: '连不上:认证失败',
}
const probed = { ...empty, table_count: 3, schema_error: '', schema_synced_at: new Date().toISOString() }
// 假库二:配置改成了 sales,缓存还是按 legacy 探的——助手看到的和配置对不上
const drifted = {
...empty, ...noCheck, id: 'check-manage-drift', name: 'zz_drift', options: { schema: 'sales' }, cached_schema: 'legacy',
table_count: 2, schema_error: '', available_schemas: [], schema_synced_at: ago(3600_000), tools: ['db_query__zz_drift'],
}
let cur = empty
let drift = drifted
const { page, sent, natives, errors, close } = await open('/data/databases', {
handlers: [
[/^GET \/datasources$/, json([...sources, empty])],
[/^GET \/datasources\/check-manage-empty\/schema$/, json({ tables: ['mes.work_order', 'mes.line', 'mes.shift'], summary: '', synced_at: probed.schema_synced_at })],
[/^POST \/datasources\/check-manage-empty\/introspect$/, (route, { url }) =>
json(url.searchParams.get('schema') === 'mes' ? probed : empty)(route)],
[/^PATCH \/datasources\/check-manage-empty$/, (route, { body }) => json({ ...empty, ...body, options: body?.options ?? empty.options })(route)],
[/^GET \/datasources$/, (route) => json([...sources, cur, drift])(route)],
[/^GET \/datasources\/check-manage-empty\/schema$/, json({ tables: ['mes.work_order', 'mes.line', 'mes.shift'], summary: '', synced_at: ago(0) })],
[/^GET \/datasources\/check-manage-drift\/schema$/, json({ tables: ['sales.orders', 'sales.sales_daily'], summary: '', synced_at: ago(0) })],
[/^POST \/datasources\/check-manage-empty\/introspect$/, (route, { url }) => {
const schema = url.searchParams.get('schema')
if (url.searchParams.get('dry_run') === 'true') {
return json(schema === 'mes'
? { dry_run: true, schema: 'mes', table_count: 3, tables: ['mes.work_order', 'mes.line', 'mes.shift'], truncated: false, total: 3, schema_error: '', available_schemas: empty.available_schemas }
: { dry_run: true, schema, table_count: 0, tables: [], truncated: false, total: 0, schema_error: '', available_schemas: empty.available_schemas })(route)
}
const hit = cur.options.schema === 'mes'
cur = { ...cur, cached_schema: cur.options.schema ?? '', table_count: hit ? 3 : 0, schema_error: hit ? '' : empty.schema_error,
schema_synced_at: hit ? ago(0) : cur.schema_synced_at }
return json(cur)(route)
}],
[/^POST \/datasources\/check-manage-drift\/introspect$/, (route) => {
drift = { ...drift, cached_schema: drift.options.schema, schema_synced_at: ago(0) }
return json(drift)(route)
}],
// 后端的规矩:连接配置(地址、账号、库)改了,上次测连接的结果作废
[/^PATCH \/datasources\/check-manage-empty$/, (route, { body }) => {
const reconnect = ['host', 'port', 'database', 'username'].some((k) => k in (body ?? {}) && body[k] !== cur[k]) || 'password' in (body ?? {})
cur = { ...cur, ...body, options: body?.options ?? cur.options, ...(reconnect ? noCheck : {}) }
return json(cur)(route)
}],
[/^POST \/datasources\/[^/]+\/test$/, delayed(250, { ok: true, elapsed_ms: 42, url: 'x' })],
[/^POST \/datasources\/test$/, json({ ok: false, error: '连不上:认证失败', hint: '核对用户名和密码', detail: 'ORA-01017' })],
[/^POST \/datasources\/[^/]+\/introspect/, (route, { url }) => {
@@ -332,7 +488,7 @@ console.log('\n=== 数据 · 数据库 ===')
await rows.first().click()
await page.waitForTimeout(800)
const cols = await card.locator('[data-schema-browser] tbody tr').count()
check('点表名懒加载出逐列信息', cols > 0, `${cols} 列`)
check('点表名懒加载出逐列信息(后端给的结构化列)', cols > 0, `${cols} 列`)
const scroller = card.locator('[data-schema-browser] .overflow-y-auto')
await scroller.evaluate((el) => { el.scrollTop = 60 })
const before = await scroller.evaluate((el) => el.scrollTop)
@@ -345,8 +501,9 @@ console.log('\n=== 数据 · 数据库 ===')
await shot(page, 'datasource-schema')
}
// 好好的库不给「换个 schema 探查」:后端探查会落进缓存,换着探就把助手看到的结构换掉了
check('结构正常的库不给「换个 schema 探查」', await card.locator('[data-probe-schema]').count() === 0)
// 「换个 schema 看看」只看不存,好好的库也给;单文件的 SQLite 没有 schema 可换
check(first.kind === 'sqlite' ? 'SQLite 不给「换个 schema 看看」' : '结构正常的库也能「换个 schema 看看」(只看不存,没有风险)',
await card.locator('[data-probe-schema]').count() === (first.kind === 'sqlite' ? 0 : 1))
await card.getByRole('button', { name: `删除数据源 ${first.name}` }).click()
const del = dialog(page)
@@ -358,40 +515,50 @@ console.log('\n=== 数据 · 数据库 ===')
}
{
// 配置里的 schema 探不出东西:才给「换个 schema 探查」
const ec = page.locator(`[data-source="${empty.name}"]`)
check('探查失败的库给「换个 schema 探查」', await ec.locator('[data-probe-schema]').count() === 1)
const pill0 = await ec.locator('[data-health]').innerText()
check('卡片初值是后端记着的上次测连接:连不上 · 2 小时前测', /连不上.*2 小时前测/.test(pill0), pill0)
check('……原因写在卡片上', (await ec.innerText()).includes('认证失败'))
// 换个 schema 看看:只看不存(dry_run),缓存和配置都不动
check('探查失败的库给「换个 schema 看看」', await ec.locator('[data-probe-schema]').count() === 1)
await ec.locator('[data-probe-schema]').click()
const pd = dialog(page)
const pdText = await pd.innerText()
check('……先说清楚:探到的结构会换掉缓存,不写进配置就换回来', pdText.includes('换掉') && pdText.includes('换回「ods」'), pdText.slice(0, 120))
check('……先说清楚:只看不存,缓存和配置都不动', pdText.includes('只看不存') && pdText.includes('缓存和配置都不动'), pdText.slice(0, 120))
await pd.locator('input').fill('mes')
await pd.getByRole('button', { name: '探查', exact: true }).click()
await pd.getByRole('button', { name: '看看', exact: true }).click()
await page.waitForTimeout(500)
const probeReq = sent.filter((s) => s.key.endsWith('/introspect')).at(-1)
check('……请求带上指定的 schema', !!probeReq?.url.includes('schema=mes'), probeReq?.url ?? '')
check('……请求带上 schema 和 dry_run=true(不写缓存)', !!probeReq?.url.includes('schema=mes') && probeReq.url.includes('dry_run=true'), probeReq?.url ?? '')
const ask = dialog(page)
const askText = await ask.innerText().catch(() => '')
check('……探到了再问要不要写进配置,并举出探到的表名', askText.includes('mes') && askText.includes('work_order'), askText.slice(0, 160))
check('……「不改」说的是什么都不动,不再「换回去」', askText.includes('什么都不动') && !askText.includes('换回'), askText.slice(0, 200))
await shot(page, 'datasource-probe-schema')
const before = sent.filter((s) => s.key.endsWith('/introspect')).length
await ask.getByRole('button', { name: '不改,换回 ods' }).click()
const before = sent.length
await ask.getByRole('button', { name: '不改', exact: true }).click()
await page.waitForTimeout(500)
const restore = sent.filter((s) => s.key.endsWith('/introspect')).slice(before)
check('……不改:马上按配置里的 schema 重探一次,缓存和配置对得上',
restore.length === 1 && !restore[0].url.includes('schema='), restore.map((r) => r.url).join(' | '))
check('……不改就不发 PATCH', !sent.some((s) => s.key.startsWith('PATCH /datasources')))
check('……卡片回到配置里那份(探查失败、0 个对象)', (await ec.innerText()).includes('上次探查失败'))
check('……不改:一个请求都不再发(以前要再探一次把缓存换回来)', sent.length === before, sent.slice(before).map((r) => r.url).join(' | '))
check('……卡片还是配置里那份(探查失败、0 个对象)', (await ec.innerText()).includes('上次探查失败'))
// 失败框里的候选 chip:探到了选「改」就写进配置
await ec.getByRole('button', { name: '用 mes 探查' }).click()
// 失败框里的候选 chip:探到了选「改」就写进配置,再按新配置真探一次
await ec.getByRole('button', { name: '看看 mes' }).click()
await page.waitForTimeout(500)
await dialog(page).getByRole('button', { name: '改成 mes' }).click()
await page.waitForTimeout(400)
await page.waitForTimeout(600)
const patch = sent.filter((s) => s.key === 'PATCH /datasources/check-manage-empty').at(-1)
check('……选「改成 mes」:PATCH 写进 options.schema', patch?.body?.options?.schema === 'mes', JSON.stringify(patch?.body ?? {}))
check('……选「改成 mes」:PATCH 写进 options.schema,别的连接参数原样带上',
patch?.body?.options?.schema === 'mes' && patch?.body?.options?.sslmode === 'disable', JSON.stringify(patch?.body ?? {}))
const real = sent.filter((s) => s.key.endsWith('check-manage-empty/introspect')).at(-1)
check('……再按新配置真探一次(不带 dry_run)', !!real && !real.url.includes('dry_run') && !real.url.includes('schema='), real?.url ?? '')
const ecText = await ec.innerText()
check('……卡片换成新 schema 的结构', /3\s*个对象/.test(ecText) && !ecText.includes('上次探查失败'), ecText.replace(/\s+/g, ' ').slice(0, 160))
// 免密库:密码不拦保存
// 本机刚测的比后端记的新;只改说明不动它,改了连接配置就作废
await ec.getByRole('button', { name: /测连接/ }).click()
await page.waitForTimeout(500)
check('本机刚测过:卡片换成本机的结果', /已连通.*42 ms/.test(await ec.locator('[data-health]').innerText()))
await ec.getByRole('button', { name: '编辑' }).click()
const ed = dialog(page)
await ed.getByLabel('说明', { exact: true }).fill('检查脚本的假库(改过说明)')
@@ -407,6 +574,24 @@ console.log('\n=== 数据 · 数据库 ===')
const saved = sent.filter((s) => s.key === 'PATCH /datasources/check-manage-empty').at(-1)
check('……保存时不带 password(不动它)', !!saved && !('password' in (saved.body ?? {})) && saved.body?.description?.includes('改过说明'),
JSON.stringify(saved?.body ?? {}).slice(0, 120))
check('……只改说明:刚测的结果还作数', /已连通/.test(await ec.locator('[data-health]').innerText()))
await ec.getByRole('button', { name: '编辑' }).click()
await dialog(page).getByLabel(/主机/).fill('10.0.0.10')
await dialog(page).getByRole('button', { name: '保存' }).click()
await page.waitForTimeout(400)
const after = await ec.locator('[data-health]').innerText()
check('……换了主机:之前测的不再代表它,回到「未测试」', after.includes('未测试'), after)
// 缓存和配置对不上:常驻一行 warn,一键按配置重探
const dc = page.locator(`[data-source="${drifted.name}"]`)
const driftText = await dc.locator('[data-schema-drift]').innerText().catch(() => '')
check('缓存按 legacy 探、配置写着 sales:卡片上常驻一行说对不上', driftText.includes('legacy') && driftText.includes('sales'), driftText)
await shot(page, 'datasource-drift')
await dc.getByRole('button', { name: '按配置重新探查' }).click()
await page.waitForTimeout(500)
const re = sent.filter((s) => s.key === 'POST /datasources/check-manage-drift/introspect').at(-1)
check('……点「按配置重新探查」:按配置真探(不带 schema、不带 dry_run)', !!re && !re.url.includes('?'), re?.url ?? '')
check('……探完那行 warn 就消失', await dc.locator('[data-schema-drift]').count() === 0)
}
if (oracle) {
@@ -463,11 +648,21 @@ console.log('\n=== 数据 · 表格 ===')
] }],
}
const { page, sent, natives, close } = await open('/data/tables', {
uploadProgress: true,
handlers: [
[/^POST \/datasources\/upload$/, delayed(400, fake, 201)],
// 传上来的表只有一两张,卡片会自动摊开结构:假的那张也得有结构可取
// 列注释里带两个空格:以前解析 detail 文本(按两个以上空格切列),「见附表」会被切丢
[/^GET \/datasources\/check-manage-fake\/schema$/, (route, { url }) => json(url.searchParams.get('table')
? { table: 'sales_demo', detail: '表 sales_demo\n 2026-01 REAL\n region TEXT\n Unnamed: 2 TEXT' }
? {
table: 'sales_demo', found: true, qualified: 'sales_demo', kind: 'table', comment: '按月的销售明细',
detail: '表 sales_demo(按月的销售明细)\n 2026-01 REAL\n region TEXT 主键、非空、销售区域 见附表\n Unnamed: 2 TEXT',
columns: [
{ name: '2026-01', type: 'REAL', pk: false, not_null: false, comment: null },
{ name: 'region', type: 'TEXT', pk: true, not_null: true, comment: '销售区域 见附表' },
{ name: 'Unnamed: 2', type: 'TEXT', pk: false, not_null: false, comment: null },
],
}
: { tables: ['sales_demo'], summary: '', synced_at: fake.source.schema_synced_at })(route)],
],
})
@@ -476,8 +671,21 @@ console.log('\n=== 数据 · 表格 ===')
await dlg.locator('input[type="file"]').setInputFiles({ name: 'sales_demo.csv', mimeType: 'text/csv', buffer: Buffer.from('2026-01,region\n1,east\n') })
check('文件名自动变成数据源名', (await dlg.locator('input.mono').first().inputValue()) === 'sales_demo')
await dlg.getByRole('button', { name: /导入/ }).click()
await page.waitForTimeout(100)
await page.waitForTimeout(250)
check('导入中按钮写已用时间', /导入中/.test(await dlg.getByRole('button', { name: /导入中/ }).innerText().catch(() => '')))
const sending = await dlg.locator('[data-upload-progress]').innerText().catch(() => '')
check('上传中画真实的字节进度:已传多少 / 总共多少 · 百分比', sending.includes('已传') && sending.includes('40%')
&& (await dlg.locator('[data-upload-progress] [role="progressbar"]').getAttribute('aria-valuenow').catch(() => '')) === '40',
sending.replace(/\s+/g, ' '))
check('……字节没发完之前可以取消', await dlg.getByRole('button', { name: '取消上传' }).count() === 1)
await shot(page, 'table-uploading')
await page.waitForTimeout(650)
const processing = await dlg.locator('[data-upload-progress]').innerText().catch(() => '')
check('……发完写「正在读表」,不把条拉满冒充完成', processing.includes('正在读表')
&& await dlg.locator('[data-upload-progress] [role="progressbar"]').count() === 0, processing.replace(/\s+/g, ' '))
const cancel = dlg.getByRole('button', { name: '取消', exact: true })
check('……发完之后不能取消(后端已经在建表),悬停说明为什么',
await cancel.isDisabled() && ((await cancel.getAttribute('title')) ?? '').includes('传完'))
await page.waitForTimeout(700)
const res = await dialog(page).innerText()
check('导入完弹窗不关,停在列名和类型上', res.includes('region') && res.includes('TEXT'))
@@ -490,7 +698,26 @@ console.log('\n=== 数据 · 表格 ===')
await dialog(page).getByRole('button', { name: /表头不对/ }).click()
await page.waitForTimeout(200)
check('改行号重传:回到表单,行号 +1', (await dialog(page).locator('input[type="number"]').inputValue()) === '2')
// 这次发到一半取消:后端收不全,什么都不会建
await dialog(page).getByRole('button', { name: /导入/ }).click()
await page.waitForTimeout(250)
await dialog(page).getByRole('button', { name: '取消上传' }).click()
await page.waitForTimeout(900)
check('发到一半取消:说清什么都没建,弹窗留在表单上', (await page.locator('body').innerText()).includes('没传完就取消了')
&& await dialog(page).getByRole('button', { name: /导入/ }).isEnabled())
await dialog(page).getByRole('button', { name: '取消' }).click()
await page.waitForTimeout(300)
// 结构浏览器的列清单用后端给的结构化列,不再解析 detail 文本
const tcard = page.locator('[data-source="sales_demo"]')
await tcard.locator('[data-schema-browser] button[aria-expanded]').first().click()
await page.waitForTimeout(500)
const region = tcard.locator('[data-schema-browser] tbody tr', { hasText: 'region' })
const cells = await region.locator('td').allInnerTexts()
check('列清单:类型、主键、非空、说明各归各的格子', cells.length === 3 && cells[1] === 'TEXT'
&& await region.locator('[aria-label="主键"]').count() === 1 && cells[2].includes('非空'), cells.join(' | '))
check('……列注释里的两个空格不再把说明切丢(「见附表」还在)', cells[2]?.includes('销售区域') && cells[2]?.includes('见附表'), cells[2] ?? '')
check('……表注释写在列表上方', (await tcard.locator('[data-schema-browser]').innerText()).includes('按月的销售明细'))
await shot(page, 'table-columns')
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('上传只发了一次', sent.filter((s) => s.key === 'POST /datasources/upload').length === 1)
await close()
@@ -499,6 +726,7 @@ console.log('\n=== 数据 · 表格 ===')
console.log('\n=== 知识库 ===')
{
const { page, sent, natives, errors, close } = await open('/knowledge/kb', {
uploadProgress: true,
handlers: [[/^POST \/kb\/upload$/, delayed(1200, {
id: 'fake-doc', collection: 'default', title: 'note.md', source: 'note.md', mime: 'text/markdown',
chunk_count: 0, status: 'processing', meta: { progress: { done: 1, total: 4 } },
@@ -515,13 +743,34 @@ console.log('\n=== 知识库 ===')
await page.locator('[data-alpha-off] button', { hasText: '重置' }).click()
check('一键回到运行时值', Number(await page.locator('input[data-alpha]').inputValue()) === embedding.default_alpha)
await page.locator('input[data-kb-upload]').setInputFiles({ name: 'note.md', mimeType: 'text/markdown', buffer: Buffer.from('# hi') })
await page.waitForTimeout(250)
check('上传中列表顶上有占位行', await page.locator('[data-uploading]').count() === 1)
// 一次选两份:一次传一个,第二份排队;排队的能取消
await page.locator('input[data-kb-upload]').setInputFiles([
{ name: 'note.md', mimeType: 'text/markdown', buffer: Buffer.from('# hi') },
{ name: 'more.md', mimeType: 'text/markdown', buffer: Buffer.from('# more') },
])
await page.waitForTimeout(300)
check('上传中列表顶上有占位行,一份一行', await page.locator('[data-uploading]').count() === 2)
check('上传按钮写着上传中', (await page.getByRole('button', { name: /上传中/ }).count()) === 1)
const first = page.locator('[data-uploading]', { hasText: 'note.md' })
const sendingRow = await first.innerText()
check('……在传的那份画真实的字节进度', sendingRow.includes('已传') && sendingRow.includes('40%')
&& await first.locator('[role="progressbar"]').count() === 1, sendingRow.replace(/\s+/g, ' '))
const second = page.locator('[data-uploading]', { hasText: 'more.md' })
check('……第二份写「排队中」,还能取消', (await second.innerText()).includes('排队中')
&& await second.getByRole('button', { name: '取消上传 more.md' }).count() === 1)
await shot(page, 'kb-uploading')
await page.waitForTimeout(1300)
await second.getByRole('button', { name: '取消上传 more.md' }).click()
await page.waitForTimeout(200)
check('……排队的取消了立刻从列表拿掉', await page.locator('[data-uploading]', { hasText: 'more.md' }).count() === 0)
await page.waitForTimeout(500)
const processingRow = await first.innerText()
check('……字节发完写「处理中」(后端在解析、切块),不把条拉满冒充完成',
processingRow.includes('后端在解析、切块') && await first.locator('[role="progressbar"]').count() === 0,
processingRow.replace(/\s+/g, ' '))
check('……发完的那份不给取消(后端已经在切块)', await page.getByRole('button', { name: '取消上传 note.md' }).count() === 0)
await page.waitForTimeout(1500)
check('传完占位行消失', await page.locator('[data-uploading]').count() === 0)
check('……取消的那份没有发出去', sent.filter((s) => s.key === 'POST /kb/upload').length === 1)
await page.locator('input[data-kb-upload]').setInputFiles({ name: 'data.csv', mimeType: 'text/csv', buffer: Buffer.from('a,b\n1,2') })
await page.waitForTimeout(300)
@@ -548,6 +797,104 @@ console.log('\n=== 知识库 ===')
await close()
}
console.log('\n=== 知识库 · 检索贡献条与重建进度 ===')
{
const hits = {
query: '出勤', collection: 'default', degraded: [], alpha: embedding.default_alpha ?? 0.5,
results: [
{ chunk_id: 'c1', document_id: 'd1', title: 'hr_attendance.md', ordinal: 3, content: '出勤率 = 实际出勤 / 计划出勤',
score: 0.42, signals: { vector: 0.6, keyword: 3.21 }, contrib: { vector: 0.3, keyword: 0.12 } },
{ chunk_id: 'c2', document_id: 'd1', title: 'hr_attendance.md', ordinal: 4, content: '计划出勤按排班表算',
score: 0.25, signals: { vector: 0.5 }, contrib: { vector: 0.25, keyword: 0 } },
],
}
const job = (phase, chunks, memories, state = 'running') => ({
id: 'check-job', state, phase, collection: 'default', total: 15, done: chunks + memories,
chunks: { total: 12, done: chunks }, memories: { total: 3, done: memories },
started_at: ago(4000), finished_at: state === 'running' ? null : ago(0), error: null, hint: null,
result: state === 'done' ? { reindexed: 12, memories_reindexed: 3, embedder: 'openai:demo-embed' } : null,
})
// GET /kb/reindex:点重建之前没在跑(开发模式下进页那一眼会问两次);之后依次是
// 片段 → 倒排 → 记忆 → 做完
const steps = [job('chunks', 6, 0), job('index', 12, 0), job('memories', 12, 2), job('done', 12, 3, 'done')]
let started = false
let polled = 0
let rebuilt = false
const { page, sent, natives, errors, close } = await open('/knowledge/kb', {
handlers: [
[/^GET \/kb\/search$/, json(hits)],
[/^GET \/kb\/embedding$/, (route) => json({ ...embedding, fallback: false, has_semantics: true, unindexed_chunks: 0,
stale_chunks: rebuilt ? 0 : 12, stale_memories: rebuilt ? 0 : 3 })(route)],
[/^POST \/kb\/reindex$/, (route) => { started = true; return json(job('chunks', 0, 0), 202)(route) }],
[/^GET \/kb\/reindex$/, (route) => {
if (!started) return json({ state: 'idle' })(route)
const out = steps[Math.min(polled++, steps.length - 1)]
if (out.state === 'done') rebuilt = true
return json(out)(route)
}],
],
})
await page.getByLabel('检索内容').fill('出勤')
await page.getByRole('button', { name: '检索', exact: true }).click()
await page.waitForTimeout(400)
const hit = page.locator('[data-hit]').first()
const contrib = await hit.locator('[data-contrib]').innerText().catch(() => '')
check('命中拆成两路贡献:语义 +0.300、关键词 +0.120(两段之和就是总分)',
/语义\s*\+0\.300/.test(contrib) && /关键词\s*\+0\.120/.test(contrib), contrib.replace(/\s+/g, ' '))
check('……原始分另写成数:余弦、BM25', contrib.includes('余弦') && contrib.includes('0.600') && contrib.includes('3.21'))
const segs = await hit.locator('span[title^="语义贡献"] > span').count()
const segs2 = await page.locator('[data-hit]').nth(1).locator('span[title^="语义贡献"] > span').count()
check('……贡献条两段;只靠语义命中的那条只有一段', segs === 2 && segs2 === 1, `${segs} / ${segs2}`)
check('……结果上方有图例:总分 = 语义贡献 + 关键词贡献', (await page.locator('[data-contrib-legend]').innerText().catch(() => '')).includes('关键词贡献'))
await shot(page, 'kb-search-contrib')
const rebuild = page.getByRole('button', { name: /重建索引(15 段)/ })
check('向量对不上时给「重建索引(15 段)」', await rebuild.count() === 1,
(await page.locator('[data-embedder]').innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 160))
await rebuild.click()
const post = sent.find((s) => s.key === 'POST /kb/reindex')
check('重建走后台:POST 带 background=true', !!post?.url.includes('background=true'), post?.url ?? '')
const chunksText = await until(async () => {
const t = await page.locator('[data-reindex="chunks"]').innerText().catch(() => '')
return t.includes('6 / 12') ? t : ''
}, 4000)
check('进度是后端按批记的真实数:正在重算知识片段的向量 · 6 / 12 段', !!chunksText, chunksText)
check('……进度条的值就是 done / total', (await page.locator('[data-reindex] [role="progressbar"]').getAttribute('aria-valuenow').catch(() => '')) === '6')
check('……跑着的时候不能换向量模型', await page.getByRole('button', { name: '换一个' }).isDisabled())
await shot(page, 'kb-reindex-progress')
const indexText = await until(() => page.locator('[data-reindex="index"]').innerText().catch(() => ''), 4000)
check('倒排那一段没有分批进度:写明,不去假装它在走', indexText.includes('没有分批进度'), indexText.replace(/\s+/g, ' '))
const memText = await until(() => page.locator('[data-reindex="memories"]').innerText().catch(() => ''), 4000)
check('……接着重算记忆:2 / 3 条', memText.includes('2 / 3 条'), memText.replace(/\s+/g, ' '))
const doneToast = await until(async () => {
const t = await page.locator('body').innerText()
return t.includes('重建 12 段知识') ? t : ''
}, 4000)
check('做完了报一句:用哪个模型重建了多少', !!doneToast && doneToast.includes('openai:demo-embed') && doneToast.includes('3 条记忆'))
check('……进度收起,重建按钮也跟着消失(不再有对不上的)',
await page.locator('[data-reindex]').count() === 0 && await page.getByRole('button', { name: /重建索引/ }).count() === 0)
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
// 已经有一次在跑:回 409 就说明,并接着看它的进度,不起第二次
let other = false
const busy = await open('/knowledge/kb', {
handlers: [
[/^GET \/kb\/embedding$/, json({ ...embedding, fallback: false, has_semantics: true, unindexed_chunks: 0, stale_chunks: 12, stale_memories: 3 })],
[/^POST \/kb\/reindex$/, (route) => { other = true; return json({ detail: '已经在重建索引了(全部集合,已完成 3/15),等它做完再点' }, 409)(route) }],
// 进页那一眼还没在跑;点了之后别处起的那一次才露面
[/^GET \/kb\/reindex$/, (route) => json(other ? { ...job('chunks', 3, 0), collection: null } : { state: 'idle' })(route)],
],
})
await busy.page.getByRole('button', { name: /重建索引(15 段)/ }).click()
await busy.page.waitForTimeout(600)
check('已经在重建:回 409 时说明已经在跑', (await busy.page.locator('body').innerText()).includes('已经在重建索引了'))
check('……接着显示那一次的进度', ((await busy.page.locator('[data-reindex]').innerText().catch(() => '')).includes('3 / 12')))
check('……只发了一次 POST,不起第二次', busy.sent.filter((s) => s.key === 'POST /kb/reindex').length === 1)
await busy.close()
}
console.log('\n=== 长期记忆 ===')
{
const withRun = memories.find((m) => m.source?.kind === 'run' && m.source?.run_exists)
@@ -607,6 +954,20 @@ console.log('\n=== 长期记忆 ===')
await close()
}
console.log('\n=== 目录没取回来:空态不冒充「还没有」 ===')
for (const [path, pattern, label] of [
['/settings/providers', /^GET \/providers$/, '模型接入'],
['/knowledge/skills', /^GET \/skills$/, 'Skill'],
]) {
const { page, close } = await open(path, { handlers: [[pattern, json({ detail: '后端出错了' }, 500)]] })
await page.waitForTimeout(600)
const el = page.locator('main [data-empty-unknown]')
check(`${label}那张表没取回来:不说「还没有」,说没取回来并给「重新读取」`,
(await el.getAttribute('data-empty-unknown').catch(() => null)) === 'error' && (await el.innerText().catch(() => '')).includes('重新读取'))
check('……收起「添加 / 新建」这类动作,免得照着建出重复的', await el.getByRole('button', { name: /添加接入|新建 Skill/ }).count() === 0)
await close()
}
console.log('\n=== Skill ===')
{
const { page, sent, natives, close } = await open('/knowledge/skills', {
@@ -637,10 +998,23 @@ console.log('\n=== 工具 ===')
[/^POST \/tools\/[^/]+\/run$/, (route, { body }) => {
calls++
return body?.confirm
? json({ ok: true, result: 'done', duration_ms: 5 })(route)
? json({ ok: true, result: 'done', duration_ms: 5, note: '参数名 cuont 不存在,已按唯一候选 count 执行' })(route)
: json({ detail: '会在 playground 工作目录里写入「x.txt」,同名文件会被覆盖。在工具库里执行不经过审批,确认后才会执行。' }, 409)(route)
}],
[/^POST \/mcp\/refresh$/, (route) => route.abort()],
[/^POST \/custom-tools\/test$/, (route, { body }) => json(body?.config?.url?.includes('fail')
? { ok: false, error: '工具执行失败:HTTP 404', hint: '核对接口地址', detail: '', duration_ms: 12 }
: { ok: true, result: { echo: body?.args ?? {} }, duration_ms: 34, note: '参数名 qurey 不存在,已按唯一候选 query 执行' })(route)],
// 沙箱里没有 MCP 服务:两台假的,一台后端记着 5 分钟前连上过,一台老数据只有 status、没有测连接记录
[/^GET \/mcp\/servers$/, json([
{ id: 'check-mcp-ok', name: 'files_demo', transport: 'stdio', command: 'npx', args: ['-y', 'demo-mcp'], env: {}, url: null,
enabled: true, status: 'ok', last_error: null, tools_cache: ['read', 'list'],
last_checked_at: ago(5 * 60_000), last_check_ok: true, last_latency_ms: 88 },
{ id: 'check-mcp-old', name: 'legacy_demo', transport: 'http', command: null, args: [], env: {}, url: 'https://example.com/mcp',
enabled: true, status: 'error', last_error: '连不上', tools_cache: [],
last_checked_at: null, last_check_ok: null, last_latency_ms: null },
])],
[/^POST \/mcp\/servers\/check-mcp-ok\/probe$/, delayed(200, { ok: true, tools: [{ name: 'read' }, { name: 'list' }, { name: 'write' }], elapsed_ms: 120 })],
],
})
if (danger) {
@@ -661,17 +1035,52 @@ console.log('\n=== 工具 ===')
const runs = sent.filter((s) => /^POST \/tools\/.+\/run$/.test(s.key))
check('确认后带 confirm:true 重发', runs.length === 2 && runs[1].body?.confirm === true, JSON.stringify(runs.map((r) => r.body?.confirm)))
check('结果显示成功', (await page.locator('[data-tool-result]').getAttribute('data-tool-result')) === 'ok')
const note = await page.locator('[data-tool-note]').innerText().catch(() => '')
check('参数名被纠正过:成功结果下面用 warn 写出来,提醒先改对再抄进工作流', note.includes('cuont') && note.includes('改对'), note)
}
await page.getByRole('tab', { name: '自定义工具' }).click()
await page.waitForTimeout(400)
await page.getByRole('button', { name: /新建工具/ }).first().click()
const params = await dialog(page).locator('textarea').nth(1).inputValue()
const ed = dialog(page)
const params = await ed.locator('textarea').nth(1).inputValue()
check('新建工具的参数给了能跑的示例,不是 {}', params.includes('"query"'), params.slice(0, 60))
check('编辑器里有试跑区(保存后可用)', (await dialog(page).locator('[data-tool-trial]').innerText()).includes('先保存'))
await dialog(page).getByRole('button', { name: '取消' }).click()
const trialBox = ed.locator('[data-tool-trial]')
check('新建时不用先保存就能试跑', (await trialBox.innerText()).includes('不用先保存'))
const tryBtn = trialBox.getByRole('button', { name: /试跑/ }).last()
check('……URL 还没填时试跑不可点,悬停说缺什么', await tryBtn.isDisabled() && ((await tryBtn.getAttribute('title')) ?? '').includes('URL'))
await ed.getByLabel(/^URL/).fill('https://api.example.com/search?q={{ query }}')
await tryBtn.click()
await page.waitForTimeout(400)
const draft = sent.filter((s) => s.key === 'POST /custom-tools/test').at(-1)
check('……试跑的是眼前这份没保存的配置(POST /custom-tools/test)',
draft?.body?.kind === 'http' && draft?.body?.config?.url?.includes('api.example.com') && 'query' in (draft?.body?.parameters?.properties ?? {})
&& 'args' in (draft?.body ?? {}), JSON.stringify(draft?.body ?? {}).slice(0, 160))
check('……没有保存(没发 POST /custom-tools)', !sent.some((s) => s.key === 'POST /custom-tools'))
const trialNote = await trialBox.locator('[data-tool-note]').innerText().catch(() => '')
check('……试跑结果里参数名被纠正过也写出来', trialNote.includes('qurey'), trialNote)
await shot(page, 'custom-tool-trial')
await ed.getByLabel(/^URL/).fill('https://api.example.com/fail')
check('……改了配置,刚才的结果标成旧的', (await trialBox.innerText()).includes('这是改之前的结果'))
await tryBtn.click()
await page.waitForTimeout(400)
check('……失败:原因和怎么办写在试跑区', (await trialBox.innerText()).includes('HTTP 404') && (await trialBox.innerText()).includes('核对接口地址'))
await ed.getByRole('button', { name: '取消' }).click()
await page.waitForTimeout(200)
const discard = dialog(page).getByRole('button', { name: '放弃修改' })
if (await discard.count()) await discard.click()
await page.getByRole('tab', { name: 'MCP 接入' }).click()
await page.waitForTimeout(400)
const okCard = page.locator('article', { hasText: 'files_demo' })
const okPill = await okCard.locator('[data-health]').innerText().catch(() => '')
check('MCP 卡片初值是后端记着的上次探测:已连通 · 88 ms · 5 分钟前测', /已连通.*88 ms.*5 分钟前测/.test(okPill), okPill)
check('……只有 status、没有测连接记录的老数据写「未测试」,不再拿 status 猜',
(await page.locator('article', { hasText: 'legacy_demo' }).locator('[data-health]').getAttribute('data-health')) === 'idle')
await okCard.getByRole('button', { name: /测试/ }).click()
await page.waitForTimeout(500)
const probed = await okCard.locator('[data-health]').innerText()
check('……测一次:用后端量的耗时(120 ms),不是往返时间', /已连通.*120 ms.*刚测过/.test(probed), probed)
await shot(page, 'mcp-health')
await page.getByRole('button', { name: /重新加载/ }).click()
await page.waitForTimeout(700)
check('写操作失败有 error toast(以前一声不响)', await page.locator('[role="alert"][aria-live="assertive"] > *').count() > 0)
+410 -3
View File
@@ -14,6 +14,9 @@
// 这些推导状态和循环容器「两轮之间」也按那一刻算
// - 协作矩阵的表头不和底部打架:并行的一轮交回一部分、全部交回等调度时都不说「串行」
// - 去审批浮层键盘可达;出口密的分支节点标签不压别人的线;精简档读数够大
// - 收场要说人话:协作团队用完轮数(判失败 / 降档交付)写清「用完 N 轮未完成」、点出
// 一次都没被派到的成员,收尾判定不另算一轮;模型把工具调用写成文字的失败写原因,
// 不是半句原话;工具超出后端声明的时限直说「已超出 Ns 上限」;校验靠修复才过要提一句
//
// 用法:AGENTLAB_WEB=http://localhost:5373 AGENTLAB_API=http://localhost:8100/api node scripts/check-run-states.mjs
// 截图落在 RUN_STATES_SHOTS(默认 /tmp/agentlab-run-states)。
@@ -25,8 +28,10 @@ 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 SHOTS = process.env.RUN_STATES_SHOTS ?? '/tmp/agentlab-run-states'
// 截图的像素密度:要看清卡片里 11px 的字时设成 2
const DSF = Number(process.env.RUN_STATES_DSF ?? 1) || 1
mkdirSync(SHOTS, { recursive: true })
// 只跑其中几段(逗号分隔:themes, interact, team, replay, history, reduced, exits),调样式时省时间
// 只跑其中几段(逗号分隔:themes, interact, team, endings, replay, history, reduced, exits),调样式时省时间
const ONLY = (process.env.RUN_STATES_ONLY ?? '').split(',').filter(Boolean)
const want = (part) => !ONLY.length || ONLY.includes(part)
@@ -237,6 +242,7 @@ async function open({ theme = 'dark', reduced = false, workflow = WORKFLOW } = {
const browser = await chromium.launch({ executablePath: CHROME })
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: DSF,
colorScheme: theme,
reducedMotion: reduced ? 'reduce' : 'no-preference',
})
@@ -372,6 +378,20 @@ async function shot(page, name) {
return path
}
/** 只截几张卡(外扩一圈):看卡片里的字用,整张画布截图里它们太小 */
async function cardShot(page, ids, name) {
const boxes = (await Promise.all(ids.map((id) => page.locator(`.react-flow__node[data-id="${id}"]`).boundingBox())))
.filter(Boolean)
if (!boxes.length) return null
const x = Math.min(...boxes.map((b) => b.x)) - 16
const y = Math.min(...boxes.map((b) => b.y)) - 16
const w = Math.max(...boxes.map((b) => b.x + b.width)) + 16 - x
const h = Math.max(...boxes.map((b) => b.y + b.height)) + 16 - y
const path = `${SHOTS}/${name}.png`
await page.screenshot({ path, clip: { x: Math.max(0, x), y: Math.max(0, y), width: w, height: h } })
return path
}
const SCENARIOS = [
{ key: 'live', events: LIVE, expect: { team: 'running', loop: 'running', body: 'running', join: 'queued', skip: 'skipped', gate: 'idle', in: 'done' } },
{ key: 'waiting', events: WAITING, expect: { human: 'waiting', body: 'done', loop: 'done', team: 'done', out: 'idle' } },
@@ -717,10 +737,32 @@ if (want('interact')) {
const stampInfo = await stamp.evaluate((el) => ({
tag: el.tagName, role: el.getAttribute('role'), tab: el.tabIndex, cursor: getComputedStyle(el).cursor, title: el.title,
})).catch(() => null)
// 右栏还没有可以滚过去的出具横幅:它是一枚章,不装作能点
check('印章不是个点了没反应的按钮', !!stampInfo && stampInfo.tag !== 'BUTTON' && stampInfo.role === 'img'
// 它是一枚章,不是按钮:不进 Tab 序列,悬停前没有手形
check('印章不是个按钮', !!stampInfo && stampInfo.tag !== 'BUTTON' && stampInfo.role === 'img'
&& stampInfo.tab < 0 && stampInfo.cursor !== 'pointer', JSON.stringify(stampInfo && { ...stampInfo, title: undefined }))
check('印章悬停写出「校验没跑全」', (stampInfo?.title ?? '').includes('校验没跑全:指标集为空'), (stampInfo?.title ?? '').split('\n').join(' / '))
// 右栏看得见时点它是去看完整判定的捷径:广播 agentlab:goto-issuance,右栏在对话层时自己切过去
await page.evaluate(() => {
window.__issuance = []
window.addEventListener('agentlab:goto-issuance', (e) => window.__issuance.push(e.detail))
})
await stamp.hover()
const cursor = await stamp.evaluate((el) => getComputedStyle(el).cursor)
await stamp.click()
const went = await page.evaluate(() => window.__issuance)
check('右栏看得见:悬停成了链接,点它请右栏摆出出具横幅', cursor === 'pointer' && went.length === 1 && went[0].runId === RUN_ID,
`${cursor} · ${JSON.stringify(went)}`)
// 回指明细带口径(issuance.matched[].caliber):同一个指标换过口径,看得出这次按的是哪一版
await page.evaluate(() => {
const st = window.__studio
const out = st.getState().run.output
st.setState({ run: { ...st.getState().run, output: { ...out, _issuance: { ...out._issuance,
matched: [{ token: '12.4%', metric: '毛利率', caliber: '月度口径 @ v2' }, { token: '1,204', metric: '订单数' }] } } } })
})
await page.waitForTimeout(150)
const matchedTitle = await stamp.getAttribute('title')
check('印章悬停列出回指明细和口径', (matchedTitle ?? '').includes('12.4% → 毛利率(月度口径 @ v2)')
&& (matchedTitle ?? '').includes('1,204 → 订单数'), (matchedTitle ?? '').split('\n').join(' / '))
check('运行结束后端口恢复可连', await page.evaluate(() =>
document.querySelector('.react-flow__node[data-id="gate"] .react-flow__handle')?.classList.contains('connectable')))
// 探索运行进行中也不能连线:端口跟着 React Flow 的 connectable 走
@@ -787,6 +829,371 @@ if (want('team')) {
await browser.close()
}
// ---------------------------------------------------------------- 收场:用完轮数、写成文字的工具调用、超时
if (want('endings')) {
// 一张小图:协作团队两轮上限、三个成员(「定稿员」一次都派不到);一个 agent 节点把
// 工具调用写成了文字;一个工具节点查询超时;一个校验节点靠修复才过
const graph = await (await fetch(`${API}/copilot/layout`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ graph: {
nodes: [
{ id: 'in', type: 'input', data: { label: '入口', config: { fields: [{ name: 'week' }] } } },
{ id: 'team', type: 'supervisor', data: { label: '复盘小组', config: {
goal: '汇总本周订单异常', max_rounds: 2, max_parallel: 2,
agents: [agent('检索员', '拉订单明细'), agent('分析员', '找异常原因'), agent('定稿员', '写结论')],
} } },
{ id: 'ask', type: 'agent', data: { label: '订单查询', config: { prompt: '查 orders 本周异常', tools: ['db_query__shop'] } } },
{ id: 'q', type: 'tool', data: { label: '取订单', config: { tool: 'db_query__shop' } } },
{ id: 'chk', type: 'validate', data: { label: '结构校验', config: { repair_with_llm: true, max_retries: 2 } } },
{ id: 'out', type: 'output', data: { label: '周报', config: { fields: [{ name: 'report' }] } } },
],
edges: [
{ source: 'in', target: 'team' }, { source: 'in', target: 'ask' }, { source: 'in', target: 'q' },
{ source: 'q', target: 'chk' }, { source: 'team', target: 'out' }, { source: 'ask', target: 'out' },
{ source: 'chk', target: 'out' },
],
} }),
})).json()
const workflow = { ...WORKFLOW, id: 'wcard-endings', name: '__endings__', graph }
const MARKUP = '模型输出了工具调用的原始标记,但没有真正调用工具。常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。'
const EXHAUSTED = '协作团队用完 2 轮仍未完成:还没有定稿。一次都没被派到的成员:定稿员。先看成员有没有绑定要用的工具,再调大「最多轮数」;也可以把「用完轮数时」改成降档交付'
const START = [
[0, 'run.started', null, { nodes: 6 }],
[0.1, 'node.started', 'in', {}],
[0.05, 'node.finished', 'in', { duration_ms: 3, preview: { week: '2026-W39' } }],
]
// 两轮都派了、调度者始终没说完成;第一轮的检索员把工具调用写成了文字,这一步记失败
const ROUNDS = [
[0.05, 'node.started', 'team', {}],
[0.02, 'agent.route.start', 'team', { round: 0 }],
[1.2, 'agent.route.end', 'team', { round: 0, duration_ms: 1200, agents: ['检索员'], parallel: 1, done: false, reason: '先拉订单明细' }],
[0.02, 'agent.step.start', 'team', { agent: '检索员', instruction: '拉本周订单', round: 0, parallel: 1 }],
[2.0, 'agent.step.end', 'team', { agent: '检索员', duration_ms: 2000, round: 0, parallel: 1, preview: '<tool_call>{"name": "db_query__shop"}</tool_call>',
failed: true, error: '模型输出了工具调用的原始标记,但没有真正调用工具' }],
[0.02, 'agent.route.start', 'team', { round: 1 }],
[1.0, 'agent.route.end', 'team', { round: 1, duration_ms: 1000, agents: ['分析员'], parallel: 1, done: false, reason: '换人分析' }],
[0.02, 'agent.step.start', 'team', { agent: '分析员', instruction: '找异常原因', round: 1, parallel: 1 }],
[1.5, 'agent.step.end', 'team', { agent: '分析员', duration_ms: 1500, round: 1, parallel: 1, preview: '缺数据,无法判断' }],
]
// 轮数用完之后的收尾判定:只判定、不派活,round 是上限(从 0 数就是「第 3 轮」)
const closing = (done, reason) => [
[0.02, 'agent.route.start', 'team', { round: 2, closing: true }],
[0.9, 'agent.route.end', 'team', { round: 2, closing: true, duration_ms: 900, agents: [], parallel: 0, done, reason }],
[0.05, 'llm.end', 'team', { agent: '调度者', model: 'm', input_tokens: 900, output_tokens: 120, cost_usd: 0.001 }],
]
const FAIL = script([...START,
[0.02, 'node.started', 'ask', {}],
...ROUNDS, ...closing(false, '还没有定稿'),
[0.02, 'log', 'ask', { level: 'warn', code: 'tool_markup_leak', message: '模型输出了工具调用的原始标记,但没有真正调用工具' }],
[0.02, 'node.failed', 'ask', { duration_ms: 6800, error: MARKUP }],
[0.05, 'node.failed', 'team', { duration_ms: 6800, error: EXHAUSTED }],
[0.05, 'run.failed', null, { error: '「复盘小组」失败', node_id: 'team', timing: { wall_ms: 7000, active_ms: 7000, wait_ms: 0 } }],
])
const DEGRADE = script([...START,
[0.02, 'node.started', 'ask', {}],
// 第一次查询超出声明的 2s 时限被放弃,第二次查到了;收尾时模型还想要工具
[0.05, 'tool.start', 'ask', { tool: 'db_query__shop', call_id: 'c1', timeout_s: 2 }],
[3.0, 'tool.error', 'ask', { tool: 'db_query__shop', call_id: 'c1', duration_ms: 3000, timed_out: true, error: '查询超过 2s 没有返回,已放弃等待' }],
[0.05, 'tool.start', 'ask', { tool: 'db_query__shop', call_id: 'c2', timeout_s: 2 }],
[0.4, 'tool.end', 'ask', { tool: 'db_query__shop', call_id: 'c2', duration_ms: 400, preview: '{"row_count": 12}' }],
[0.1, 'log', 'ask', { level: 'warn', code: 'tool_markup_leak', message: '收尾轮仍想调用工具:步数用完了' }],
[0.05, 'llm.end', 'ask', { agent: '订单查询', model: 'm', input_tokens: 1200, output_tokens: 300, cost_usd: 0.002 }],
[0.02, 'node.finished', 'ask', { duration_ms: 3700, preview: { text: '本周异常 12 单' } }],
[0.02, 'node.started', 'q', {}],
[0.02, 'tool.start', 'q', { tool: 'db_query__shop', call_id: 'c3', timeout_s: 30 }],
[0.3, 'tool.end', 'q', { tool: 'db_query__shop', call_id: 'c3', duration_ms: 300, preview: '{"row_count": 12}' }],
[0.02, 'node.finished', 'q', { duration_ms: 320, preview: { row_count: 12 } }],
[0.02, 'node.started', 'chk', {}],
[0.4, 'llm.end', 'chk', { model: 'm', purpose: 'repair', duration_ms: 400, input_tokens: 300, output_tokens: 40, cost_usd: 0.0005, calls: 1 }],
[0.02, 'log', 'chk', { level: 'warn', code: 'repair_invented', message: '修复时出现了原文没有的值:total_count=0' }],
[0.4, 'llm.end', 'chk', { model: 'm', purpose: 'repair', duration_ms: 400, input_tokens: 300, output_tokens: 40, cost_usd: 0.0005, calls: 1 }],
[0.02, 'node.finished', 'chk', { duration_ms: 850, preview: { total_count: 12 } }],
...ROUNDS, ...closing(false, '还没有定稿'),
[0.02, 'log', 'team', { level: 'warn', code: 'team_exhausted', message: '协作团队用完 2 轮仍未完成:还没有定稿。一次都没被派到的成员:定稿员。按降档交付' }],
[0.02, 'node.finished', 'team', { duration_ms: 6800, preview: { exhausted: true, exhausted_reason: '还没有定稿', never_dispatched: '[1 项]', text: '缺数据,无法判断' } }],
[0.05, 'node.started', 'out', {}],
[0.05, 'node.finished', 'out', { duration_ms: 4, preview: { report: '…' } }],
[0.05, 'run.finished', null, { output: {}, usage: {}, duration_ms: 9000, timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }],
])
// 进行中:收尾判定还没出结论;工具节点的查询已经超出声明的 2s
const LIVE_JUDGING = script([...START,
...ROUNDS,
[0.02, 'agent.route.start', 'team', { round: 2, closing: true }],
[0.02, 'node.started', 'q', {}],
[0.02, 'tool.start', 'q', { tool: 'db_query__shop', call_id: 'c9', timeout_s: 2 }],
[3.2, 'log', null, { level: 'info', message: '心跳' }],
])
// 收尾判定说完成了:正常交付,表头说清是轮数用完之后判的,不点名谁没被派到
const CLOSED_OK = script([...START,
...ROUNDS, ...closing(true, '材料够了,可以收尾'),
[0.05, 'node.finished', 'team', { duration_ms: 6800, preview: { text: '结论:…' } }],
[0.05, 'run.finished', null, { output: {}, usage: {}, duration_ms: 7000, timing: { wall_ms: 7000, active_ms: 7000, wait_ms: 0 } }],
])
const askLlm = (out) => [0.05, 'llm.end', 'ask', { agent: '订单查询', model: 'm', input_tokens: 600, output_tokens: out, cost_usd: 0.001 }]
// 同一个节点执行两次:第一次的收场不能挂到第二次上。团队用完轮数判失败 → 调大轮数
// 接着跑(后端给失败的节点标 resumed,但这是重新执行;前两轮在断点里,不重发)→ 派到
// 定稿员、调度者判完成。agent 第一次收尾时还想调工具,第二次(循环体每一轮也是这样)干干净净
const RERUN = script([...START,
[0.02, 'node.started', 'ask', {}],
[0.3, 'log', 'ask', { level: 'warn', code: 'tool_markup_leak',
message: '模型把工具调用写成了文字(<tool_call>{"name": "db_query__shop"…),没有真正调用工具:步数用完后的收尾轮仍想调用工具,没能给出结论' }],
askLlm(300),
[0.02, 'node.finished', 'ask', { duration_ms: 400, preview: { text: '本周异常 12 单' } }],
...ROUNDS, ...closing(false, '还没有定稿'),
[0.05, 'node.failed', 'team', { duration_ms: 6800, error: EXHAUSTED }],
[0.05, 'run.failed', null, { error: '「复盘小组」失败', node_id: 'team', timing: { wall_ms: 7000, active_ms: 7000, wait_ms: 0 } }],
[2.0, 'run.resumed', null, { from: ['team'], message: '从「复盘小组」接着跑,前面 2 个节点的结果保留' }],
[0.02, 'run.started', null, { nodes: 6, resumed: true }],
[0.05, 'node.started', 'team', { resumed: true }],
[0.02, 'agent.route.start', 'team', { round: 2 }],
[0.8, 'agent.route.end', 'team', { round: 2, duration_ms: 800, agents: ['定稿员'], parallel: 1, done: false, reason: '派定稿员写结论' }],
[0.02, 'agent.step.start', 'team', { agent: '定稿员', instruction: '写结论', round: 2, parallel: 1 }],
[1.2, 'agent.step.end', 'team', { agent: '定稿员', duration_ms: 1200, round: 2, parallel: 1, preview: '结论:…' }],
[0.02, 'agent.route.start', 'team', { round: 3 }],
[0.6, 'agent.route.end', 'team', { round: 3, duration_ms: 600, agents: [], parallel: 0, done: true, reason: '已经定稿' }],
[0.05, 'node.finished', 'team', { duration_ms: 2700, preview: { text: '结论:…' } }],
[0.05, 'node.started', 'ask', {}],
askLlm(240),
[0.3, 'node.finished', 'ask', { duration_ms: 320, preview: { text: '本周异常 9 单' } }],
[0.05, 'node.started', 'out', {}],
[0.05, 'node.finished', 'out', { duration_ms: 4, preview: { report: '…' } }],
[0.05, 'run.finished', null, { output: {}, usage: {}, duration_ms: 12000, timing: { wall_ms: 12000, active_ms: 10000, wait_ms: 0 } }],
])
// 写成文字、被提醒之后重答了:成果是重答的那一次,不是「收尾时仍想调用工具」那种不完整。
// 团队里检索员提醒后重答了;分析员提醒后还这样,这一步记失败,不能算进「纠正过」
const NUDGE = script([...START,
[0.02, 'node.started', 'ask', {}],
[0.3, 'log', 'ask', { level: 'warn', code: 'tool_markup_leak',
message: '模型把工具调用写成了文字(<tool_call>{"name": "db_query__shop"…),没有真正调用工具,已提醒它重试一次' }],
[0.02, 'tool.start', 'ask', { tool: 'db_query__shop', call_id: 'n1', timeout_s: 30 }],
[0.3, 'tool.end', 'ask', { tool: 'db_query__shop', call_id: 'n1', duration_ms: 300, preview: '{"row_count": 12}' }],
askLlm(280),
[0.02, 'node.finished', 'ask', { duration_ms: 700, preview: { text: '本周异常 12 单' } }],
[0.02, 'node.started', 'team', {}],
[0.02, 'agent.route.start', 'team', { round: 0 }],
[0.8, 'agent.route.end', 'team', { round: 0, duration_ms: 800, agents: ['检索员', '分析员'], parallel: 2, done: false, reason: '先拉明细、再找原因' }],
[0.02, 'agent.step.start', 'team', { agent: '检索员', instruction: '拉本周订单', round: 0, parallel: 2 }],
[0.01, 'agent.step.start', 'team', { agent: '分析员', instruction: '找异常原因', round: 0, parallel: 2 }],
[0.4, 'log', 'team', { level: 'warn', code: 'tool_markup_leak',
message: '检索员把工具调用写成了文字(<tool_call>{"name": "db_query__shop"…),没有真正调用工具,已提醒它重试一次' }],
[0.2, 'log', 'team', { level: 'warn', code: 'tool_markup_leak',
message: '分析员把工具调用写成了文字(<tool_call>{"name": "db_query__shop"…),没有真正调用工具,已提醒它重试一次' }],
[0.6, 'agent.step.end', 'team', { agent: '检索员', duration_ms: 1200, round: 0, parallel: 2, preview: '拉到 12 单' }],
[0.3, 'agent.step.end', 'team', { agent: '分析员', duration_ms: 1500, round: 0, parallel: 2, preview: '',
failed: true, error: '模型输出了工具调用的原始标记,但没有真正调用工具,这一步什么都没查到' }],
[0.02, 'agent.route.start', 'team', { round: 1 }],
[0.6, 'agent.route.end', 'team', { round: 1, duration_ms: 600, agents: [], parallel: 0, done: true, reason: '明细够了' }],
[0.05, 'node.finished', 'team', { duration_ms: 2500, preview: { text: '结论:…' } }],
[0.05, 'run.finished', null, { output: {}, usage: {}, duration_ms: 3500, timing: { wall_ms: 3500, active_ms: 3500, wait_ms: 0 } }],
])
/** 改团队节点的配置(运行之后调过「最多轮数」、或者压根没配)。值给 null 就是删掉这一项 */
const setTeamConfig = (page, patch) => page.evaluate((patch) => {
const st = window.__studio
const node = st.getState().nodes.find((n) => n.id === 'team')
const config = { ...node.data.config, ...patch }
for (const [k, v] of Object.entries(patch)) if (v === null) delete config[k]
st.setState({ nodes: st.getState().nodes.map((n) => (n.id === 'team' ? { ...n, data: { ...n.data, config } } : n)) })
}, patch)
const at = (page, id) => page.locator(`.react-flow__node[data-id="${id}"]`)
const flat = (t) => (t ?? '').replace(/\s+/g, ' ').trim()
const matrixOf = (page) => page.evaluate(() => {
const m = document.querySelector('.react-flow__node[data-id="team"] .team-matrix')
const right = m?.querySelector('.team-head-right')
const row = (name) => [...(m?.querySelectorAll('.team-row') ?? [])].find((r) => r.textContent.includes(name))
const member = (name) => {
const r = row(name)
return r ? { status: r.dataset.memberStatus, text: r.innerText.replace(/\s+/g, ' ').trim(), title: r.title } : null
}
return {
head: (m?.querySelector('.team-head')?.textContent ?? '').replace(/\s+/g, ' ').trim(),
headTitle: m?.querySelector('.team-head')?.getAttribute('title') ?? '',
right: right?.textContent ?? '', tone: right?.classList.contains('is-err') ? 'err' : right?.classList.contains('is-warn') ? 'warn' : '',
dispatch: (m?.querySelector('.team-row-dispatch')?.textContent ?? '').replace(/\s+/g, ' ').trim(),
a: member('检索员'), b: member('分析员'), c: member('定稿员'),
}
})
const note = (page, id) => page.evaluate((id) => {
const el = document.querySelector(`.react-flow__node[data-id="${id}"] .nc-tele-note`)
return el ? { text: el.textContent.trim(), title: el.title, warn: el.classList.contains('is-warn') } : null
}, id)
for (const theme of ['dark', 'light']) {
console.log(`\n=== ${theme === 'dark' ? '暗色' : '亮色'}:收场说人话(用完轮数、工具调用写成文字、超时、修复) ===`)
const { browser, page, errors } = await open({ theme, workflow })
await setLod(page, 'full')
const base = Object.fromEntries(Object.entries(await cards(page)).map(([id, c]) => [id, c.h]))
// ---- 判失败:用完轮数(默认)
await feed(page, FAIL(Date.now()))
await page.waitForTimeout(900)
let c = await cards(page)
let m = await matrixOf(page)
const teamNote = await note(page, 'team')
check(`${theme}: 团队用完轮数判失败,卡片写「用完 2 轮仍未完成」`, c.team.state === 'failed'
&& teamNote?.text === '协作团队用完 2 轮仍未完成', JSON.stringify(teamNote?.text))
check(`${theme}: 悬停写清理由和怎么办`, (teamNote?.title ?? '').includes('还没有定稿') && (teamNote?.title ?? '').includes('怎么办'),
flat(teamNote?.title))
check(`${theme}: 收尾判定不另算一轮(表头仍是第 2 轮 / 上限 2)`, m.head.startsWith('第 2 轮 / 上限 2'), m.head)
check(`${theme}: 表头说「用完 2 轮 · 未完成」,失败上红`, m.right === '用完 2 轮 · 未完成' && m.tone === 'err', `${m.right} · ${m.tone}`)
check(`${theme}: 调度者那一行写「收尾判定」`, m.dispatch.includes('收尾判定'), m.dispatch)
check(`${theme}: 一次都没被派到的成员点出来`, m.c?.status === 'never' && m.c.text.includes('未派到')
&& m.headTitle.includes('一次都没被派到:定稿员'), JSON.stringify(m.c))
check(`${theme}: 成员把工具调用写成文字:格子是失败,悬停写原因`, m.a?.status === 'failed'
&& (m.a.title ?? '').includes('原因:模型输出了工具调用的原始标记'), JSON.stringify(m.a))
const askNote = await note(page, 'ask')
check(`${theme}: agent 把工具调用写成文字而失败:卡片写原因,不是半句原话`, c.ask.state === 'failed'
&& askNote?.text === '模型没有真正调用工具' && (askNote?.title ?? '').includes('怎么办'), JSON.stringify(askNote))
const grew = Object.entries(c).filter(([id, x]) => x.h !== base[id])
check(`${theme}: 失败收场不撑高卡片`, grew.length === 0, grew.map(([id, x]) => `${id} ${base[id]}→${x.h}`).join(', '))
shots.push(await shot(page, `${theme}-endings-failed`), await cardShot(page, ['team', 'ask'], `${theme}-endings-failed-cards`))
// ---- 降档交付、收尾时还想调工具、查询超时、修复才过
await feed(page, DEGRADE(Date.now()))
await page.waitForTimeout(900)
c = await cards(page)
m = await matrixOf(page)
const degr = await note(page, 'team')
const chip = await at(page, 'team').locator('.nc-head .nc-chip-warn', { hasText: '降档' }).count()
check(`${theme}: 降档交付:卡片写「用完 2 轮未完成 · 降档交付」,琥珀色、带「降档」标`, c.team.state === 'done'
&& degr?.text === '用完 2 轮未完成 · 降档交付' && degr.warn && chip === 1, JSON.stringify({ degr, chip }))
check(`${theme}: 降档交付:表头琥珀、点出没被派到的成员`, m.right === '用完 2 轮 · 未完成' && m.tone === 'warn'
&& m.c?.status === 'never', `${m.right} · ${m.tone} · ${m.c?.status}`)
const askDone = await note(page, 'ask')
check(`${theme}: 收尾时还想调工具:跑完了但琥珀色提一句`, c.ask.state === 'done' && askDone?.text === '收尾时仍想调用工具' && askDone.warn,
JSON.stringify(askDone))
const tools = await at(page, 'ask').locator('.nc-tools').evaluate((el) => ({ bad: el.classList.contains('is-bad'), title: el.title }))
.catch(() => null)
check(`${theme}: 超时的那次调用算失败,悬停写「超时(上限 2s)」`, !!tools?.bad && tools.title.includes('超时(上限 2s)'), JSON.stringify(tools))
const chkNote = await note(page, 'chk')
check(`${theme}: 校验靠修复才过:写「修复 2 次后通过」,有一次凑数被作废就上琥珀`, chkNote?.text === '修复 2 次后通过'
&& chkNote.warn && chkNote.title.includes('total_count=0'), JSON.stringify(chkNote))
shots.push(await shot(page, `${theme}-endings-degraded`), await cardShot(page, ['team', 'ask', 'q'], `${theme}-endings-degraded-cards`))
// 校验节点在右边,常被右栏挡住:取景过去再截
await page.evaluate(() => window.__studio.getState().focusNode('chk'))
await page.waitForTimeout(600)
shots.push(await cardShot(page, ['chk'], `${theme}-endings-degraded-validate`))
// 回放到收尾判定还没出结论的那一刻:不提前降档、不提前点名
const judging = await page.evaluate(() => {
const segs = window.__studio.getState().trace.nodes.team.segments.filter((s) => s.kind === 'dispatch')
return segs[segs.length - 1].start + 300
})
await page.evaluate((t) => window.__studio.getState().setReplayAt(t), judging)
await page.waitForTimeout(300)
m = await matrixOf(page)
const early = await at(page, 'team').locator('.nc-head .nc-chip-warn', { hasText: '降档' }).count()
check(`${theme}: 回放到收尾判定中:表头写「调度者在判定」,不提前降档、不点名`, m.right.includes('调度者在判定') && early === 0
&& m.c?.status !== 'never', `${m.right} · 降档标 ${early} · ${m.c?.status}`)
await page.evaluate(() => window.__studio.getState().setReplayAt(null))
// ---- 进行中:收尾判定中;查询超出声明的时限
await feed(page, LIVE_JUDGING(Date.now()))
await page.waitForTimeout(600)
m = await matrixOf(page)
check(`${theme}: 进行中的收尾判定:表头写「轮数用完 · 调度者在判定」,不是新的一轮`,
m.right === '轮数用完 · 调度者在判定' && m.head.startsWith('第 2 轮 / 上限 2') && m.dispatch.includes('判定'), `${m.head} | ${m.dispatch}`)
const over = await at(page, 'q').locator('.nc-over').innerText().catch(() => '')
const overTitle = await at(page, 'q').locator('.nc-tools').getAttribute('title').catch(() => '') ?? ''
// 这句话整个落在卡片里,不被右边沿切掉
const overFits = await page.evaluate(() => {
const el = document.querySelector('.react-flow__node[data-id="q"] .nc-over')
const card = el?.closest('.nc')
if (!el || !card) return false
return el.getBoundingClientRect().right <= card.getBoundingClientRect().right - 2
})
check(`${theme}: 查询超出声明的时限:直说「已超出 2s 上限」,工具名在悬停里`, over === '已超出 2s 上限' && overTitle.includes('db_query__shop ⋯ 进行中(上限 2s)') && overFits, `${over} · ${flat(overTitle)}${overFits ? '' : ' · 超出了卡片'}`)
shots.push(await shot(page, `${theme}-endings-live`), await cardShot(page, ['team', 'q'], `${theme}-endings-live-cards`))
await setLod(page, 'compact')
const brief = await at(page, 'team').locator('.nc-lod-read').innerText()
check(`${theme}: 精简档:收尾判定中写清是判定,不写「第 3 轮」`, brief === '轮数用完 · 收尾判定中', brief)
// 收尾判定认事件里的 closing,不拿配置里的「最多轮数」比:没配(后端按缺省值跑)、
// 运行之后调大了(接着跑之前、回放历史运行),照样认得出是在判定
await setTeamConfig(page, { max_rounds: null })
await page.waitForTimeout(150)
const briefBare = await at(page, 'team').locator('.nc-lod-read').innerText()
await setLod(page, 'full')
m = await matrixOf(page)
check(`${theme}: 没配最多轮数:照样认出收尾判定(精简档、表头、调度者那一行)`, briefBare === '轮数用完 · 收尾判定中'
&& m.right === '轮数用完 · 调度者在判定' && m.dispatch.includes('判定'), `${briefBare} | ${m.right} | ${m.dispatch}`)
await setTeamConfig(page, { max_rounds: 4 })
await page.waitForTimeout(150)
m = await matrixOf(page)
check(`${theme}: 运行之后调大了最多轮数:照样认出收尾判定`, m.right === '轮数用完 · 调度者在判定'
&& m.dispatch.includes('判定'), `${m.right} | ${m.dispatch}`)
await setTeamConfig(page, { max_rounds: 2 })
// ---- 收尾判定说完成了
await feed(page, CLOSED_OK(Date.now()))
await page.waitForTimeout(600)
m = await matrixOf(page)
check(`${theme}: 收尾判定已完成:表头说清、安静、不点名`, m.right === '轮数用完 · 判定已完成' && m.tone === ''
&& m.head.startsWith('第 2 轮 / 上限 2') && m.c?.status !== 'never', `${m.head} · ${m.tone} · ${m.c?.status}`)
check(`${theme}: 悬停写完整判定`, m.headTitle.includes('轮数用完 · 调度者判定:已完成') && m.headTitle.includes('材料够了'), flat(m.headTitle))
// ---- 同一个节点执行第二次:上一次的收场不挂到这一次上。照真实流程先把最多轮数调大
// 再接着跑:回放到第一次时「用完 2 轮」得按那一次的原话,不按现在的配置写成 4
await setTeamConfig(page, { max_rounds: 4 })
await feed(page, RERUN(Date.now()))
await page.waitForTimeout(700)
const quietEnding = async (label) => {
const cs = await cards(page)
const mx = await matrixOf(page)
const never = await at(page, 'team').locator('[data-member-status="never"]').count()
const chips = await at(page, 'team').locator('.nc-head .nc-chip-warn', { hasText: '降档' }).count()
const tn = await note(page, 'team')
const an = await note(page, 'ask')
check(`${theme}: ${label}:团队表头不挂上一次的「判定未完成」、安静、不点名`, cs.team.state === 'done' && mx.tone === ''
&& !mx.right.includes('未完成') && !mx.headTitle.includes('未完成') && never === 0 && chips === 0 && !tn?.warn,
`${mx.right} · 语气「${mx.tone}」· 未派到 ${never} · 降档标 ${chips} · ${JSON.stringify(tn)}`)
check(`${theme}: ${label}:agent 这一次干干净净,写产出量,不写上一次的「收尾时仍想调用工具」`, cs.ask.state === 'done'
&& !!an && !an.warn && /tok/.test(an.text), JSON.stringify(an))
}
await quietEnding('调大轮数接着跑成功、agent 再执行一次')
shots.push(await cardShot(page, ['team', 'ask'], `${theme}-endings-rerun-cards`))
const execs = await page.evaluate(() => {
const n = window.__studio.getState().trace.nodes
const runs = (id) => n[id].segments.filter((s) => s.kind === 'run')
return { teamRuns: runs('team').length, askRuns: runs('ask').length,
first: runs('team')[0].end + 300, second: runs('ask').at(-1).end + 10 }
})
check(`${theme}: 夹具确实是两次执行(团队、agent 各两段)`, execs.teamRuns === 2 && execs.askRuns === 2, JSON.stringify(execs))
await page.evaluate((t) => window.__studio.getState().setReplayAt(t), execs.second)
await page.waitForTimeout(300)
await quietEnding('回放到第二次执行里')
// 回放到第一次失败的那一刻:说的是那一次的收场,不因为后来成功了就不说
await page.evaluate((t) => window.__studio.getState().setReplayAt(t), execs.first)
await page.waitForTimeout(300)
m = await matrixOf(page)
const firstTeam = await note(page, 'team')
const firstAsk = await note(page, 'ask')
check(`${theme}: 回放到第一次失败那一刻:团队写那一次的「用完 2 轮仍未完成」,表头红、点名定稿员`,
firstTeam?.text === '协作团队用完 2 轮仍未完成' && m.right === '用完 2 轮 · 未完成' && m.tone === 'err' && m.c?.status === 'never',
`${JSON.stringify(firstTeam?.text)} · ${m.right} · ${m.tone} · ${m.c?.status}`)
check(`${theme}: 回放到第一次执行:agent 写那一次的「收尾时仍想调用工具」`, firstAsk?.text === '收尾时仍想调用工具' && firstAsk.warn,
JSON.stringify(firstAsk))
await page.evaluate(() => window.__studio.getState().setReplayAt(null))
await setTeamConfig(page, { max_rounds: 2 })
// ---- 写成文字、被提醒之后重答了:不是「收尾时仍想调用工具」
await feed(page, NUDGE(Date.now()))
await page.waitForTimeout(700)
const askNudge = await note(page, 'ask')
check(`${theme}: agent 写成文字被提醒后重答了:写「纠正过一次工具调用」,不说成果可能不完整`,
askNudge?.text === '纠正过一次工具调用' && askNudge.warn && askNudge.title.includes('提醒之后重答了')
&& !askNudge.title.includes('可能不完整'), JSON.stringify(askNudge))
const teamNudge = await note(page, 'team')
check(`${theme}: 协作成员提醒后重答了:团队卡同样写「纠正过一次」,提醒后仍失败的那位不算进去`,
teamNudge?.text === '纠正过一次工具调用' && teamNudge.warn && teamNudge.title.includes('检索员')
&& !teamNudge.title.includes('分析员'), JSON.stringify(teamNudge))
shots.push(await cardShot(page, ['team', 'ask'], `${theme}-endings-nudge-cards`))
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
await browser.close()
}
}
if (want('replay')) {
console.log('\n=== 回放:卡片回到游标那一刻 ===')
const { browser, page, errors } = await open({ theme: 'dark' })
+619 -4
View File
@@ -357,9 +357,28 @@ if (authFail) {
check('有「接着跑」', await page.locator('[data-run-banner=failed] [data-action=continue]').count() === 1)
check('有「去模型接入」', await page.locator('[data-run-banner=failed] [data-action=settings]').count() === 1)
check('有「复制错误」', await page.locator('[data-run-banner=failed] [data-action=copy-error]').count() === 1)
const href = await page.locator('[data-run-banner=failed] [data-action=locate]').getAttribute('href')
check('「在画布中定位」带 run 和 focus', href === `/studio/${authFail.workflow_id}?run=${authFail.id}&focus=${full.error_node_id}`, href ?? '')
check('失败的运行不给「提取模板」', await page.locator('[data-action=extract]').count() === 0)
// 横幅里的定位是排错的主路,详情头上的回放是看全程:两处都得够得着画布。失败的节点
// 在工作流现在的图里已经没了的话(之后改过结构),对准它会落空:横幅改给「在航迹中看」,
// 详情头照样回放、只是不带 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`, () => ({
@@ -463,7 +482,16 @@ if (syntaxFail) {
await page.locator('[data-run-banner=failed]').waitFor()
check('表达式写错的失败不给「接着跑」', await page.locator('[data-run-banner=failed] [data-action=continue]').count() === 0)
const locate = page.locator('[data-run-banner=failed] [data-action=locate]')
check('「在画布中定位」成了主按钮', await locate.count() === 1 && /\bbtn-primary\b/.test(await locate.getAttribute('class') ?? ''))
// 失败的节点在工作流现在的图里没了的话,定位落空,主路改成去航迹看(定位作主按钮的
// 情形由后面「模型没有真正调用工具」的夹具测)
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)
}
@@ -478,6 +506,8 @@ if (held) {
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)
}
// ------------------------------------------------------------------ 封存凭证
@@ -573,6 +603,16 @@ 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('先停止'))
@@ -703,8 +743,583 @@ await page.getByRole('dialog').getByRole('button', { name: '删除记录' }).cli
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=== 收尾 ===')
check('只有伪造过的写请求', writes.every((w) => /\/continue$|\/cancel$|\/copilot\/from-run$|DELETE \/api\/runs\/fake0|^POST \/api\/runs$/.test(w)),
// 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 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] ?? '')
+219 -21
View File
@@ -82,6 +82,8 @@ async function open({ theme = 'dark', state }) {
if (state.offline) return route.abort('connectionrefused')
// 后端连得上但不回:/health 照常,其余请求一直挂着
if (state.hang && url.pathname !== '/api/health') return
// 只卡一张表:别的照常回
if (state.hangPath === url.pathname) return
if (r.method() !== 'GET') {
state.writes.push({ method: r.method(), path: url.pathname, body: r.postData() })
return route.fulfill({ json: {} })
@@ -98,11 +100,19 @@ async function open({ theme = 'dark', state }) {
const nav = (page) => page.locator('nav[aria-label="主导航"]')
const waitNav = (page) => nav(page).waitFor({ timeout: 15000 })
const isMac = process.platform === 'darwin'
// 只跑其中几段:CHECK_ONLY=离开前确认,启动页 node scripts/check-shell.mjs(按段名包含匹配)
const ONLY = process.env.CHECK_ONLY?.split(',').map((s) => s.trim()).filter(Boolean)
const want = (name) => !ONLY?.length || ONLY.some((k) => name.includes(k))
/** 这一段要跑就打出段标题、返回 true */
const section = (name, title) => {
if (!want(name)) return false
console.log(title)
return true
}
const MOD = isMac ? 'Meta' : 'Control'
// ---------------------------------------------------------------------------
console.log('=== 导航 ===')
{
if (section('导航', '=== 导航 ===')) {
const state = { approvals: PENDING, offline: false, writes: [] }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/chat`)
@@ -187,8 +197,7 @@ console.log('=== 导航 ===')
}
// ---------------------------------------------------------------------------
console.log('\n=== 待审批徽标 ===')
for (const theme of ['dark', 'light']) {
if (section('待审批徽标', '\n=== 待审批徽标 ===')) for (const theme of ['dark', 'light']) {
const state = { approvals: PENDING, offline: false, writes: [] }
const { ctx, page } = await open({ theme, state })
await page.goto(`${WEB}/chat`)
@@ -225,8 +234,7 @@ for (const theme of ['dark', 'light']) {
}
// ---------------------------------------------------------------------------
console.log('\n=== 404 ===')
{
if (section('404', '\n=== 404 ===')) {
const state = { approvals: [], offline: false, writes: [] }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/does-not-exist/at-all`)
@@ -244,8 +252,7 @@ console.log('\n=== 404 ===')
}
// ---------------------------------------------------------------------------
console.log('\n=== 标签页标题、favicon、后台提醒 ===')
{
if (section('标签页标题', '\n=== 标签页标题、favicon、后台提醒 ===')) {
const state = { approvals: [], offline: false, writes: [] }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/runs`)
@@ -331,8 +338,7 @@ console.log('\n=== 标签页标题、favicon、后台提醒 ===')
}
// ---------------------------------------------------------------------------
console.log('\n=== ⌘K 命令面板 ===')
{
if (section('⌘K 命令面板', '\n=== ⌘K 命令面板 ===')) {
const state = { approvals: PENDING, offline: false, writes: [] }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/runs`)
@@ -374,6 +380,22 @@ console.log('\n=== ⌘K 命令面板 ===')
check('点工作流进它的画布', await page.waitForURL(new RegExp(`/studio/${wf.id}`), { timeout: 5000 }).then(() => true, () => false), page.url())
}
// 页面刚挂载时的自动聚焦(比如画布助手的输入框)会把焦点从开着的面板里抢走,
// 之后打的字、按的 Esc 就都落到了面板底下
await page.keyboard.press(`${MOD}+KeyK`)
await dialog.waitFor({ timeout: 3000 })
const stolen = await page.evaluate(() => {
const el = document.querySelector('main textarea') ?? document.querySelector('main input, main button, nav a')
el?.focus()
return el?.tagName ?? null
})
check('面板开着时焦点被页面抢走,会回到搜索框', await dialog.getByRole('combobox').evaluate((el) => el === document.activeElement),
`${stolen} → ${await page.evaluate(() => document.activeElement?.tagName)}`)
await page.keyboard.press('Escape')
check('……Esc 照样关掉面板', await dialog.waitFor({ state: 'detached', timeout: 3000 }).then(() => true, () => false))
// 上面没过时面板还开着:⌘K 再按一下把它收掉,后面几项照常跑
if (await dialog.count()) await page.keyboard.press(`${MOD}+KeyK`)
await page.keyboard.press(`${MOD}+KeyK`)
await dialog.getByRole('combobox').fill('zzqq没有这个东西')
check('没结果时说清楚', (await dialog.innerText()).includes('没有匹配'))
@@ -403,8 +425,7 @@ console.log('\n=== ⌘K 命令面板 ===')
}
// ---------------------------------------------------------------------------
console.log('\n=== ? 快捷键说明 ===')
{
if (section('快捷键说明', '\n=== ? 快捷键说明 ===')) {
const state = { approvals: [], offline: false, writes: [] }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/studio`)
@@ -428,8 +449,117 @@ console.log('\n=== ? 快捷键说明 ===')
}
// ---------------------------------------------------------------------------
console.log('\n=== 离线横幅 ===')
{
if (section('离开前确认', '\n=== 离开前确认 ===')) {
// 页面有没保存的改动时登记一道守卫(lib/leave)。以前设置页只在捕获阶段拦 <a> 的
// 点击:⌘K、⌥ 数字直接调 navigate(),浏览器后退也不走 <a>,改到一半的表单就这么丢了。
// 这里用 dev 构建挂出的 window.__leave 登记一道测试守卫:每问一次记一笔,答案由脚本给
const state = { approvals: PENDING, offline: false, writes: [] }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/runs`)
await waitNav(page)
const hooked = await page.waitForFunction(() => !!window.__leave, null, { timeout: 5000 }).then(() => true, () => false)
check('dev 构建挂出了 window.__leave', hooked)
if (hooked) {
await page.evaluate(() => {
window.__asks = []
window.__answer = null
window.__unguard = window.__leave.register({
confirm: (next) => new Promise((resolve) => {
window.__asks.push(next ? next.pathname + next.search : null)
window.__answer = resolve
}),
})
})
const asks = () => page.evaluate(() => window.__asks.length)
const asked = (n) => page.waitForFunction((n) => window.__asks.length >= n, n, { timeout: 3000 }).then(() => true, () => false)
const answer = (ok) => page.evaluate((ok) => { const r = window.__answer; window.__answer = null; r?.(ok) }, ok)
const where = () => { const u = new URL(page.url()); return u.pathname + u.search }
const settle = () => page.waitForTimeout(250)
const dialog = page.getByRole('dialog', { name: '命令面板' })
await page.keyboard.press(`${MOD}+KeyK`)
await dialog.getByRole('combobox').fill('设置')
await page.keyboard.press('Enter')
check('⌘K 跳页先问守卫,问的时候地址不动', await asked(1) && where().startsWith('/runs'), `${await asks()} 次 · ${where()}`)
check('守卫拿得到要去哪', (await page.evaluate(() => window.__asks[0])) === '/settings', await page.evaluate(() => window.__asks[0]))
await answer(false)
await settle()
check('选「留下」:还在原地,面板也关了', where().startsWith('/runs') && (await dialog.count()) === 0, where())
await page.evaluate(() => document.activeElement?.blur())
await page.keyboard.press('Alt+Digit1')
check('⌥1 切页先问守卫', await asked(2) && where().startsWith('/runs'), where())
await answer(true)
check('选「离开」:去到问数据', await page.waitForURL(/\/chat/, { timeout: 5000 }).then(() => true, () => false), where())
await nav(page).locator('a[href="/data"]').click()
check('点左侧导航先问守卫', await asked(3))
await answer(false)
await settle()
check('选「留下」:地址不变', where().startsWith('/chat'), where())
await page.evaluate(() => history.back())
check('浏览器后退也先问守卫', await asked(4))
await answer(false)
await page.waitForTimeout(400)
check('选「留下」:地址退回原处', where().startsWith('/chat'), where())
await page.evaluate(() => history.back())
await asked(5)
await answer(true)
check('后退时选「离开」:真的退回去了', await page.waitForURL(/\/runs/, { timeout: 5000 }).then(() => true, () => false), where())
const before = await asks()
await page.locator('[data-approval-badge]').click()
await page.waitForURL(/tab=approvals/, { timeout: 5000 }).catch(() => {})
check('同一页里只换 ?tab 不问', (await asks()) === before && /\/runs\?tab=approvals/.test(where()), `${await asks() - before} 次 · ${where()}`)
// 先建东西再跳的命令:得先问,不然人点了「留下」,会话已经建出来了
state.writes.length = 0
await page.keyboard.press(`${MOD}+KeyK`)
await dialog.getByRole('combobox').fill('新对话')
await page.keyboard.press('Enter')
check('⌘K「新对话」先问守卫', await asked(before + 1))
check('……问的时候还没建会话', !state.writes.some((w) => w.method === 'POST' && w.path === '/api/conversations'),
state.writes.map((w) => `${w.method} ${w.path}`).join(', '))
await answer(false)
await settle()
check('选「留下」:不建会话、不跳', !state.writes.some((w) => w.method === 'POST' && w.path === '/api/conversations')
&& where().startsWith('/runs'), where())
await page.keyboard.press(`${MOD}+KeyK`)
await dialog.getByRole('combobox').fill('新建工作流')
await page.keyboard.press('Enter')
check('⌘K「新建工作流」先问守卫,再问名字', await asked(before + 2)
&& (await page.getByRole('dialog', { name: '新建工作流' }).count()) === 0)
await answer(false)
await settle()
check('选「留下」:不弹起名框', (await page.getByRole('dialog', { name: '新建工作流' }).count()) === 0 && where().startsWith('/runs'))
// 关页、刷新:守卫在时浏览器会问(它只给通用文案),撤了就不问
const unloadAsks = []
page.on('dialog', (d) => { unloadAsks.push(d.type()); void d.dismiss() })
await page.close({ runBeforeUnload: true })
await page.waitForTimeout(500)
check('有守卫时关页,浏览器先问', unloadAsks.includes('beforeunload') && !page.isClosed(), unloadAsks.join(',') || '没问')
await page.evaluate(() => window.__unguard())
const n = await asks()
await page.evaluate(() => document.activeElement?.blur())
await page.keyboard.press('Alt+Digit1')
check('撤掉守卫后照常切页,不再问', await page.waitForURL(/\/chat/, { timeout: 5000 }).then(() => true, () => false)
&& (await asks()) === n, where())
unloadAsks.length = 0
await page.close({ runBeforeUnload: true })
// 页面这回真关了,只能在外面等
await new Promise((r) => setTimeout(r, 500))
check('撤掉守卫后关页不再问', unloadAsks.length === 0 && page.isClosed(), unloadAsks.join(','))
}
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
await ctx.close()
}
// ---------------------------------------------------------------------------
if (section('离线横幅', '\n=== 离线横幅 ===')) {
const state = { approvals: [], offline: false, writes: [] }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/chat`)
@@ -440,7 +570,11 @@ console.log('\n=== 离线横幅 ===')
const banner = page.locator('main').getByText('后端未连接')
check('断网后出横幅', await banner.waitFor({ timeout: 15000 }).then(() => true, () => false))
check('横幅是主区第一个孩子(toast 才会跟着下移)',
await page.evaluate(() => document.querySelector('main')?.firstElementChild?.getAttribute('role') === 'alert'))
// role=alert 只包不变的那几句(整块重念会把倒计时也念一遍),外层认 data-offline-banner
await page.evaluate(() => document.querySelector('main')?.firstElementChild?.hasAttribute('data-offline-banner') === true))
const alertText = await page.evaluate(() => document.querySelector('[data-offline-banner] [role="alert"]')?.textContent ?? null)
check('横幅的播报区只有不变的那几句:倒计时不在里面', alertText != null && alertText.includes('后端未连接') && !/秒后/.test(alertText)
&& /秒后自动重试/.test(await page.locator('[data-offline-banner]').innerText()), alertText ?? '没有 role=alert')
check('遥测点变成离线', /离线/.test(await nav(page).locator('[data-telemetry]').innerText()))
state.offline = false
await page.getByRole('button', { name: '立即重试' }).first().click()
@@ -463,8 +597,7 @@ const bootLiveRegions = (page) => page.evaluate(() => {
})
// ---------------------------------------------------------------------------
console.log('\n=== 启动页 ===')
for (const theme of ['dark', 'light']) {
if (section('启动页', '\n=== 启动页 ===')) for (const theme of ['dark', 'light']) {
const state = { approvals: [], offline: true, writes: [] }
const { ctx, page } = await open({ theme, state })
await page.goto(`${WEB}/chat`)
@@ -488,20 +621,27 @@ for (const theme of ['dark', 'light']) {
await ctx.close()
}
{
if (want('启动页')) {
// 后端连着、只有一张表报错:照常进,但要说出来,不然那张表对应的下拉就是空的,看着像"没有"
const state = { approvals: [], offline: false, writes: [], broken: '/api/skills' }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/chat`)
await waitNav(page)
const warned = await page.getByText(/有 1 项没加载成功:Skill(500)/).waitFor({ timeout: 5000 }).then(() => true, () => false)
const warn = page.getByText(/有 1 项没加载成功(Skill 500)/)
const warned = await warn.waitFor({ timeout: 5000 }).then(() => true, () => false)
check('部分请求报错:照常进页面,并说出哪一项没加载', warned)
const tone = await nav(page).locator('[data-telemetry]').getAttribute('data-tone')
check('遥测点记为「降级」(琥珀),不是一片绿', tone === '降级', tone)
// 那张表一直是空的,提示就一直在:4 秒一闪就没,人还是会对着空列表去新建
await page.waitForTimeout(4500)
check('提示常驻,直到那张表取回来', await warn.isVisible())
state.broken = null
await page.locator('[role="status"] > div', { hasText: '没加载成功' }).getByRole('button', { name: '重试' }).click({ timeout: 5000 }).catch(() => {})
check('重试取回来:提示撤掉', await warn.waitFor({ state: 'detached', timeout: 5000 }).then(() => true, () => false))
await ctx.close()
}
{
if (want('启动页')) {
// 后端连得上、请求却卡着不回:「先进去看看」之后不能是一片绿加一屏「还没有工作流」
const state = { approvals: [], offline: false, writes: [], hang: true }
const { ctx, page } = await open({ state })
@@ -515,6 +655,9 @@ for (const theme of ['dark', 'light']) {
await enter.click()
await waitNav(page)
const telemetry = nav(page).locator('[data-telemetry]')
// 这一路唯一的 /health 探活和「先进去看看」同时出现,按钮点得快时它还在路上
await page.waitForFunction(() => document.querySelector('[data-telemetry]')?.getAttribute('data-telemetry') !== 'checking', null, { timeout: 5000 })
.catch(() => {})
const tone = await telemetry.getAttribute('data-tone')
check('进去以后遥测点不是「在线」,而是「加载中」', tone === '加载中', `${tone} / ${await telemetry.innerText()}`)
const warn = page.getByText(/没取回来.*先别新建/)
@@ -537,7 +680,62 @@ for (const theme of ['dark', 'light']) {
await ctx.close()
}
{
if (want('启动页')) {
// 只有一张表卡住(后端连着):每个请求最多等 15 秒,到点记成出错。遥测点从「加载中」
// 转成「降级」,提示也得跟着改口说「没加载成功」,而不是悄悄撤掉——那张表还是空的
const state = { approvals: [], offline: false, writes: [], hangPath: '/api/tools' }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/chat`)
// 第一次全量加载要等那一项超时才算落定,启动页到点自己放人进来
const entered = await nav(page).waitFor({ timeout: 25000 }).then(() => true, () => false)
check('卡住的那一项超时后,启动页自己放人进来', entered)
const warn = page.getByText(/有 1 项没加载成功(工具 超时).*先别新建/)
check('超时的那一项:进门就说它没加载成功,劝先别新建', await warn.waitFor({ timeout: 5000 }).then(() => true, () => false),
await warn.innerText().catch(() => '没有这条提示'))
const telemetry = nav(page).locator('[data-telemetry]')
check('遥测点记为「降级」', (await telemetry.getAttribute('data-tone')) === '降级', await telemetry.getAttribute('data-tone'))
await telemetry.click()
const row = page.getByRole('dialog', { name: '后端连接' }).locator('[data-check="tools"]')
const rowText = (await row.innerText()).replace(/\s+/g, ' ')
check('加载清单里那一项写「超时」,不写「连不上」', /超时/.test(rowText), rowText)
await page.keyboard.press('Escape')
await page.waitForTimeout(4500)
check('提示常驻,不会自己消失', await warn.isVisible())
state.hangPath = null
await page.locator('[role="status"] > div', { hasText: '没加载成功' }).getByRole('button', { name: '重试' }).click({ timeout: 5000 }).catch(() => {})
check('重试取回来:提示撤掉', await warn.waitFor({ state: 'detached', timeout: 5000 }).then(() => true, () => false))
check('遥测点回到「在线」', await page.waitForFunction(() => document.querySelector('[data-telemetry]')?.getAttribute('data-tone') === '在线', null, { timeout: 8000 })
.then(() => true, () => false), await telemetry.getAttribute('data-tone'))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
await ctx.close()
}
if (want('启动页')) {
// 取回来过的表,后来的刷新卡住了:手上的列表是真的(失败不清空),遥测点说「加载中」
// 就够了,不能再弹「列表可能不全,先别新建」吓人
const state = { approvals: [], offline: false, writes: [] }
const { ctx, page } = await open({ state })
await page.goto(`${WEB}/chat`)
await waitNav(page)
const telemetry = nav(page).locator('[data-telemetry]')
await page.waitForFunction(() => document.querySelector('[data-telemetry]')?.getAttribute('data-tone') === '在线', null, { timeout: 10000 }).catch(() => {})
state.hangPath = '/api/tools'
await telemetry.click()
await page.getByRole('dialog', { name: '后端连接' }).getByRole('button', { name: '重新检测' }).click()
await page.keyboard.press('Escape')
await page.waitForTimeout(3800)
check('刷新卡住:遥测点说「加载中」', (await telemetry.getAttribute('data-tone')) === '加载中', await telemetry.getAttribute('data-tone'))
check('……但不弹「先别新建」:那张表之前取回来过', (await page.getByText(/先别新建/).count()) === 0)
await telemetry.click()
const panel = page.getByRole('dialog', { name: '后端连接' })
check('遥测浮层照样写出哪一项还没回来,但不劝别新建', /工具还没回来/.test(await panel.innerText()) && !/先别急着新建/.test(await panel.innerText()),
(await panel.innerText()).replace(/\n/g, ' '))
await page.keyboard.press('Escape')
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
await ctx.close()
}
if (want('启动页')) {
// 断线时在导航上换了主题:重连后补存进设置,不能被服务端的旧值翻回去
const state = { approvals: [], offline: true, writes: [] }
const { ctx, page } = await open({ state })
+395
View File
@@ -939,6 +939,401 @@ console.log('\n=== 编号列、追问、CSV ===')
await q.page.close()
}
// ---------------------------------------------------------------- 第三波
/** 量一个 CSS 颜色表达式算出来的 rgb,跟元素的实际颜色比 */
const colorOf = (page, v) => page.evaluate((expr) => {
const probe = document.createElement('span')
probe.style.color = expr
document.body.append(probe)
const c = getComputedStyle(probe).color
probe.remove()
return c
}, v)
const count = (text, needle) => text.split(needle).length - 1
console.log('\n=== 协作团队用完轮数:泳道、头部、报错(NI-5、REQ-3A-3)===')
for (const [w, dense, label] of [[1100, '0', '宽栏'], [380, '1', '窄栏']]) {
const { page, errors } = await open(`syn=exhausted&dense=${dense}`, { w, h: 900, name: `exhausted-${dense === '1' ? 'dense' : 'wide'}` })
const body = await page.locator('body').innerText()
const lanes = await page.locator('[data-team-lanes]').innerText().catch(() => '')
check(`${label}:泳道头说「用完 2 轮仍未完成」`, lanes.includes('用完 2 轮仍未完成'), lanes.split('\n')[0])
check(`${label}:泳道点出从没派到的成员`, lanes.includes('汇总员') && lanes.includes('没派到'), lanes.replace(/\n/g, ' ').slice(0, 120))
check(`${label}:最后那次判定不画成第 3 轮`, !/第\s?3\s?轮/.test(lanes), lanes.replace(/\n/g, ' ').slice(0, 160))
const cell = await page.evaluate(() => {
const b = document.querySelector('[data-team-lanes] button[aria-label^="取数员 第 1 轮"]')
return b ? getComputedStyle(b).backgroundColor : ''
})
check(`${label}:失败的成员格子是失败色`, !!cell && cell === await colorOf(page, 'var(--st-failed)'), cell)
check(`${label}:判定那一行说「轮数用完 · 调度者判定:未完成」`, body.includes('轮数用完 · 调度者判定:未完成'))
// 报错用 lib/explain 讲清为什么、怎么办,不再原样贴后端那一整句
const alert = await page.locator('[data-turn] [role="alert"]').first().innerText().catch(() => '')
check(`${label}:报错标题说人话`, alert.startsWith('协作团队用完 2 轮仍未完成'), alert.split('\n')[0])
check(`${label}:报错给出怎么办`, alert.includes('最多轮数'), alert.replace(/\n/g, ' ').slice(0, 160))
check(`${label}:没有运行时报错`, errors.length === 0, errors.join(' | '))
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check(`${label}:不横向溢出`, overflow <= 0, `${overflow}px`)
await page.close()
}
{
const { page } = await open('syn=exhausted-degrade', { w: 1100, h: 900, name: 'exhausted-degrade' })
const head = await page.locator('[data-turn] .sticky').first().innerText()
check('降档交付:头部不是一个安静的「已完成」', head.includes('协作团队没做完'), head.replace(/\n/g, ' '))
const lanes = await page.locator('[data-team-lanes]').innerText().catch(() => '')
check('降档交付:泳道头说按降档交付', lanes.includes('按降档交付'), lanes.split('\n')[0])
const body = await page.locator('body').innerText()
check('降档交付:团队那一行说清是降档', body.includes('用完 2 轮仍未完成,按降档交付'))
check('降档交付:提醒行给出下一步', body.includes('不能当结论用'))
await page.close()
const r = await open('syn=exhausted-closing&dense=1', { w: 380, h: 800, name: 'exhausted-closing' })
const rb = await r.page.locator('body').innerText()
check('最后那次判定进行中:写「调度者在做最后判定」', rb.includes('轮数用完 · 调度者在做最后判定'))
check('最后那次判定进行中:不写「第 3 轮」', !rb.includes('第 3 轮'))
const rl = await r.page.locator('[data-team-lanes]').innerText().catch(() => '')
check('最后那次判定进行中:泳道也说在判定', rl.includes('轮数用完 · 调度者在判定'), rl.split('\n').slice(0, 2).join(' '))
await r.page.close()
}
console.log('\n=== 模型把工具调用写成文字、修复凑数、工具超时(NI-3/4)===')
{
const { page, errors } = await open('syn=markup&dense=1', { w: 380, h: 900, name: 'markup' })
const body = await page.locator('body').innerText()
const alert = await page.locator('[data-turn] [role="alert"]').first().innerText().catch(() => '')
check('报错标题:模型没有真正调用工具', alert.startsWith('模型没有真正调用工具'), alert.split('\n')[0])
check('报错说原因和怎么办', alert.includes('当成文字') && alert.includes('绑定'), alert.replace(/\n/g, ' '))
check('原始标记不出现在正文里', !body.includes('DSML'))
const row = await page.locator('[data-node-id="query"][data-step-status="failed"]').first().innerText().catch(() => '')
check('失败的节点行说人话', row.includes('模型没有真正调用工具'), row.replace(/\n/g, ' '))
const warn = page.locator('[data-step-code="tool_markup_leak"]').first()
const warnText = await warn.innerText().catch(() => '')
check('提醒行说人话', warnText.includes('模型把工具调用写成了文字,没有真正执行'), warnText.replace(/\n/g, ' '))
check('下一步不用展开就看得到', await warn.locator('[data-step-next]').count() === 1
&& (await warn.locator('[data-step-next]').innerText()).includes('绑定'))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await page.close()
const rp = await open('syn=repair', { w: 1100, h: 900, name: 'repair' })
const rbody = await rp.page.locator('body').innerText()
// 两次修复凑出的是同一个值:并成一行「×2」,原因照样写在行上;值不同时分行(见 check-decode)
check('每次修复都算上、说出凑出来的值', rbody.includes('让模型修复格式') && /×2/.test(rbody) && rbody.includes('total_count=0'),
rbody.split('\n').filter((l) => l.includes('修复') || l.includes('×')).join(' | '))
const ralert = await rp.page.locator('[data-turn] [role="alert"]').first().innerText().catch(() => '')
check('报错:校验修复被拒绝', ralert.startsWith('校验修复被拒绝'), ralert.split('\n')[0])
await rp.page.close()
const tl = await open('syn=timeout-live&dense=1', { w: 380, h: 700, name: 'timeout-live' })
const over = await tl.page.evaluate(() => {
const row = document.querySelector('[aria-busy="true"][data-step-status="running"]')
const clock = row?.querySelector('[data-over-limit]')
return { text: clock?.textContent ?? '', color: clock ? getComputedStyle(clock).color : '' }
})
check('进行中的查询越过时限要说破', over.text.includes('已超出 30 s 上限'), over.text)
check('越过时限的秒表是提醒色', over.color === await colorOf(tl.page, 'var(--st-waiting)'), over.color)
await tl.page.close()
const td = await open('syn=timeout', { w: 1100, h: 700 })
check('超时的查询写明超了多少上限', (await td.page.locator('body').innerText()).includes('超过 30 s 上限,已放弃等待'))
await td.page.close()
}
console.log('\n=== 放弃、结构化的报错、恢复的轮次、出具横幅(REQ-1/5/6、REQ-3A-2)===')
{
const { page } = await open('syn=abandoned', { w: 1100, h: 600, name: 'abandoned' })
const mark = await page.locator('[data-phase-mark]').allInnerTexts()
check('放弃的运行:分段线说清谁放弃的、一并关了什么', mark.some((t) => t.includes('已取消') && t.includes('张工')
&& t.includes('1 条待审批一并关闭')), mark.join(' | '))
await page.close()
const s = await open('syn=structured', { w: 1100, h: 600, name: 'structured' })
const alert = await s.page.locator('[data-turn] [role="alert"]').first().innerText()
// 以前页面拆好的「操作超时」又被翻译一遍:「操作超时 / 操作超时:查询超时…」
check('拆好的报错照原样画,标题不重复', count(alert, '操作超时') === 1 && alert.startsWith('操作超时'), alert.replace(/\n/g, ' '))
check('原因、怎么办各一行', alert.includes('查询超时:数据库 90 秒没有返回') && alert.includes('缩小时间范围'))
check('原文收进技术细节', !alert.includes('OperationalError') && alert.includes('技术细节'))
await s.page.close()
const r = await open('syn=restored', { w: 1100, h: 600 })
const head = await r.page.locator('[data-turn] .sticky').first().innerText()
check('恢复的轮次:头部照样写几次查询', head.includes('3 次查询'), head.replace(/\n/g, ' '))
await r.page.close()
const i = await open('syn=issued', { w: 1100, h: 900 })
check('出具横幅带 data-issuance-banner(画布印章据此滚过来)', await i.page.locator('[data-issuance-banner]').count() === 1)
const live = await i.page.locator('[data-turn] [role="status"][aria-live="polite"]').first().innerText()
check('读屏也播报出具档位', live.includes('降档出具'), live)
await i.page.close()
const c = await open('syn=tools-dropped&dense=1', { w: 380, h: 700, name: 'tools-dropped' })
const cb = await c.page.locator('body').innerText()
check('改图时工具被清空要单独说', cb.includes('「数据查询」的工具被清空了'))
check('自查通过但工具被去掉,自查那行不是安静的通过', cb.includes('自查通过,但有 1 处工具被去掉了'))
await c.page.close()
}
console.log('\n=== 吸顶的轮次头贴着顶边(REQ-8)===')
for (const [q, w, h, label] of [['syn=mixed', 1024, 768, '宽栏 1024'], ['syn=mixed', 1180, 800, '宽栏 1180'],
['syn=mixed&dense=1', 380, 700, '窄栏']]) {
const { page } = await open(q, { w, h })
const gap = await page.evaluate(() => {
const sc = document.querySelector('[data-stream-scroll]')
sc.scrollTop = Math.min(sc.scrollHeight - sc.clientHeight, 260)
sc.dispatchEvent(new Event('scroll'))
const head = document.querySelector('[data-turn] .sticky')
const s = sc.getBoundingClientRect()
const hd = head.getBoundingClientRect()
// 顶边往下 1px 那一行像素:落在头上才算没缝
const hit = document.elementFromPoint(s.left + s.width / 2, s.top + 1)
return { gap: Math.round(hd.top - s.top), onHead: !!hit && head.contains(hit), scrolled: sc.scrollTop }
})
check(`${label}:往下翻后轮次头贴着滚动区顶边,上面不露正文`, gap.scrolled > 0 && gap.gap === 0 && gap.onHead, JSON.stringify(gap))
await page.close()
}
console.log('\n=== 画布右栏:去审批、放弃、出具横幅、回执(REQ-1/3/4、REQ-3A-2)===')
{
const WF_ID = 'fx-stream3'
const RUN = 'fxstream0003'
const GRAPH = {
nodes: [
{ id: 'in', type: 'input', position: { x: 0, y: 0 }, data: { label: '问题', config: { fields: [{ name: 'q' }] } } },
{ id: 'review', type: 'human', position: { x: 300, y: 0 }, data: { label: '主管审批', config: { mode: 'approve' } } },
{ id: 'out', type: 'output', position: { x: 620, y: 0 }, data: { label: '成果', config: {} } },
{ id: 'query', type: 'agent', position: { x: 300, y: 200 }, data: { label: '数据查询', config: { tools: [] } } },
],
edges: [{ id: 'e1', source: 'in', target: 'review' }, { id: 'e2', source: 'review', target: 'out', sourceHandle: 'approved' }],
}
const WF = { id: WF_ID, name: '右栏检查三', description: '', graph: GRAPH, tags: [], version: 1, status: 'draft',
published_version: null, run_count: 0, created_at: '2026-09-26T00:00:00Z', updated_at: '2026-09-26T00:00:00Z' }
const APPROVAL = { id: 'ap-3', run_id: RUN, node_id: 'review', mode: 'approve', title: '这份报表可以发出吗?',
payload: { message: '华东 1,204 单' }, status: 'pending', response: {}, created_at: new Date(Date.now() - 60_000).toISOString(),
workflow_name: '右栏检查三', node_label: '主管审批', run_class: 'exploratory' }
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'dark' })
await ctx.addInitScript(() => { try { localStorage.setItem('agentlab_actor', '张工') } catch { /* 隐私窗口 */ } })
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
let pending = true
let status = 'interrupted'
const cancels = []
await page.route(/\/api\/workflows(\?.*)?$/, async (route) => {
if (route.request().method() !== 'GET') return json(route, { detail: '检查脚本不写库' }, 409)
const real = await (await route.fetch()).json().catch(() => [])
return json(route, [WF, ...(Array.isArray(real) ? real : [])])
})
await page.route(/\/api\/workflows\/fx-stream3(\/.*)?(\?.*)?$/, (route) =>
route.request().method() === 'GET' ? json(route, WF) : json(route, { detail: '检查脚本不写库' }, 409))
await page.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (route) =>
route.request().method() === 'GET' ? json(route, []) : json(route, { id: 'fx-conv3', kind: 'canvas', title: '', turns: [] }))
await page.route(/\/api\/approvals(\/.*)?(\?.*)?$/, (route) =>
route.request().method() !== 'GET' ? json(route, { detail: '检查脚本不写库' }, 409) : json(route, pending ? [APPROVAL] : []))
await page.route(/\/api\/runs(\/.*)?(\?.*)?$/, (route) => {
const req = route.request()
if (req.method() === 'POST' && req.url().includes(`${RUN}/cancel`)) {
cancels.push(req.headers()['x-actor'] ?? '')
return json(route, { ok: true, status: 'cancelled' })
}
if (req.method() === 'GET' && req.url().includes(RUN)) {
return json(route, { id: RUN, workflow_id: WF_ID, status, input: {}, output: {}, error: null, usage: {}, run_class: 'exploratory' })
}
return req.method() === 'GET' ? route.continue() : json(route, { detail: '检查脚本不写库' }, 409)
})
await page.goto(`${WEB}/studio/${WF_ID}`, { waitUntil: 'networkidle' })
await page.waitForFunction((id) => window.__studio?.getState().workflow?.id === id, WF_ID, { timeout: 15000 })
const now = Date.now() / 1000
const ev = (seq, type, node_id, data = {}, t = 0) => ({ seq, type, node_id, data, ts: now - 30 + t })
const WAIT = [
ev(1, 'run.started', null, { nodes: 3 }, 0),
ev(2, 'node.started', 'in', { node_type: 'input', label: '问题' }, 0.1),
ev(3, 'node.finished', 'in', { duration_ms: 3, preview: { q: 'x' } }, 0.2),
ev(4, 'node.started', 'review', { node_type: 'human', label: '主管审批' }, 0.3),
ev(5, 'human.requested', 'review', { kind: 'human_node', node_id: 'review', mode: 'approve', title: '这份报表可以发出吗?' }, 0.4),
ev(6, 'run.interrupted', 'review', { payload: { node_id: 'review', title: '这份报表可以发出吗?' } }, 0.5),
]
const feed = (list) => page.evaluate((l) => { const s = window.__studio.getState(); for (const e of l) s.applyEvent(e) }, list)
await page.evaluate((run) => window.__studio.setState({ run: { id: run, workflow_id: 'fx-stream3', status: 'queued', input: {},
output: {}, error: null, usage: {}, run_class: 'exploratory', version: null }, streaming: true, unsubscribe: () => {} }), RUN)
await feed(WAIT)
await page.waitForTimeout(600)
const panel = page.locator('.sheet-in').first()
check('等人:栏头「去审批」旁边有「放弃这次运行」', await panel.getByRole('button', { name: '放弃这次运行' }).count() === 1)
// 胶囊的「去审批」在右栏处于对话层时派发 agentlab:goto-approval:右栏得自己切过去、把焦点放进审批卡
await panel.getByRole('button', { name: /助手/ }).first().click()
await page.waitForTimeout(300)
check('回到对话层后运行层收起', await page.locator('.sheet-in').count() === 0)
// 右栏条和胶囊同一套说法:停在谁、等了多久
const strip = await page.locator('[data-run-strip]').innerText().catch(() => '')
check('对话层的运行条说停在哪个节点', strip.includes('主管审批'), strip.replace(/\n/g, ' '))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator('[data-assistant-panel]').screenshot({ path: `${SHOTS}/stream-panel-strip-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
// 右栏收起时它还挂着(只是藏起来):那时不接手,让胶囊自己兜底,别切到一层没人看得见的运行层
const unhandled = await page.evaluate((run) => {
const aside = document.querySelector('[data-assistant-panel]').closest('aside')
aside.style.visibility = 'hidden'
const passed = window.dispatchEvent(new CustomEvent('agentlab:goto-approval', { detail: { runId: run }, cancelable: true }))
aside.style.visibility = ''
return passed
}, RUN)
await page.waitForTimeout(200)
check('右栏收起时不接手 goto-approval', unhandled && await page.locator('.sheet-in').count() === 0)
await page.evaluate((run) => window.dispatchEvent(new CustomEvent('agentlab:goto-approval', { detail: { runId: run, nodeId: 'review' } })), RUN)
await page.waitForTimeout(900)
const focusIn = await page.evaluate(() => !!document.activeElement?.closest('[data-approval]'))
check('goto-approval:切到运行层', await page.locator('.sheet-in').count() === 1)
check('goto-approval:焦点落在审批卡里', focusIn)
check('goto-approval:审批卡描了一圈', await page.locator('[data-approval].sf-flash').count() === 1)
if (SHOTS) await page.screenshot({ path: `${SHOTS}/stream-panel-goto-approval-dark.png` })
// 放弃:确认之后调 cancel,带着署名;事件流过来就收成「已取消」,说清是谁放弃的
await page.locator('.sheet-in').first().getByRole('button', { name: '放弃这次运行' }).click()
await page.waitForTimeout(300)
const dialog = await page.locator('[role="dialog"]').innerText().catch(() => '')
check('放弃前先确认,说清后果', dialog.includes('待审批') && dialog.includes('不能再接着跑'), dialog.replace(/\n/g, ' ').slice(0, 160))
await page.locator('[role="dialog"]').getByRole('button', { name: '放弃这次运行' }).click()
await page.waitForTimeout(400)
check('放弃:调了 cancel,带着署名', cancels.length === 1 && decodeURIComponent(cancels[0]) === '张工', JSON.stringify(cancels))
pending = false
status = 'cancelled'
await feed([ev(7, 'run.cancelled', null, { timing: { wall_ms: 30000, active_ms: 500, wait_ms: 29500 }, actor: '张工',
message: '放弃了这次运行,1 条待审批一并关闭' }, 30), ev(8, 'stream.end', null, { status: 'cancelled' }, 30)])
await page.waitForTimeout(500)
const head = await page.locator('.sheet-in [data-turn] .sticky').first().innerText().catch(() => '')
check('放弃:头部写已取消', head.includes('已取消'), head.replace(/\n/g, ' '))
const marks = await page.locator('.sheet-in [data-phase-mark]').allInnerTexts()
check('放弃:分段线写清谁放弃、一并关了什么', marks.some((t) => t.includes('张工') && t.includes('一并关闭')), marks.join(' | '))
check('放弃之后不再给「去审批」和「放弃」', await page.locator('.sheet-in').getByRole('button', { name: /去审批|放弃这次运行/ }).count() === 0)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.screenshot({ path: `${SHOTS}/stream-panel-abandoned-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
// 出具横幅:画布上的印章派发 agentlab:goto-issuance;右栏在对话层时先切过去再滚到横幅
await page.evaluate((run) => {
const s = window.__studio.getState()
s.clearRun()
window.__studio.setState({ run: { id: run, workflow_id: 'fx-stream3', status: 'succeeded', input: {}, output: {
结论: '华东 1,204 单', _issuance: { tier: 'degraded', calibers: [], metrics_checked: 1, matched_numbers: 0,
unmatched_numbers: [{ token: '1,204', context: '华东 1,204 单' }], missing_required: [], missing_expected: [], gaps: [] } },
error: null, usage: {}, run_class: 'exploratory', version: null }, streaming: false, unsubscribe: () => {} })
}, RUN)
await feed([ev(1, 'run.started', null, { nodes: 3 }, 0), ev(2, 'node.started', 'out', { node_type: 'output', label: '成果' }, 0.1),
ev(3, 'issuance', 'out', { tier: 'degraded', unmatched: 1, gaps: [], metrics_checked: 1, matched_numbers: 0 }, 0.2),
ev(4, 'node.finished', 'out', { duration_ms: 2, preview: {} }, 0.3),
ev(5, 'run.finished', null, { output: { 结论: '华东 1,204 单', _issuance: { tier: 'degraded', calibers: [], metrics_checked: 1,
matched_numbers: 0, unmatched_numbers: [{ token: '1,204', context: '华东 1,204 单' }], missing_required: [], missing_expected: [], gaps: [] } },
usage: {}, duration_ms: 400, timing: { wall_ms: 400, active_ms: 400, wait_ms: 0 } }, 0.4)])
await page.waitForTimeout(400)
await page.locator('.sheet-in').first().getByRole('button', { name: /助手/ }).first().click()
await page.waitForTimeout(300)
await page.evaluate((run) => window.dispatchEvent(new CustomEvent('agentlab:goto-issuance', { detail: { runId: run } })), RUN)
await page.waitForTimeout(900)
const banner = await page.evaluate(() => {
const b = document.querySelector('.sheet-in [data-issuance-banner]')
const s = b?.closest('[data-stream-scroll]')?.getBoundingClientRect()
const r = b?.getBoundingClientRect()
return { found: !!b, inView: !!r && !!s && r.top >= s.top - 1 && r.top < s.bottom, focused: !!b && document.activeElement === b }
})
check('goto-issuance:切到运行层、横幅在视野里', banner.found && banner.inView, JSON.stringify(banner))
// 焦点带过去:读屏和键盘用户从画布那头被带过来,得落在横幅上,而不是留在画布的印章上
check('goto-issuance:焦点落在出具横幅上', banner.focused)
// 回执:看过了没改(unchanged)不说「已更新画布」;修过几处数不出来时不写「几 处」
await page.locator('.sheet-in').first().getByRole('button', { name: /助手/ }).first().click()
await page.evaluate(() => {
const s = window.__studio.getState()
s.clearRun()
const base = { explanation: '', error: '', phase: 'done' }
window.__studio.setState({ copilotTurns: [
{ ...base, id: 'u1', instruction: '检查一下有没有问题', outcome: 'unchanged',
ops: [{ op: 'done', explanation: '' }, { op: 'check', status: 'passed', repaired: 0 }, { op: 'final', graph: { nodes: [{ id: 'in' }] } }] },
{ ...base, id: 'u2', instruction: '把循环条件改对', outcome: 'applied', diff: { added: [], changed: ['review'], removed: [], total: 1 },
ops: [{ op: 'update_node', id: 'review' }, { op: 'check', status: 'passed', repaired: 1 }, { op: 'final', graph: { nodes: [{ id: 'in' }] } }] },
] })
})
await page.waitForTimeout(400)
const turns = await page.locator('[data-turn]').allInnerTexts()
check('unchanged:说「看过了,画布没有需要改的地方」', turns[0]?.includes('看过了,画布没有需要改的地方')
&& !turns[0]?.includes('已更新画布'), turns[0]?.split('\n').slice(0, 3).join(' '))
check('修过但数不出几处:不写「几 处」', turns[1]?.includes('自查发现的问题已自动修好') && !turns[1]?.includes('几 处'),
turns[1]?.split('\n').slice(0, 3).join(' '))
// 改图时模型漏写了 tools:回执逐项列出去掉了什么;自查是通过的,「让 Copilot 再修」不管用,撤销是首选
const dropped = { level: 'warning', node_id: 'query', code: 'tools_dropped', field: 'tools',
message: '「数据查询」的工具从 db_query__shop 变成了空:这一轮的要求里没有提到去掉工具' }
await page.evaluate((warn) => {
window.__studio.setState({ copilotTurns: [{ explanation: '', error: '', phase: 'done', id: 'u3', instruction: '只查上月的数据',
outcome: 'applied', diff: { added: [], changed: ['query'], removed: [], total: 1 },
ops: [{ op: 'update_node', id: 'query', label: '数据查询', config: { system: '只查上月' } },
{ op: 'check', status: 'passed', repaired: 0, warnings: [warn] },
{ op: 'final', graph: { nodes: [{ id: 'in' }, { id: 'query' }] }, issues: [warn], tool_changes: [{ node_id: 'query',
label: '数据查询', member: null, field: 'tools', before: ['db_query__shop'], after: [], added: [], removed: ['db_query__shop'] }] }] }] })
}, dropped)
await page.waitForTimeout(400)
const loss = await page.locator('[data-tool-changes]').innerText().catch(() => '')
check('回执逐项列出被去掉的工具', loss.includes('「数据查询」去掉了') && loss.includes('db_query__shop') && loss.includes('一个工具都没有'),
loss.replace(/\n/g, ' '))
const head3 = await page.locator('[data-turn="u3"] .sticky').innerText().catch(() => '')
check('回执头部是提醒,写清几处工具被去掉', head3.includes('1 处工具被去掉了'), head3.replace(/\n/g, ' '))
check('只丢了工具时不给「让 Copilot 再修」,撤销是主按钮',
await page.getByRole('button', { name: /让 Copilot/ }).count() === 0
&& await page.locator('button.btn-primary', { hasText: '撤销这次生成' }).count() === 1)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator('[data-assistant-panel]').screenshot({ path: `${SHOTS}/stream-panel-tools-lost-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
// 模型把工具调用写成文字、判失败:报错和提醒行给「打开『数据查询』的设置」,点了就是那个节点的属性面板
const MARKUP_ERROR = '模型输出了工具调用的原始标记,但没有真正调用工具,这一步一次都没查到数据。常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。到画布里给这个节点绑定要用的工具;绑定了还这样,就换一个支持工具调用的模型'
await page.evaluate((run) => {
window.__studio.setState({ copilotTurns: [] })
window.__studio.setState({ run: { id: run, workflow_id: 'fx-stream3', status: 'running', input: {}, output: {}, error: null,
usage: {}, run_class: 'exploratory', version: null }, streaming: true, unsubscribe: () => {} })
}, RUN)
await feed([ev(1, 'run.started', null, { nodes: 4 }, 0), ev(2, 'node.started', 'query', { node_type: 'agent', label: '数据查询' }, 0.1),
ev(3, 'llm.start', 'query', { model: 'demo-chat' }, 0.2),
ev(4, 'llm.end', 'query', { model: 'demo-chat', duration_ms: 1800, input_tokens: 800, output_tokens: 90, cost_usd: 0.001 }, 2),
ev(5, 'log', 'query', { level: 'warn', code: 'tool_markup_leak', message: '模型把工具调用写成了文字(<||DSML||invoke name="db_query__shop">…),没有真正调用工具,已提醒它重试一次' }, 2.1),
ev(6, 'node.failed', 'query', { error: MARKUP_ERROR, duration_ms: 3900 }, 4),
ev(7, 'run.failed', null, { error: MARKUP_ERROR, node_id: 'query', label: '数据查询', timing: { wall_ms: 4000, active_ms: 4000, wait_ms: 0 } }, 4)])
await page.waitForTimeout(500)
const runPanel = page.locator('.sheet-in').first()
const alertText = await runPanel.locator('[data-turn-error]').innerText().catch(() => '')
check('右栏报错说人话:模型没有真正调用工具', alertText.startsWith('模型没有真正调用工具') && !alertText.includes('DSML'),
alertText.split('\n')[0])
check('提醒行的下一步也给直达入口', await runPanel.locator('[data-step-code="tool_markup_leak"] [data-fix="canvas"]').count() === 1)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator('[data-assistant-panel]').screenshot({ path: `${SHOTS}/stream-panel-markup-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
const openSettings = runPanel.locator('[data-turn-error]').getByRole('button', { name: '打开「数据查询」的设置' })
check('报错给「打开『数据查询』的设置」', await openSettings.count() === 1)
if (await openSettings.count()) {
await openSettings.click()
await page.waitForTimeout(300)
check('点「打开设置」就是那个节点的属性面板', await page.evaluate(() => window.__studio.getState().selectedId) === 'query')
}
check('画布右栏没有运行时报错', errors.length === 0, errors.join(' | '))
await ctx.close()
}
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 助手流渲染全部通过')
process.exit(failed ? 1 : 0)
+371 -9
View File
@@ -51,6 +51,34 @@ const GRAPH = {
}
const V1 = { nodes: GRAPH.nodes.slice(0, 3).concat([GRAPH.nodes[6]]), edges: [edge('start', 'lookup'), edge('lookup', 'gate'), edge('gate', 'done', 'fast')] }
// 查库的 agent 和带成员的协作团队:助手改节点时工具不能被悄悄改没(NI-1),提示词点名的
// 工具没绑定要能定位到工具那一栏(NI-2)。全是通用示例名
const QUERY_TOOLS = ['db_query__shop', 'db_schema__shop']
const TEAM_GRAPH = {
nodes: [
node('start', 'input', 0, 200, '输入', { fields: [{ name: 'question', required: true }] }),
node('fetch', 'agent', 300, 200, '数据查询', {
system: '你是取数助手。', prompt: '先用 db_schema__shop 看表结构,再用 db_query__shop 查 orders 表里每个门店的订单数',
tools: ['db_query__shop'], assign_to: 'rows', max_steps: 8,
}),
node('team', 'supervisor', 600, 200, '复核团队', {
goal: '核对订单数', max_rounds: 4,
agents: [
{ name: 'fetcher', description: '负责查库', system: '只用 SQL 取数', tools: ['db_query__shop'] },
{ name: 'writer', description: '写结论', system: '用 python_exec 算出占比再写结论', tools: [] },
],
}),
node('done', 'output', 900, 200, '成果', { fields: [{ name: 'answer', value: '{{ vars.rows }}' }] }),
],
edges: [edge('start', 'fetch'), edge('fetch', 'team'), edge('team', 'done')],
}
/** 工具都绑好了的那一版:合并语义要守住的就是它 */
const TEAM_BOUND = {
...TEAM_GRAPH,
nodes: TEAM_GRAPH.nodes.map((n) => (n.id === 'fetch'
? { ...n, data: { ...n.data, config: { ...n.data.config, tools: [...QUERY_TOOLS] } } } : n)),
}
const wf = (id, extra) => ({
id, name: extra.name, description: extra.description ?? '检查脚本伪造的工作流', graph: extra.graph ?? GRAPH,
tags: extra.tags ?? [], version: extra.version ?? 3, is_template: !!extra.is_template,
@@ -62,6 +90,8 @@ const FAKES = {
'st-main': wf('st-main', { name: '__studio_check__', version: 3, published_version: 2 }),
'st-gov': wf('st-gov', { name: '__studio_check_gov__', status: 'governed', version: 3, published_version: 3, published_by: '张工' }),
'st-tpl': wf('st-tpl', { name: '__studio_check_tpl__', is_template: true, tags: ['extracted'] }),
'st-team': wf('st-team', { name: '__studio_check_team__', graph: TEAM_BOUND, version: 2, published_version: 2, status: 'published' }),
'st-lint': wf('st-lint', { name: '__studio_check_lint__', graph: TEAM_GRAPH, version: 1 }),
}
const VERSIONS = [
{ id: 'v3', version: 3, note: '', created_at: '2026-09-26T02:00:00Z' },
@@ -182,7 +212,7 @@ async function open({ width = 1440, height = 900, path = '/studio/st-main', pref
return { ctx, page, errors, state }
}
const S = (page, fn) => page.evaluate(fn)
const S = (page, fn, arg) => page.evaluate(fn, arg)
const st = (page) => page.evaluate(() => {
const s = window.__studio.getState()
return { nodes: s.nodes.map((n) => n.id), edges: s.edges.length, selectedId: s.selectedId,
@@ -675,9 +705,10 @@ console.log('=== 助手改图:一步撤销、回执、只回话、失败退回
message: '模型写了一个不存在的节点类型「magic」,这一步已跳过' }] },
]
const past0 = (await st(page)).past
await S(page, () => window.__studio.getState().runCopilot('给快速回答后面加一步润色', true))
const began = await S(page, () => window.__studio.getState().runCopilot('给快速回答后面加一步润色', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const t1 = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
check('开始了就告诉输入框(runCopilot 返回 true)', began === true, String(began))
check('收到 final:这一轮是「已应用」', t1.outcome === 'applied', t1.outcome)
check('diff 记下新增和改动', t1.diff?.added.includes('polish') && t1.diff?.changed.includes('answer'), JSON.stringify(t1.diff))
check('final.issues 进了这一轮(少了一步)', t1.issues?.some((i) => i.code === 'unknown_node_type'))
@@ -767,8 +798,7 @@ console.log('=== 助手改图:一步撤销、回执、只回话、失败退回
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
state.stream = [{ op: 'model', model: 'fake-model' }, { op: 'reply', text: '好' }]
const turnId = await S(page, () => window.__studio.getState().copilotTurns.at(-1).id)
await S(page, (id) => window.__studio.getState().repairWithCopilot(id), turnId).catch(() => null)
await page.evaluate((id) => window.__studio.getState().repairWithCopilot(id), turnId)
await S(page, (id) => window.__studio.getState().repairWithCopilot(id), turnId)
await page.waitForTimeout(400)
const lastBody = state.bodies.at(-1)
check('再修一次:指令里带着剩下的问题', lastBody?.instruction?.includes('还没选工具') && !!lastBody?.base_graph,
@@ -798,7 +828,7 @@ console.log('=== 入口:?run=&focus=、选择器、发布弹窗 ===')
await page.keyboard.type('__studio_check')
await page.waitForTimeout(200)
const rows = await count(page, '[role="listbox"][aria-label="工作流"] > li')
check('选择器能搜索', rows === 3, `${rows} 条`)
check('选择器能搜索', rows === Object.keys(FAKES).length, `${rows} 条`)
check('当前工作流有底色和勾', await count(page, 'li[aria-selected="true"] .bg-accent-solid') === 1)
check('模板行常驻「用它新建」', await count(page, 'li:has-text("__studio_check_tpl__") button:has-text("用它新建")') === 1)
check('标签本地化(extracted → 从运行提取)', await count(page, 'li:has-text("__studio_check_tpl__") :text("从运行提取")') === 1)
@@ -822,7 +852,8 @@ console.log('=== 入口:?run=&focus=、选择器、发布弹窗 ===')
: route.fallback()))
await S(pub.page, () => { const s = window.__studio.getState(); s.updateNode('lookup', { label: '背景检索2' }); s.undo() })
const pb = await S(pub.page, () => { const s = window.__studio.getState(); return { future: s.future.length, fit: s.fitRequest, dirty: s.dirty } })
await pub.page.locator('button:has-text("发布")').first().click()
await pub.page.getByRole('button', { name: '发布', exact: true }).click()
await pub.page.waitForSelector('[role="dialog"] button.btn-primary', { timeout: 5000 }).catch(() => {})
await pub.page.waitForTimeout(300)
await pub.page.locator('[role="dialog"] button.btn-primary').click()
await pub.page.waitForFunction(() => window.__studio.getState().workflow?.status === 'published', null, { timeout: 5000 }).catch(() => {})
@@ -843,17 +874,348 @@ console.log('=== 入口:?run=&focus=、选择器、发布弹窗 ===')
await win.ctx.close()
const gov = await open({ path: '/studio/st-gov' })
await gov.page.locator('button:has-text("发布")').first().click()
// 按名字找:别的按钮(版本标签、提示)也可能带着「发布」两个字
await gov.page.getByRole('button', { name: '发布', exact: true }).click()
await gov.page.waitForSelector('[role="dialog"] [role="radio"]', { timeout: 5000 }).catch(() => {})
await gov.page.waitForTimeout(300)
const radio = await gov.page.locator('[role="radio"][aria-checked="true"]').innerText()
// 只看发布弹窗里的:助手输入框的「在现有图上改 | 从头生成」也是一组 radio
const radio = await gov.page.locator('[role="dialog"] [role="radio"][aria-checked="true"]').innerText()
check('受管工作流的发布默认选「受管」', radio.includes('受管'), radio.slice(0, 20))
check('选项用统一术语(已发布 — 可发起正式运行)', await gov.page.getByText('已发布 — 可发起正式运行').count() === 1)
check('写明以谁的名义发布', await gov.page.getByText('检查脚本').count() >= 1)
await gov.page.locator('[role="radio"]:has-text("已发布")').click()
await gov.page.locator('[role="dialog"] [role="radio"]:has-text("已发布")').click()
check('从受管降级要说出来', await gov.page.getByText(/会降级/).count() === 1)
await gov.ctx.close()
}
// ================================================================ 6b
console.log('=== 助手改节点是合并,不是整体替换(NI-1);出错留下「怎么办」 ===')
{
const { ctx, page, state, errors } = await open({ path: '/studio/st-team' })
// 流在最后一步出错:画布退回这一轮之前,半成品进重做栈。读重做栈顶就是按操作流逐条
// 落上去的样子——final 会拿后端合并好的整张图覆盖,只看 final 看不出前端合得对不对
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'update_node', id: 'fetch', label: '数据查询',
config: { system: '你是取数助手,按月份汇总。', prompt: '按月份统计 orders 的订单数' } },
{ op: 'update_node', id: 'team', config: { goal: '核对按月的订单数', agents: [
{ name: 'fetcher', system: '按月份取数' },
{ name: 'writer', remove: true },
{ name: 'checker', description: '复核口径', tools: ['db_schema__shop'], system: null },
] } },
{ op: 'update_node', id: 'fetch', config: { max_steps: null } },
{ op: 'error', message: '助手这一轮没跑完:等待超时', hint: '换一个响应快的模型再试', detail: 'ReadTimeout: 120s' },
]
await S(page, () => window.__studio.getState().runCopilot('按月份拆一下', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const half = await S(page, () => {
const s = window.__studio.getState()
const nodes = s.future.at(-1)?.nodes ?? []
const cfg = (id) => nodes.find((n) => n.id === id)?.data.config ?? null
return { fetch: cfg('fetch'), team: cfg('team') }
})
const f = half.fetch ?? {}
const members = half.team?.agents ?? []
const fetcher = members.find((m) => m.name === 'fetcher') ?? {}
const checker = members.find((m) => m.name === 'checker') ?? {}
check('只改提示词:agent 的工具一个不少', JSON.stringify(f.tools) === JSON.stringify(QUERY_TOOLS), JSON.stringify(f.tools))
check('写了的键改上去、没写的键保留', f.prompt === '按月份统计 orders 的订单数' && f.assign_to === 'rows',
`${f.prompt} / ${f.assign_to}`)
check('写 null 的键删掉', !('max_steps' in f), JSON.stringify(Object.keys(f)))
check('成员按 name 合并:没写 tools 的保留原来的 tools 和别的字段',
JSON.stringify(fetcher.tools) === '["db_query__shop"]' && fetcher.system === '按月份取数' && fetcher.description === '负责查库',
JSON.stringify(fetcher))
check('{name, remove:true} 删掉成员,新名字接在后面', members.map((m) => m.name).join(',') === 'fetcher,checker',
members.map((m) => m.name).join(','))
check('新成员里写 null 的字段不留', !('system' in checker) && JSON.stringify(checker.tools) === '["db_schema__shop"]',
JSON.stringify(checker))
check('团队没写的顶层键保留', half.team?.max_rounds === 4 && half.team?.goal === '核对按月的订单数')
const cp = await S(page, () => window.__studio.getState().copilot)
check('出错时保留「怎么办」和技术细节(Composer 的错误条要用)',
cp.error.includes('等待超时') && cp.errorHint === '换一个响应快的模型再试' && cp.errorDetail === 'ReadTimeout: 120s',
JSON.stringify({ hint: cp.errorHint, detail: cp.errorDetail }))
const t0 = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
check('这一轮记下开始时刻(助手流头部的实时计时用)', typeof t0.startedAt === 'number'
&& Math.abs(Date.now() - t0.startedAt) < 60_000, String(t0.startedAt))
// 改图回执:后端 final 带回的工具绑定变化。工具集合变小、指令又没让删的,warn 色、可一键撤销
const dropped = {
...TEAM_BOUND,
nodes: TEAM_BOUND.nodes.map((n) => {
if (n.id === 'fetch') return { ...n, data: { ...n.data, config: { ...n.data.config, prompt: '简短一点', tools: [] } } }
if (n.id === 'team') {
return { ...n, data: { ...n.data, config: { ...n.data.config,
agents: n.data.config.agents.map((a) => (a.name === 'fetcher' ? { ...a, tools: [] } : a)) } } }
}
return n
}),
}
const warn = (id, field, message) => ({ level: 'warning', node_id: id, edge_id: null, code: 'tools_dropped', field, message })
const warnings = [
warn('fetch', 'tools', '「数据查询」的工具从 db_query__shop、db_schema__shop 变成了空。这一轮的要求里没有提到去掉工具,确认一下是不是改漏了'),
warn('team', 'agents[0].tools', '「复核团队」的成员「fetcher」的工具从 db_query__shop 变成了空。这一轮的要求里没有提到去掉工具,确认一下是不是改漏了'),
]
const changes = [
{ node_id: 'fetch', label: '数据查询', member: null, field: 'tools', before: QUERY_TOOLS, after: [], added: [], removed: QUERY_TOOLS },
{ node_id: 'team', label: '复核团队', member: 'fetcher', field: 'agents[0].tools', before: ['db_query__shop'], after: [], added: [], removed: ['db_query__shop'] },
]
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'update_node', id: 'fetch', config: { prompt: '简短一点', tools: [] } },
{ op: 'update_node', id: 'team', config: { agents: [{ name: 'fetcher', tools: [] }] } },
{ op: 'done', explanation: '改短了' },
{ op: 'check', status: 'passed', repaired: 0, warnings },
{ op: 'final', graph: dropped, explanation: '改短了', layout: { mode: 'keep', placed: [] },
issues: [...warnings, { level: 'warning', node_id: 'done', edge_id: null, message: '成果字段引用的变量可能为空' }],
tool_changes: changes },
]
await S(page, () => window.__studio.getState().runCopilot('提示词写短一点', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const t1 = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
check('回执记下后端给的工具绑定变化', t1.toolChanges?.length === 2 && t1.toolChanges[0].node_id === 'fetch',
JSON.stringify(t1.toolChanges?.map((c) => c.node_id)))
check('工具被删的提醒单独记,不混进普通校验提示', t1.toolWarnings?.length === 2
&& t1.toolWarnings.every((w) => w.code === 'tools_dropped'), JSON.stringify(t1.toolWarnings?.map((w) => w.node_id)))
check('自查那一步也带着这两条提醒', t1.check?.warnings?.length === 2, JSON.stringify(t1.check?.warnings))
check('记下的 final 操作带着 tool_changes(右栏回执从它解码)',
t1.ops.find((o) => o.op === 'final')?.tool_changes?.length === 2)
const toastBox = page.locator('[role="status"] > div').filter({ hasText: '工具绑定变少了' })
const toastText = (await toastBox.first().innerText().catch(() => '')).replace(/\s+/g, ' ')
check('工具集合变小:回执是 warn 色,逐条列出前后', await toastBox.count() === 1
&& toastText.includes('「数据查询」') && toastText.includes('db_query__shop、db_schema__shop → 空')
&& toastText.includes('fetcher'), toastText.slice(0, 120))
check('工具被删的回执用 warn 色边框', await toastBox.first().evaluate((el) =>
el.getAttribute('style')?.includes('var(--warn)'), null, { timeout: 2000 }).catch(() => false))
await toastBox.first().locator('button:has-text("撤销")').click({ timeout: 2000 }).catch(() => {})
await page.waitForTimeout(250)
const back = await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'fetch').data.config.tools)
check('回执里一键撤销:工具回来了', JSON.stringify(back) === JSON.stringify(QUERY_TOOLS), JSON.stringify(back))
// 老后端的 final 不带 tool_changes:前端自己比对前后两张图
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'update_node', id: 'fetch', config: { tools: ['db_query__shop', 'python_exec'] } },
{ op: 'final', graph: { ...TEAM_BOUND, nodes: TEAM_BOUND.nodes.map((n) => (n.id === 'fetch'
? { ...n, data: { ...n.data, config: { ...n.data.config, tools: ['db_query__shop', 'python_exec'] } } } : n)) },
explanation: '', layout: { mode: 'keep', placed: [] }, issues: [] },
]
await S(page, () => window.__studio.getState().runCopilot('查表结构换成算一下', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const t2 = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
const c2 = t2.toolChanges?.[0]
check('老后端不给 tool_changes:前端自己比对出来', t2.toolChanges?.length === 1 && c2.node_id === 'fetch'
&& c2.added.join() === 'python_exec' && c2.removed.join() === 'db_schema__shop' && c2.member === null,
JSON.stringify(t2.toolChanges))
check('换了一个工具(数量没少)不算被删,没有提醒', !(t2.toolWarnings ?? []).length)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await ctx.close()
// 合并规则本身:和后端 merge_node_config 同一组用例(backend/tests/test_copilot_merge.py)
const unit = await open({ path: '/studio/st-team' })
const r = await unit.page.evaluate(async () => {
const m = await import('/src/canvas/copilotMerge.ts')
const old = { prompt: 'p', tools: ['db_query__shop', 'db_schema__shop'], assign_to: 'rows',
agents: [{ name: '取数员', system: '只用 SQL 取数', tools: ['db_query__shop'] }, { system: '无名', tools: ['x'] }] }
const snapshot = JSON.stringify(old)
const a = m.mergeNodeConfig('agent', old, { assign_to: null, temperature: 0.2 })
const b = m.mergeNodeConfig('agent', old, { tools: ['db_query__shop'] })
const c = m.mergeNodeConfig('supervisor', old, { agents: [
{ name: 'agent1', tools: null }, { name: '核对员', tools: ['db_schema__shop'], note: null }, { name: '取数员', remove: true }] })
// agents 不是列表、或者不是 supervisor:整体替换这一个键(和后端一样只对 supervisor 的成员列表按 name 合并)
const d = m.mergeNodeConfig('agent', old, { agents: [{ name: 'x' }] })
return { a, b, c, d, untouched: JSON.stringify(old) === snapshot }
})
check('合并:null 删键,没写的留着', !('assign_to' in r.a) && r.a.temperature === 0.2 && r.a.tools.length === 2, JSON.stringify(r.a))
check('合并:显式写 tools 就换成新的', r.b.tools.join() === 'db_query__shop')
// 没名字的那个按 agent1 认出来、合并进去:条目里写的 name 也跟着落上(后端同样如此)
check('合并:没名字的成员按 agentN 认;删成员、新名字接在后面', r.c.agents.map((x) => x.name).join() === 'agent1,核对员'
&& !('tools' in r.c.agents[0]) && r.c.agents[0].system === '无名' && !('note' in r.c.agents[1]), JSON.stringify(r.c.agents))
check('合并:不是 supervisor 时 agents 整体替换', JSON.stringify(r.d.agents) === '[{"name":"x"}]')
check('合并:旧 config 不被原地改(比对工具变化还要用它)', r.untouched)
await unit.ctx.close()
}
// ================================================================ 6c
console.log('=== 正式运行期间画布只读:调色板、粘贴、复制、排版、检查器、撤销都拦下 ===')
{
const { ctx, page, errors, state } = await open({ path: '/studio/st-team' })
const layoutCalls = []
page.on('request', (req) => { if (req.url().includes('/copilot/layout')) layoutCalls.push(req.url()) })
const formal = (phase) => page.evaluate((p) => window.__studio.setState((s) => ({
run: { id: 'st-formal-0001', workflow_id: 'st-team', status: p === 'succeeded' ? 'succeeded' : 'running',
run_class: 'formal', version: 2, input: {}, output: {}, error: '', usage: {}, created_at: '2026-09-26T02:00:00Z' },
runPhase: p, trace: { ...s.trace, phase: p, runClass: 'formal' },
})), phase)
// 先选中一个节点、复制下来,再开始正式运行
await S(page, () => window.__studio.getState().select('fetch'))
await blur(page)
await S(page, () => window.__studio.getState().select('fetch'))
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur())
await page.keyboard.press(`${MOD}+c`)
// 助手先改一轮(改成果节点的名字),正式运行开始后再点这一轮的「撤销这次生成」
const renamed = { ...TEAM_BOUND, nodes: TEAM_BOUND.nodes.map((n) => (n.id === 'done'
? { ...n, data: { ...n.data, label: '成果(按月)' } } : n)) }
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'update_node', id: 'done', label: '成果(按月)' },
{ op: 'final', graph: renamed, explanation: '', layout: { mode: 'keep', placed: [] }, issues: [] },
]
await S(page, () => window.__studio.getState().runCopilot('成果改个名字', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const turnId = await S(page, () => window.__studio.getState().copilotTurns.at(-1).id)
await formal('running')
await page.waitForTimeout(200)
const s0 = await st(page)
await page.locator('button:has-text("模型调用"), button[aria-label="添加模型调用"]').first().click({ force: true, timeout: 2000 }).catch(() => {})
await S(page, () => window.__studio.getState().addNode('llm'))
check('调色板点击添加被拦下', (await st(page)).nodes.length === s0.nodes.length)
check('调色板标成不可用并说明为什么', await count(page, '[aria-label^="节点库"] [aria-disabled="true"]') === 1
&& (await page.locator('[aria-label^="节点库"]').innerText()).includes('只读'))
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur())
await page.keyboard.press(`${MOD}+v`)
await page.keyboard.press(`${MOD}+d`)
await page.keyboard.press('Shift+KeyL')
await page.keyboard.press(`${MOD}+z`)
await page.waitForTimeout(400)
const s1 = await st(page)
check('⌘V 粘贴、⌘D 复制、⇧L 排版、⌘Z 撤销都不改图', s1.nodes.length === s0.nodes.length && s1.past === s0.past
&& layoutCalls.length === 0, `${s1.nodes.length}/${s0.nodes.length} 节点 · 排版请求 ${layoutCalls.length}`)
check('按了改图快捷键会说为什么不行', await page.getByText(/正式运行进行中/).count() >= 1)
check('工具栏的撤销、自动排版置灰', await page.getByRole('button', { name: '自动排版' }).isDisabled()
&& await page.getByRole('button', { name: '撤销', exact: true }).first().isDisabled())
// 撤销被锁拦下、画布没动:这一轮不能被标成「已撤回」,回执也不能说撤掉了
const undone = await S(page, (id) => window.__studio.getState().undoCopilotTurn(id), turnId)
const kept = await S(page, (id) => {
const s = window.__studio.getState()
return { outcome: s.copilotTurns.find((t) => t.id === id)?.outcome,
label: s.nodes.find((n) => n.id === 'done')?.data.label }
}, turnId)
check('「撤销这次生成」也拦下,这一轮不被标成已撤回', undone === false && kept.outcome === 'applied'
&& kept.label === '成果(按月)', JSON.stringify({ undone, ...kept }))
await S(page, () => window.__studio.getState().select('fetch'))
await page.waitForTimeout(300)
check('检查器整块只读,并说明原因', await count(page, 'fieldset[disabled]:has([data-field="prompt"])') === 1
&& await page.getByText('只能看,不能改').count() === 1)
await S(page, () => window.__studio.getState().updateNode('fetch', { label: '改不动' }))
check('store 这一层也拦着(直接调 updateNode 不生效)',
(await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'fetch').data.label)) === '数据查询')
const prompt0 = await S(page, () => window.__studio.getState().copilotTurns.length)
// 返回 false:输入框据此留着用户刚写的那句,不能拦下了还把它清掉
const started = await S(page, () => window.__studio.getState().runCopilot('加一步', true))
check('正式运行期间不让助手改图(并告诉输入框没开始)', started === false
&& (await S(page, () => window.__studio.getState().copilotTurns.length)) === prompt0
&& !(await S(page, () => window.__studio.getState().copilot.active)), String(started))
// 跑完就能改了
await formal('succeeded')
await page.waitForTimeout(150)
await S(page, () => window.__studio.getState().addNode('llm'))
check('正式运行结束后恢复编辑', (await st(page)).nodes.length === s0.nodes.length + 1)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
// Shift+1 的名字要和它做的事一致:它回到打开时的取景(从入口看起),不是「看全所有节点」
const fit = await page.evaluate(async () => (await import('/src/canvas/shortcuts.ts')).studioShortcut('fit').label)
check('Shift+1 叫「重新取景」', fit.startsWith('重新取景') && !fit.includes('看全'), fit)
await ctx.close()
}
// ================================================================ 6d
console.log('=== 提示词点名的工具没绑定:问题面板定位到工具那一栏,一键绑定(NI-2);团队用完轮数(NI-5) ===')
{
const { ctx, page, errors } = await open({ path: '/studio/st-lint' })
await page.waitForFunction(() => window.__studio.getState().analysis === 'ok', null, { timeout: 8000 })
const lint = await S(page, () => window.__studio.getState().issues
.filter((i) => /要求用「/.test(i.message)).map((i) => ({ node: i.node_id, field: i.field, msg: i.message.slice(0, 30) })))
check('后端校验报出两处「提示词要求用 X 但没绑定」', lint.length === 2, JSON.stringify(lint))
await page.locator('button[aria-expanded]').filter({ hasText: /错|提示|可运行/ }).first().click()
await page.waitForTimeout(250)
const agentRow = page.locator('[data-problem]').filter({ hasText: '节点没有绑定它' })
check('问题面板里这一条指向「可用工具」', (await agentRow.innerText().catch(() => '')).includes('可用工具'),
(await agentRow.innerText().catch(() => '')).replace(/\s+/g, ' ').slice(-20))
const memberRow = page.locator('[data-problem]').filter({ hasText: '没有给这个成员绑定它' })
check('成员那一条指向「成员「writer」的工具」', (await memberRow.innerText().catch(() => '')).includes('成员「writer」的工具'),
(await memberRow.innerText().catch(() => '')).replace(/\s+/g, ' ').slice(-20))
await agentRow.locator('button').first().click({ timeout: 2000 }).catch(() => {})
await page.waitForTimeout(500)
const tools = page.locator('[data-field="tools"]')
check('点一下:检查器翻到工具那一栏,问题落在那儿', await S(page, () => window.__studio.getState().selectedId) === 'fetch'
&& (await tools.innerText()).includes('db_schema__shop'))
await tools.locator('button:has-text("绑定 db_schema__shop")').click({ timeout: 2000 }).catch(() => {})
await page.waitForTimeout(200)
const bound = await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'fetch').data.config.tools)
check('一键绑定:工具加进这个节点', JSON.stringify(bound) === JSON.stringify(['db_query__shop', 'db_schema__shop']), JSON.stringify(bound))
await memberRow.locator('button').first().click({ timeout: 2000 }).catch(() => {})
await page.waitForTimeout(500)
const member = page.locator('[data-field="agents"] [data-item="1"]')
check('成员那一条落在第 2 个成员的工具下面', (await member.locator('[data-sub="tools"]').innerText({ timeout: 2000 })
.catch(() => '')).includes('没有给这个成员绑定它'))
const inView = await member.locator('[data-sub="tools"]').evaluate((el) => {
const r = el.getBoundingClientRect()
return r.top >= 0 && r.bottom <= window.innerHeight
}, null, { timeout: 2000 }).catch(() => false)
check('镜头滚到那个成员的工具', inView)
await member.locator('button:has-text("绑定 python_exec")').click({ timeout: 2000 }).catch(() => {})
await page.waitForTimeout(200)
const mt = await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'team').data.config.agents[1].tools)
check('一键绑定到那个成员', JSON.stringify(mt) === '["python_exec"]', JSON.stringify(mt))
await page.waitForFunction(() => !window.__studio.getState().issues.some((i) => /要求用「/.test(i.message)), null, { timeout: 6000 }).catch(() => {})
check('绑好之后这两条问题消失', !(await S(page, () => window.__studio.getState().issues.some((i) => /要求用「/.test(i.message)))))
// 数据源的查询工具要能在选择器里挑到:不然「把它加进工具里」这句话做不到
await member.locator('button:has-text("添加")').click({ timeout: 2000 }).catch(() => {})
await page.waitForTimeout(300)
const pick = await member.innerText({ timeout: 2000 }).catch(() => '')
check('工具选择器列出数据源的 db_query__ / db_schema__ 工具', /db_query__/.test(pick) && /db_schema__/.test(pick))
await member.locator('button:has-text("收起")').click({ timeout: 2000 }).catch(() => {})
// NI-5:协作团队用完轮数时怎么收场
await S(page, () => window.__studio.getState().select('team'))
await page.waitForTimeout(300)
const ex = page.locator('[data-field="on_exhausted"]')
const exText = await ex.innerText().catch(() => '')
check('团队有「用完轮数时」,默认判为失败', exText.includes('用完轮数时')
&& String(await ex.locator('select').evaluate((el) => el.options[el.selectedIndex].text, null, { timeout: 2000 })
.catch(() => '')).includes('判为失败'), exText.slice(0, 40))
check('选项说清两者差别', /降档/.test(exText) && /失败/.test(exText) && exText.length > 40)
// 报错和右栏都叫人「调大『最多轮数』」:字段名得是这四个字,照着找得到
check('轮数字段叫「最多轮数」,和报错里的说法一致',
(await page.locator('[data-field="max_rounds"]').innerText().catch(() => '')).includes('最多轮数'))
await ex.locator('select').selectOption({ label: '降档交付' }, { timeout: 2000 }).catch(() => {})
check('选降档交付写进配置', (await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'team').data.config.on_exhausted)) === 'degrade')
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await ctx.close()
}
// ================================================================ 7b
console.log('=== 工作流目录没取回来:不劝人新建(EmptyState source) ===')
{
// 目录还在读、或者读失败时,「还没有工作流 · 新建」是一句假话:库里可能有几十张
for (const mode of ['loading', 'error']) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
const page = await ctx.newPage()
await page.route(/\/api\/workflows(\?.*)?$/, (route) => {
if (route.request().method() !== 'GET') return route.fallback()
return mode === 'loading' ? new Promise(() => {})
: route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ detail: '检查脚本伪造的 500' }) })
})
await page.goto(`${WEB}/studio`, { waitUntil: 'domcontentloaded' })
// 目录一直不回来时,启动屏 5 秒后给「先进去看看」:进去之后才轮到编排页说话
if (mode === 'loading') await page.getByRole('button', { name: '先进去看看' }).click({ timeout: 12000 }).catch(() => {})
const hit = await page.waitForSelector(`[data-empty-unknown="${mode}"]`, { timeout: 8000 }).then(() => true).catch(() => false)
check(mode === 'loading' ? '目录还在读:说「正在读取」' : '目录没取回来:说没取回来、给重新读取', hit
&& (mode === 'loading' ? /正在读取/ : /没取回来/).test(await page.locator('[data-empty-unknown]').innerText()))
check(mode === 'loading' ? '读的时候不劝人新建' : '读失败时不劝人新建',
await page.getByRole('button', { name: /新建工作流/ }).count() === 0)
await ctx.close()
}
}
// ================================================================ 8
console.log('=== 删掉眼前这张 ===')
{
+70
View File
@@ -541,6 +541,76 @@ console.log('\n=== 右栏收尾(decodeRun) ===')
fold([start('甲', 1), start('乙', 2), end('甲', 3, 100), end('乙', 4, 300)]).savedMs === 100)
}
console.log('\n=== 回放时的用量和工具数(逐刻度) ===')
{
// 回放到半路,卡片和 HUD 要写「那一刻」的 tokens 和工具数,而不是拿终值冒充
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 5000 + seq, data })
const events = [
ev(1, 'run.started', null, { nodes: 2 }),
ev(2, 'node.started', 'agent'),
ev(3, 'llm.end', 'agent', { input_tokens: 100, output_tokens: 20, cost_usd: 0.001 }),
ev(4, 'tool.start', 'agent', { tool: 'db_query__shop' }),
ev(5, 'tool.end', 'agent', { tool: 'db_query__shop' }),
ev(6, 'tool.start', 'agent', { tool: 'db_query__shop' }),
ev(7, 'tool.end', 'agent', { tool: 'db_query__shop' }),
ev(8, 'llm.end', 'agent', { input_tokens: 300, output_tokens: 80, cost_usd: 0.004 }),
ev(9, 'node.finished', 'agent', { duration_ms: 7000 }),
ev(10, 'node.started', 'out'), ev(11, 'node.finished', 'out'),
ev(12, 'run.finished', null, { usage: { input_tokens: 400, output_tokens: 100, cost_usd: 0.005 } }),
]
const gr = g(['agent', 'out'], [['agent', 'out']])
const t = run(events, gr)
const at = (seq) => (5000 + seq) * 1000
const p4 = project(t, at(4) + 1)
check('第一次调工具那一刻:1 次工具、1 个在跑、tokens 只有第一轮',
p4.nodes.agent.tools === 1 && p4.nodes.agent.toolsRunning === 1 && p4.nodes.agent.tokensIn === 100
&& p4.nodes.agent.tokensOut === 20,
JSON.stringify(p4.nodes.agent))
const p6 = project(t, at(6) + 0.5)
check('第二次调工具时:2 次,第一次已经回来', p6.nodes.agent.tools === 2 && p6.nodes.agent.toolsRunning === 1,
JSON.stringify(p6.nodes.agent))
const p9 = project(t, at(9) + 1)
check('节点跑完:用量是这个节点的最终值、没有在跑的工具', p9.nodes.agent.tokensIn === 400
&& p9.nodes.agent.tokensOut === 100 && p9.nodes.agent.tools === 2 && p9.nodes.agent.toolsRunning === 0,
JSON.stringify(p9.nodes.agent))
check('回放到开始之前:0,不是终值', project(t, at(1)).nodes.agent.tools === 0
&& project(t, at(1)).nodes.agent.tokensIn === 0)
check('整次运行的用量也按刻度走', p4.tokensIn === 100 && p4.tokensOut === 20 && p9.tokensIn === 400,
`${p4.tokensIn}/${p4.tokensOut} → ${p9.tokensIn}`)
const live = project(t, trace.lastEventAt(t))
check('此刻(实时)读节点的当前值', live.nodes.agent.tokensIn === 400 && live.nodes.agent.tools === 2
&& live.tokensIn === 400)
// 老后端:agent 的调用不发 llm.end,run.finished 的总数比刻度加起来多——回放时那一刻的
// 整次用量不可信,给 undefined,不拿终值冒充
const old = run([...events.slice(0, 11),
ev(12, 'run.finished', null, { usage: { input_tokens: 9000, output_tokens: 900, cost_usd: 0.05 } })], gr)
const op = project(old, at(4) + 1)
check('刻度对不上后端总数:回放时整次用量写不出', op.tokensIn === undefined && op.tokensOut === undefined,
`${op.tokensIn}/${op.tokensOut}`)
check('……实时时照样是后端的总数', project(old, trace.lastEventAt(old)).tokensIn === 9000)
check('没有 tool / llm 事件的节点刻度为空、读数是 0',
t.nodes.out.marks === undefined && project(t, at(11)).nodes.out.tools === 0)
}
console.log('\n=== 最后一条事件的时刻(lastEventAt) ===')
{
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 6000 + seq, data })
const gr = g([{ id: 'in', type: 'input' }, 'boom', 'after'], [['in', 'boom'], ['boom', 'after']])
const t = run([
ev(1, 'run.started', null, { nodes: 3 }),
ev(2, 'node.started', 'in'), ev(3, 'node.finished', 'in'),
ev(4, 'node.started', 'boom'), ev(5, 'node.failed', 'boom', { error: 'x' }),
ev(6, 'run.failed', null, { error: 'x', timing: { wall_ms: 5000, active_ms: 4000, wait_ms: 0 } }),
], gr)
check('lastEventAt 是最后一条事件的 ts(毫秒)', trace.lastEventAt(t) === 6006 * 1000, `${trace.lastEventAt(t)}`)
const settled = project(t, trace.lastEventAt(t))
check('投影到这一刻 = 此刻:推导出的阻断还在', settled.nodes.after?.state === 'blocked', settled.nodes.after?.state)
check('……后端给的权威时长也在', settled.elapsedMs === 5000 && settled.activeMs === 4000,
`${settled.elapsedMs}/${settled.activeMs}`)
check('早一毫秒就是按段重建的回放(阻断不在)', project(t, trace.lastEventAt(t) - 1).nodes.after?.state !== 'blocked')
check('空航迹的 lastEventAt 是 0', trace.lastEventAt(emptyTrace()) === 0)
}
console.log('\n=== 性能 ===')
{
// 256 段的大循环(真实运行里见过):一次折叠 + 投影都要快,拖游标才不卡
+200 -7
View File
@@ -1,5 +1,7 @@
// 基础组件(components/ui.tsx)、前端工具库(lib/*)和连接状态(store/catalog.ts)
// 的回归检查。
// 基础组件(components/ui.tsx)、前端工具库(lib/*)、连接状态(store/catalog.ts)
// 和 api/client 的上传进度的回归检查。管理页共用件(页头、连通胶囊、单选组、删除
// 后撤销)和失败说明(lib/explain)也在这里:它们从页面挪进了公共层,页面级检查
// 只走得到其中一两种情况。
//
// 这几样是全站的地基,坏了不会在哪一页上报错,只会悄悄变样:弹窗的焦点漏到页面
// 上、组字时按 Esc 把半屏草稿关掉、toast 压住工具栏按钮、后端一抖整站说「还没有
@@ -105,6 +107,11 @@ for (const [name, ok, detail] of await page.evaluate(() => {
eq('相对时间:59.6 分钟进位到小时档', ago(59.6 * 60_000), '1 小时前')
ok('相对时间:23.6 小时不写「24 小时前」', !/24 小时前/.test(ago(23.6 * 3_600_000)), ago(23.6 * 3_600_000))
eq('短 id', f.shortId('66a5a6ff0011'), '#66a5a6')
eq('文件大小:字节', f.formatBytes(820), '820 B')
eq('文件大小:KB', f.formatBytes(12_700), '12.4 KB')
eq('文件大小:MB', f.formatBytes(3.1 * 1024 * 1024), '3.1 MB')
eq('文件大小拿不到写 —', f.formatBytes(undefined), '—')
eq('短名去掉括号里的补充', f.shortLabel('OpenAI 兼容(DeepSeek、通义…)'), 'OpenAI 兼容')
// 状态
eq('succeeded = 已完成', st.statusLabel('succeeded'), '已完成')
@@ -157,6 +164,29 @@ for (const [name, ok, detail] of await page.evaluate(() => {
ok('isNetworkError 对 HTTP 错误为假', !e.isNetworkError(new ApiError(500, 'NetworkError when attempting to fetch resource')))
ok('测连接结果里的 ECONNREFUSED 不算连不上后端',
e.humanizeError({ ok: false, error: 'connect ECONNREFUSED 10.0.0.5:5432' }).title !== '连不上后端服务')
// 已经是「标题:原因」的中文人话,不再套一个「操作超时」、把整句当原因(标题说两遍)
const zh = e.humanizeError(new ApiError(504, '数据库查询超时:超过 30 秒没有返回,已中断。缩小查询范围后重试。'))
ok('中文人话里的「超时」不套「操作超时」', zh.title !== '操作超时' && !(zh.reason ?? '').includes('操作超时')
&& !(zh.reason && zh.reason.includes(zh.title)), JSON.stringify(zh))
eq('英文 timeout 原文照样翻成「操作超时」', e.humanizeError('ReadTimeout: timed out').title, '操作超时')
ok('……原因里不带异常类名', !/ReadTimeout/.test(e.humanizeError('ReadTimeout: timed out').reason ?? ''))
// 失败说明(lib/explain):记录页、助手流、问数据页共用一份
const x = window.__ui.lib.explain
const leak = x.explainRunError('模型输出了工具调用的原始标记,但没有真正调用工具。常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。')
ok('工具调用标记泄漏:说人话、不给接着跑、指到画布', leak.title === '模型没有真正调用工具' && !leak.continuable && leak.fix === 'canvas', JSON.stringify(leak))
const tired = x.explainRunError('NodeError: 协作团队用完 4 轮仍未完成:还缺汇总员的结论')
ok('团队轮数用完:标题带轮数,原因是调度者最后的理由', tired.title === '协作团队用完 4 轮仍未完成'
&& tired.reason === '还缺汇总员的结论' && !tired.continuable, JSON.stringify(tired))
const made = x.explainRunError('校验修复无效:修复时出现了原文没有的值:total_count=0')
ok('校验修复编造数值:点出是哪个值、不给接着跑', made.reason.includes('total_count=0') && !made.continuable, JSON.stringify(made))
eq('提示词点名的工具没绑定', x.explainRunError('提示词要求用 db_query__shop,但节点没有绑定它').title,
'提示词要求用「db_query__shop」,但节点没有绑定它')
eq('老运行的鉴权失败', x.explainRunError('NodeError: AuthenticationError: Error code: 401 - invalid_api_key').title, '模型鉴权没通过')
const miss = x.explainRunError('缺少必填输入:region')
ok('缺必填输入:给出是哪一项、重新发起', miss.missingInput === 'region' && miss.fix === 'rerun' && !miss.continuable)
eq('没留原因', x.explainRunError(null).title, '运行失败,但没有留下原因')
ok('兜底:句中的异常类名也去掉', !/ValueError/.test(x.explainRunError('结构不对:ValueError: bad').title))
return out
})) check(name, ok, detail)
@@ -305,12 +335,13 @@ await page.waitForTimeout(100)
check('出错的在 assertive 区', (await page.locator('[role="alert"][aria-live="assertive"]').innerText()).includes('保存失败'))
check('普通的在 polite 区', (await page.locator('[role="status"][aria-live="polite"]').first().innerText()).includes('已复制到剪贴板'))
check('出错的能复制', (await page.getByRole('button', { name: '复制详情' }).count()) >= 1)
// 位置:内容区(让出 56px 导航)居中,顶边在 48px 工具栏之下
// 位置:内容区(让出 64px 的导航,App 里的 w-16)居中,顶边在 48px 工具栏之下
const NAV_W = 64
const stackBox = await page.locator('[role="alert"][aria-live="assertive"]').boundingBox()
const vw = page.viewportSize().width
check('toast 在工具栏下沿之下(≥ 56px)', stackBox.y >= 56, `top=${Math.round(stackBox.y)}`)
check('toast 在内容区里居中', Math.abs(stackBox.x + stackBox.width / 2 - (56 + vw) / 2) < 4,
`中线 ${Math.round(stackBox.x + stackBox.width / 2)},内容区中线 ${(56 + vw) / 2}`)
check('toast 在内容区里居中', Math.abs(stackBox.x + stackBox.width / 2 - (NAV_W + vw) / 2) < 2,
`中线 ${Math.round(stackBox.x + stackBox.width / 2)},内容区中线 ${(NAV_W + vw) / 2}`)
const infoCard = page.getByText('已复制到剪贴板')
await infoCard.hover()
await page.waitForTimeout(4600)
@@ -339,14 +370,24 @@ const emptyBlock = page.locator('[data-block="空态 EmptyState(模拟离线
await page.click('#toggle-offline')
await page.waitForTimeout(100)
check('横幅出现', (await page.getByText('后端未连接').count()) === 1)
check('空态改说拿不到数据', (await emptyBlock.getByText('暂时拿不到数据').count()) === 1)
// 横幅早就挂着(连着时什么都不画),计时器是这一刻才走起来的:头一个数得按此刻算
const firstSecs = Number((await page.getByText(/秒后自动重试/).innerText()).match(/\d+/)?.[0])
check('刚断开时倒计时从此刻算(8 秒)', firstSecs === 8 || firstSecs === 7, `横幅 ${firstSecs} 秒`)
check('空态改说拿不到数据', (await emptyBlock.getByText('暂时拿不到数据').count()) >= 1)
check('离线空态不许诺「会自动刷新」', (await emptyBlock.getByText(/自动刷新/).count()) === 0)
check('离线时收起「新建」', (await page.getByRole('button', { name: '新建工作流' }).count()) === 0)
check('本地空态不受影响', (await page.getByText('没有匹配的工具').count()) === 1)
// role=alert 整块重念:倒计时一秒一跳,放在里面读屏就一秒念一遍整条横幅
const bannerAlert = page.locator('[data-offline-banner] [role="alert"]')
check('横幅外层不是 role=alert', (await page.locator('[data-offline-banner]').getAttribute('role')) === null)
check('横幅的播报区只有不变的那几句', (await bannerAlert.count()) === 1
&& (await bannerAlert.innerText()).includes('后端未连接') && !/秒后自动重试/.test(await bannerAlert.innerText()),
await bannerAlert.innerText().catch(() => ''))
check('倒计时对读屏隐藏', (await page.getByText(/秒后自动重试/).getAttribute('aria-hidden')) === 'true')
// 横幅把工具栏往下推了多少,toast 就跟着让多少
await page.click('#toast-error')
await page.waitForTimeout(80)
const bannerH = (await page.locator('[role="alert"]', { hasText: '后端未连接' }).boundingBox()).height
const bannerH = (await page.locator('[data-offline-banner]').boundingBox()).height
const offTop = (await page.locator('[role="alert"][aria-live="assertive"]').boundingBox()).y
check('离线时 toast 再让出横幅的高度', Math.abs(offTop - (56 + bannerH)) < 2, `top=${Math.round(offTop)},横幅 ${Math.round(bannerH)}px`)
await page.evaluate(() => window.__ui.toast.dismiss())
@@ -364,6 +405,158 @@ const wantAria = await page.evaluate(() => window.__ui.lib.keys.ariaShortcut('Mo
const gotAria = await page.getByRole('button', { name: '刷新列表' }).getAttribute('aria-keyshortcuts')
check('IconButton 的 aria-keyshortcuts 按平台写', gotAria === wantAria && !/Ctrl|Mod/.test(gotAria), gotAria)
console.log('\n=== 管理页共用件:页头、连通胶囊、单选组、删除后撤销 ===')
check('页头 48px 高', Math.round((await page.locator('#page-header-demo header').boundingBox()).height) === 48)
const pills = await page.locator('#health-demo [data-health]').evaluateAll((els) => els.map((el) => el.textContent))
// 「3 分钟前」是从预览页挂载时算的,跑到这里可能已经过了一分钟
check('连通胶囊五种说法', pills[0] === '未测试' && /^正在测 · /.test(pills[1]) && /^已连通 · 42 ms · [34] 分钟前测$/.test(pills[2])
&& pills[3] === '连不上 · 1 小时前测' && /已连通 · 380 ms.*配置改过了/.test(pills[4]), pills.join(' | '))
check('不知道测的时刻:不留一个悬空的「 · 」', pills[5] === '连不上', JSON.stringify(pills[5]))
const spoken = await page.locator('#health-demo [role="status"]').evaluateAll((els) => els.map((el) => el.textContent))
check('读屏那一句不带跳动的计时和相对时间', spoken[1] === '正在测连接' && /^已连通 42 ms,\d\d:\d\d 测的$/.test(spoken[2]), spoken.join(' | '))
const radios = page.locator('#radio-demo [role="radio"]')
check('单选组只占一个 Tab 位', JSON.stringify(await radios.evaluateAll((els) => els.map((el) => el.tabIndex))) === '[0,-1,-1]')
const checkedRadio = () => page.locator('#radio-demo [role="radio"][aria-checked="true"]').innerText()
const focusedRadio = () => page.evaluate(() => document.activeElement?.textContent)
await radios.first().focus()
await page.keyboard.press('ArrowRight')
check('→ 选中下一项并聚焦', (await checkedRadio()) === 'sid' && (await focusedRadio()) === 'sid')
await page.keyboard.press('End')
check('End 到最后一项', (await checkedRadio()) === 'dsn')
await page.keyboard.press('ArrowDown')
check('↓ 首尾相接', (await checkedRadio()) === 'service_name')
await page.keyboard.press('ArrowUp')
await page.keyboard.press('Home')
check('↑ 往回、Home 到第一项', (await checkedRadio()) === 'service_name' && (await focusedRadio()) === 'service_name')
await page.keyboard.press('Alt+ArrowRight')
check('带 Alt 的方向键不接(那是浏览器后退 / 前进)', (await checkedRadio()) === 'service_name')
await page.getByRole('tab', { name: '模型接入' }).focus()
await page.keyboard.press('End')
check('标签页:End 到最后一个', (await page.getByRole('tab', { name: '偏好' }).getAttribute('aria-selected')) === 'true')
await page.keyboard.press('Home')
let deletes = 0
page.on('request', (r) => { if (r.method() === 'DELETE') deletes++ })
const rowShown = (id) => page.locator(`#defer-demo [data-row="${id}"]`).count()
const undoBtn = page.locator('[role="status"][aria-live="polite"]').getByRole('button', { name: '撤销' })
await page.getByRole('button', { name: '删除 orders' }).click()
await page.waitForTimeout(80)
check('删除:行先拿掉,toast 给「撤销」', (await rowShown('orders')) === 0
&& (await page.getByText('已删除「orders」').count()) === 1 && (await undoBtn.count()) === 1)
await page.click('#defer-reload')
await page.waitForTimeout(50)
check('撤销窗口里重拉列表,删掉的行不回来', (await rowShown('orders')) === 0)
await undoBtn.click()
await page.waitForTimeout(80)
check('撤销:行回来了,一个 DELETE 都没发', (await rowShown('orders')) === 1 && deletes === 0)
await page.evaluate(() => window.__ui.toast.dismiss())
console.log('\n=== 空态:catalog 的表没取回来时不说「还没有」 ===')
const sourceEmpty = page.locator('#empty-source-demo')
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
check('取回来了:照常说「还没有工作流」、给新建', (await sourceEmpty.getByText('还没有工作流').count()) === 1
&& (await sourceEmpty.locator('#empty-source-action').count()) === 1)
const realCatalog = await page.evaluate(() => {
const s = window.__ui.useCatalog.getState()
return { loadedAt: s.loadedAt, checks: s.checks }
})
await page.evaluate(() => window.__ui.useCatalog.setState({
loadedAt: {}, checks: [{ key: 'workflows', label: '工作流', state: 'pending' }],
}))
await page.waitForTimeout(50)
check('从没取回来过、还在路上:说「正在读取工作流」、收起新建', (await sourceEmpty.getByText('正在读取工作流').count()) === 1
&& (await sourceEmpty.locator('#empty-source-action').count()) === 0
&& (await sourceEmpty.locator('[data-empty-unknown="loading"]').getAttribute('role')) === 'status')
await page.evaluate(() => window.__ui.useCatalog.setState({
checks: [{ key: 'workflows', label: '工作流', state: 'error', error: '后端 15 秒没有响应,稍后重试' }],
}))
await page.waitForTimeout(50)
check('超时或报错:说「工作流没取回来」和原因、给重新读取', (await sourceEmpty.getByText('工作流没取回来').count()) === 1
&& (await sourceEmpty.getByText(/15 秒没有响应.*这里显示为空不代表没有数据/).count()) === 1
&& (await sourceEmpty.getByRole('button', { name: '重新读取' }).count()) === 1
&& (await sourceEmpty.locator('#empty-source-action').count()) === 0)
await page.evaluate((c) => window.__ui.useCatalog.setState(c), realCatalog)
console.log('\n=== catalog:一张表卡住不拖累别的表 ===')
// /tools 连得上但不回(MCP 服务不应答时就是这样):以前 Promise.all 等它,工作流也一直空着
const toolsBefore = await page.evaluate(() => window.__ui.useCatalog.getState().tools.length)
// unroute 要拿同一个函数引用才摘得掉
const isTools = (u) => new URL(u).pathname === '/api/tools'
await page.route(isTools, () => { /* 永远不回 */ })
const expectWf = await fetch(`${API}/workflows`).then((r) => r.json()).then((l) => l.length, () => null)
await page.evaluate(() => window.__ui.useCatalog.setState({ workflows: [] }))
const tHang = Date.now()
let settled = false
const hung = page.evaluate(() => window.__ui.useCatalog.getState().refresh()).then(() => { settled = true })
await page.waitForFunction(() => window.__ui.useCatalog.getState().workflows.length > 0, null, { timeout: 8000 }).catch(() => {})
let cat = await page.evaluate(() => {
const s = window.__ui.useCatalog.getState()
return { wf: s.workflows.length, wfAt: s.loadedAt.workflows, tools: s.checks.find((c) => c.key === 'tools')?.state }
})
if (expectWf) {
check('工作流先回来先填,不等卡住的工具', cat.wf === expectWf && cat.wfAt >= tHang && cat.tools === 'pending',
`${JSON.stringify(cat)},后端有 ${expectWf} 个工作流`)
}
// 最多等 20 秒:没有超时的话 refresh 永远不落定,检查自己也不能跟着挂死
await Promise.race([hung, new Promise((r) => setTimeout(r, 20_000))])
const hangMs = Date.now() - tHang
check('refresh 在超时之后落定', settled, `${hangMs}ms 还没落定`)
cat = await page.evaluate(() => {
const s = window.__ui.useCatalog.getState()
const c = s.checks.find((x) => x.key === 'tools')
return { state: c?.state, error: c?.error, backend: s.backend, tools: s.tools.length, loaded: s.loaded }
})
check('卡住的那张 15 秒超时,记成出错并说清', cat.state === 'error' && /15 秒/.test(cat.error ?? '') && hangMs > 14_500 && hangMs < 18_000,
`${cat.state} ${cat.error} ${hangMs}ms`)
check('一张表超时不算后端断开', cat.backend === 'ok', cat.backend)
check('超时不清空手上已有的那份', cat.tools === toolsBefore, `${toolsBefore} → ${cat.tools}`)
await page.unroute(isTools)
console.log('\n=== 上传进度(kb.upload 走 XHR) ===')
const isUpload = (u) => new URL(u).pathname === '/api/kb/upload'
const uploadVia = async (handler) => {
await page.unroute(isUpload)
await page.route(isUpload, handler)
}
const tryUpload = (size) => page.evaluate(async (size) => {
const events = []
try {
const doc = await window.__ui.api.kb.upload(new File([new Uint8Array(size)], 'orders.csv', { type: 'text/csv' }), 'default',
{ onProgress: (p) => events.push(p) })
return { events, doc }
} catch (e) {
return { events, err: { isApi: e instanceof window.__ui.ApiError, status: e.status, kind: e.kind, message: e.message } }
}
}, size)
// 上传进度只有真的走网络才有(page.route 直接 fulfill 时浏览器不发进度事件)。
// 转给 dev server 上一个不存在的地址:字节真发出去,回 404,什么都不写
await uploadVia((r) => r.continue({ url: `${WEB}/__upload_probe__` }))
let up = await tryUpload(300_000)
const lastUp = up.events.at(-1)
check('字节发完报 sent,已发 = 总数,且不小于文件大小', !!lastUp?.sent && lastUp.loaded === lastUp.total && lastUp.total >= 300_000,
JSON.stringify(up.events.slice(-2)))
check('sent 只在最后', up.events.filter((p) => p.sent).length === 1 && !up.events.slice(0, -1).some((p) => p.sent))
check('非 JSON 的错误响应:http 类的 ApiError', up.err?.isApi && up.err.kind === 'http' && up.err.status === 404, JSON.stringify(up.err))
await uploadVia((r) => r.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ id: 'doc_demo', collection: 'default', title: 'orders.csv', source: 'upload', mime: 'text/csv', chunk_count: 0, status: 'processing' }),
}))
up = await tryUpload(10)
check('成功:返回值照常解析', up.doc?.id === 'doc_demo', JSON.stringify(up.err ?? up.doc))
await uploadVia((r) => r.fulfill({
status: 409, contentType: 'application/json', body: JSON.stringify({ detail: '同名文档已经在处理:等它处理完再传' }),
}))
up = await tryUpload(10)
check('后端拒绝:和 fetch 那条路一样的 ApiError,message 是 detail', up.err?.isApi && up.err.status === 409
&& up.err.kind === 'http' && up.err.message === '同名文档已经在处理:等它处理完再传', JSON.stringify(up.err))
await uploadVia((r) => r.abort('connectionrefused'))
up = await tryUpload(10)
check('连不上:network 类的 ApiError', up.err?.isApi && up.err.kind === 'network' && up.err.status === 0, JSON.stringify(up.err))
await page.unroute(isUpload)
// 网络失败会顺手探一次活,等它落定再往下,免得下一段的探活计数多一次
await page.evaluate(() => window.__ui.useCatalog.getState().checkBackend())
console.log('\n=== 连接状态:心跳、退避、恢复 ===')
const expectRuns = await fetch(`${API}/runs?limit=3`).then((r) => r.json()).then((r) => r.length, () => null)
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())