Files
AgentLab/scripts/check-ui.mjs
T
ThearchyHelios 542bcb65bb 一次对话有自己的地址了:/chat/<chatID>,运行、画布、各页标签同理
在此之前 react-router 只管到页面一级,「页面内部在看哪个」全在 store 和
localStorage 里,全站一处 useParams / useSearchParams / useNavigate 都没有。
后果:

- 一次对话没有地址。想把「这个结论是怎么查出来的」发给同事,只能发截图。
- 刷新回到上次那个对话靠 localStorage,换台机器、换个浏览器就丢。
- 浏览器前进/后退在页面内部彻底失效——切了五个对话,后退一下直接离开问数据页。
- /settings 永远落在「模型接入」,指不到数据源那一屏。

    /chat/:conversationId     /studio/:workflowId     /runs/:runId
    /knowledge/:tab           /tools/:tab             /settings/:tab

每页写两条显式路由(带 id / 不带 id)而不是可选参数 `:id?`:「没带 id」是真要
单独处理的一种情况(落到上次那个、或列表第一个),藏在一个问号里下次就没人
记得它存在了。

**URL 驱动 store,不是反过来。** 两边都能写就一定会打架——点一下列表,store
改了、地址栏没动,刷新又跳回旧的。所以 useConversations.currentId 降级成 URL
的派生缓存,唯一写入口是 ChatPage 的那个同步 effect;列表里的切换、新建、删除
一律改成导航。conversations.load() 不再自作主张决定「当前看哪个」,
remove() 改成返回「接下来该去哪」交给调用方。

localStorage 留着,但它现在只回答另一个问题:**打开不带 id 的 /chat 该去哪**。

**指向不存在的东西**要优雅:等列表加载完再判(在那之前"找不到"是假的,照着它
跳会让每次深链接进入都先闪一下错误提示),落到一个真实的,并说明为什么换了
地方。RunsPage 特别一点——列表只取前 100 条,而一条老运行的链接必须也能打开,
所以详情按 id 直接取,不在列表里找。

**画布多两道闸。** 选择器里本来就有"未保存改动"的确认,但那道闸在它的 onClick
里,而前进/后退会绕过选择器直接换图——改了一半的画布就这么没了。URL→画布那个
effect 里补一道,取消就把地址退回去。

过程中实测抓到三个问题,都修了:
- 同一个坏地址弹三次"那张工作流不在了":effect 在请求回来之前又跑了两遍,
  加了在途去重(chat 那边同样的毛病弹两次)。
- 「在画布里打开」现在直接把人送去 /studio,而那是一张还没存、没有 id 的草稿图。
  不带 id 就落到第一张图的逻辑会拿第一张把它盖掉,还会先弹一句莫名其妙的
  "有未保存的改动"——加了 nodeCount 判断让开。这个坑其实原来就在,只是以前
  要手动点导航才踩得到。
- 删掉当前打开的那张图之后地址还指着它,刷新会撞上"不在了"。

顺带:三个 tab 页共用一个 useTabRoute(放在 Tabs 旁边,tab 和它的地址是同一件
事,拆两处迟早各改各的),认不出的 tab 名回落并把地址一起纠正过来。
「在画布里打开」不再是 toast 让用户自己去点导航,直接送过去。

验证:tsc 干净;后端 249 passed(没碰后端,跑一遍确认没带坏);
check-decode / check-stream 不回归;check-ui 新增「URL 指得到」一节 19 条,
真浏览器里验了深链接、切换改地址、刷新、后退、坏地址、tab 直达、
草稿图不被盖掉、越过列表上限的老运行。
2026-09-20 14:09:40 +08:00

428 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 三个页面的端到端检查:真浏览器、真后端、真数据。
//
// check-decode 守翻译,check-stream 守渲染,这一层守的是"接起来还对不对"——
// 前两层都能通过而这一层坏掉:store 没把事件存进去、tab 切换把状态卸载了、
// 唯一的运行入口被改版顺手删了。
//
// 跑之前前后端都得起着:./scripts/dev.sh
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const CHROME = process.env.CHROME_PATH
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const SHOTS = process.env.SHOT_DIR ?? ''
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const browser = await chromium.launch({ executablePath: CHROME })
const ctx = await browser.newContext({ viewport: { width: 1500, height: 940 } })
/** 打开一页并收集真实报错(favicon 的 404 不算) */
async function visit(path) {
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
page.on('console', (m) => {
if (m.type() === 'error' && !m.text().includes('404')) errors.push(m.text())
})
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(700)
return { page, errors }
}
const shot = async (page, name) => {
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png` })
}
console.log('=== 画布助手栏 ===')
{
const { page, errors } = await visit('/studio')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
const body = await page.locator('body').innerText()
check('不再有两层 tab 嵌套',
!body.includes('时间线') && !body.includes('原始事件'))
// 用户的原话:"没有一个很好的地方引导用户写问题"。空态下输入区就该是主体
check('空态给出了明确的邀请', body.includes('想让它做什么'))
check('说清楚它能够到什么', /\d+ 个工具/.test(body), body.match(/\d+ 个工具/)?.[0])
const examples = await page.locator('aside button').filter({ hasText: /。|,/ }).count()
check('例句是完整句子而不是截断的 chip', examples >= 2, `${examples} 条`)
const composer = page.locator('aside textarea').first()
check('输入框自动聚焦,不用先点一下',
await composer.evaluate((el) => el === document.activeElement))
// 改版最容易顺手删掉的东西:唯一的运行入口
check('发起运行的入口还在(已搬到工具栏)',
await page.getByRole('button', { name: /^运行/ }).count() > 0)
// 这是这次重构的核心承诺:属性是盖在助手上的一层,不是把它换掉。
// 切 tab 会卸载组件,草稿、滚动位置、展开状态全丢——那正是要修的问题
await composer.fill('测试草稿不要丢')
await page.locator('.react-flow__node').first().click()
await page.waitForTimeout(350)
const sheet = await page.locator('body').innerText()
check('选中节点滑出属性面板', sheet.includes('节点名称') || sheet.includes('ID:'))
check('面板上有明确的返回,让人知道底下还有东西',
await page.getByTitle('返回助手(Esc)').count() > 0)
await page.keyboard.press('Escape')
await page.waitForTimeout(350)
check('Esc 关得掉', await page.getByTitle('返回助手(Esc)').count() === 0)
check('回来之后输入的字还在', await composer.inputValue() === '测试草稿不要丢',
await composer.inputValue())
await composer.fill('')
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await shot(page, 'studio')
await page.close()
}
console.log('\n=== 变量表与模板补全 ===')
{
const { page, errors } = await visit('/studio')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await page.getByRole('button', { name: /^变量/ }).click()
await page.waitForTimeout(700)
const rows = await page.locator('table tbody tr').count()
check('变量表列出了变量', rows > 0, `${rows} 行`)
const body = await page.locator('body').innerText()
check('列出的是可直接粘的写法', body.includes('{{ input.'), '')
// 抽屉是挤压式的:画布不能被压没,React Flow 容器高度归零会直接报错
const canvasH = await page.locator('.react-flow').first()
.evaluate((el) => el.getBoundingClientRect().height)
check('画布没有被压没', canvasH > 120, `${Math.round(canvasH)}px`)
await page.getByRole('button', { name: /^变量/ }).click()
await page.waitForTimeout(400)
// 补全:模板取不到值会静默渲染成空字符串,补全是从源头消灭这类错误。
// 默认那张图只有 输入→成果,没有带模板的字段,先从面板拖一个模型调用出来
await page.locator('text=模型调用').first().click()
await page.waitForTimeout(700)
// 必须限定在属性面板里:底下助手栏的 Copilot 输入框也是 aside textarea,
// 而它被这一层盖着,点它会一直超时
const field = page.locator('.sheet-in textarea').first()
await field.click()
await page.keyboard.type('{{')
await page.waitForTimeout(400)
const hasPanel = (await page.locator('body').innerText()).includes('⏎ 插入')
check('打 {{ 弹出候选', hasPanel)
if (hasPanel) {
await page.keyboard.press('Enter')
await page.waitForTimeout(350)
const v = await field.inputValue()
check('选中后插入成完整写法', /\{\{ \S+ \}\}/.test(v), v.slice(0, 40))
// 完全受控组件,插入后光标会被重置到末尾,必须自己放回去
await page.keyboard.type('X')
check('光标停在插入内容之后', (await field.inputValue()).endsWith('X'),
(await field.inputValue()).slice(-20))
}
await shot(page, 'variables')
await page.close()
}
console.log('\n=== 动效对前庭敏感者可关 ===')
{
// 这套界面里动的东西不少(边在流动、节点在脉冲、面板在滑)。
// 系统里关了动效还照播,对前庭敏感的人是实打实的难受
const page = await ctx.newPage()
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
const durations = await page.evaluate(() => {
const probe = document.createElement('div')
probe.className = 'rise-in'
document.body.appendChild(probe)
const d = getComputedStyle(probe).animationDuration
probe.remove()
return d
})
check('关掉动效后动画确实停了', parseFloat(durations) < 0.01, durations)
await page.close()
}
console.log('\n=== 运行历史 ===')
{
const { page, errors } = await visit('/runs')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
const rows = page.locator('button').filter({ hasText: /\d{4}\/\d{1,2}\/\d{1,2}/ })
const n = await rows.count()
check('列出了运行记录', n > 0, `${n} 条`)
if (n > 0) {
// 列表原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样
const texts = await rows.allInnerTexts()
const withSummary = texts.filter((t) => t.trim().split('\n').length >= 3).length
check('行里有内容摘要而不只是耗时和 token', withSummary > 0,
`${withSummary}/${texts.length} 行带摘要`)
await rows.first().click()
await page.waitForTimeout(900)
const body = await page.locator('body').innerText()
check('详情走的是可读视图,不是事件表',
!/\bnode\.(started|finished)\b/.test(body) && !/\brun\.started\b/.test(body))
check('详情有内容', body.length > 300, `${body.length} 字`)
// 原始事件不是常驻 tab,但必须还能看到
await page.locator('button[title*="原始事件"]').first().click()
await page.waitForTimeout(400)
const rawBody = await page.locator('body').innerText()
check('切得到原始事件', /node\.(started|finished)|run\.started/.test(rawBody))
await page.locator('button[title*="可读视图"]').first().click()
await page.waitForTimeout(300)
check('切得回来', !/\bnode\.started\b/.test(await page.locator('body').innerText()))
}
await shot(page, 'runs')
await page.close()
}
console.log('\n=== 问数据 ===')
{
const { page, errors } = await visit('/chat')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
const body = await page.locator('body').innerText()
check('有空态或历史,不是白屏', body.length > 60, `${body.length} 字`)
check('有输入框', await page.locator('textarea').count() > 0)
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await shot(page, 'chat')
await page.close()
}
console.log('\n=== 向量模型设置 ===')
{
// 以前这里只有三个写死的选项(本地 / OpenAI 3-small / 3-large),接不了
// 本机起的服务。而本地哈希向量没有语义能力这件事,界面上也得说出来。
const { page, errors } = await visit('/knowledge')
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
let body = await page.locator('body').innerText()
check('显示当前用的是什么、几维', /·\s*\d+\s*维/.test(body),
body.match(/[\w:.-]+ · \d+ 维/)?.[0])
if (body.includes('local-hashing')) {
check('本地哈希要标出没有语义能力', body.includes('没有语义能力'))
}
await page.getByRole('button', { name: '换一个' }).first().click()
await page.waitForTimeout(400)
body = await page.locator('body').innerText()
check('能配自定义端点', body.includes('自定义端点'))
check('也留着本地那一项', body.includes('本地哈希向量'))
// 模型名手填太容易错,必须能探
check('给得出探测入口', body.includes('看看有哪些模型'))
const overflow = await page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await shot(page, 'embedding')
await page.close()
}
console.log('\n=== 会话 ===')
{
// 对话以前只活在内存里,刷新就没了。这一组守的是它真的落了库:
// 列表能列出来、切过去内容跟着变、刷新还在。
//
// 最后一项守的是一个真踩过的坑:进页面时自动建一条空会话,而 create 是
// 异步的、StrictMode 又把 effect 跑两遍,于是每次访问都留下两三条"新对话"。
const before = await (await fetch(`${API}/conversations`)).json()
const { page, errors } = await visit('/chat')
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
check('有"新对话"按钮', await page.getByRole('button', { name: /新对话/ }).first().isVisible())
const seeded = before.find((c) => c.turn_count > 0)
if (seeded) {
await page.locator('aside button[title]').filter({ hasText: seeded.title }).first().click()
await page.waitForTimeout(700)
check('切过去能看到那次问的话', (await page.locator('main').innerText()).includes(seeded.title))
await page.reload({ waitUntil: 'networkidle' })
await page.waitForTimeout(900)
check('刷新后还在', (await page.locator('main').innerText()).includes(seeded.title))
} else {
check('(跳过:库里还没有带轮次的会话)', true)
}
const after = await (await fetch(`${API}/conversations`)).json()
check('逛一圈没有凭空多出空会话', after.length <= before.length,
`${before.length} → ${after.length}`)
await shot(page, 'conversations')
await page.close()
}
console.log('\n=== URL 指得到 ===')
{
// 在此之前「在看哪个对话/哪次运行/哪一屏」全在 store 和 localStorage 里:
// 一次对话没有地址,发给同事只能发截图;刷新靠 localStorage,换台机器就丢;
// 浏览器后退会直接离开整个页面而不是回到上一个对话。
const convs = await (await fetch(`${API}/conversations`)).json()
const seeded = convs.filter((c) => c.turn_count > 0)
if (seeded.length >= 2) {
const [a, b] = seeded
const { page, errors } = await visit(`/chat/${a.id}`)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
check('深链接直达那个对话', (await page.locator('main').innerText()).includes(a.title),
`想要「${a.title}」`)
check('地址里就是那个 chatID', page.url().endsWith(`/chat/${a.id}`), page.url())
// 点另一个:地址要跟着走,而不是只改 store
await page.locator('aside button[title]').filter({ hasText: b.title }).first().click()
await page.waitForTimeout(600)
check('切换对话会改地址', page.url().endsWith(`/chat/${b.id}`), page.url())
// 刷新靠的是 URL,不是 localStorage
await page.reload({ waitUntil: 'networkidle' })
await page.waitForTimeout(800)
check('刷新后还在同一个对话', page.url().endsWith(`/chat/${b.id}`)
&& (await page.locator('main').innerText()).includes(b.title))
// 后退回到上一个对话,而不是甩出整个页面——这条以前是彻底失效的
await page.goBack({ waitUntil: 'networkidle' })
await page.waitForTimeout(700)
check('后退回到上一个对话', page.url().endsWith(`/chat/${a.id}`), page.url())
await shot(page, 'url-chat')
await page.close()
} else {
check('(跳过:库里不足两个带轮次的会话)', true)
}
{
// 链接过期/对话被删:不能白屏,也不能一声不响地跳走
const { page } = await visit('/chat/这个id根本不存在')
const body = await page.locator('body').innerText()
check('指向不存在的对话时不白屏', body.length > 40, `${body.length} 字`)
check('落到了一个真实对话', /\/chat\/[0-9a-f]{8,}$/.test(page.url()), page.url())
// 认整句提示,不认"不在了"三个字——会话标题里碰巧有这仨字就会把它蒙对
const notice = body.includes('那个对话不在了')
check('说清楚了为什么换了地方', notice,
notice ? '' : body.slice(-200).replace(/\n/g, ' '))
await page.close()
}
{
// tab 也得能指:/settings 永远落在「模型接入」,以前指不到数据源那一屏
const { page } = await visit('/settings/datasources')
check('深链接直达数据源那一屏', (await page.locator('body').innerText()).includes('数据源'))
await page.close()
const bare = await visit('/settings')
check('不带 tab 时落到第一屏并纠正地址',
bare.page.url().endsWith('/settings/providers'), bare.page.url())
await bare.page.close()
const bogus = await visit('/knowledge/没这个tab')
check('认不出的 tab 名回落而不是空白',
bogus.page.url().endsWith('/knowledge/kb'), bogus.page.url())
await bogus.page.close()
}
{
// 画布也该能指。这条路最容易出事:前进/后退会绕过选择器里那道
// "未保存改动"的确认直接换图
const wfs = await (await fetch(`${API}/workflows`)).json()
if (wfs.length >= 2) {
const bare = await visit('/studio')
check('/studio 落到第一张图并纠正地址',
bare.page.url().endsWith(`/studio/${wfs[0].id}`), bare.page.url())
await bare.page.close()
const { page } = await visit(`/studio/${wfs[1].id}`)
check('深链接直达那张图', (await page.locator('body').innerText()).includes(wfs[1].name),
`想要「${wfs[1].name}」`)
await page.close()
} else {
check(`(跳过:库里只有 ${wfs.length} 张工作流)`, true)
}
const { page } = await visit('/studio/根本没这张图')
const body = await page.locator('body').innerText()
// 说一次就够。这里真弹过三次——effect 在请求回来之前又跑了两遍
const times = (body.match(/那张工作流不在了/g) || []).length
check('指向不存在的图时说一次并让开', times === 1 && /\/studio\/[0-9a-f]{8,}$/.test(page.url()),
`提示 ${times} 次,落在 ${page.url()}`)
await page.close()
}
{
// 问数据页的「在画布里打开」:现在会直接把人送去 /studio。
// 送过去的是一张还没存、没有 id 的草稿图,所以那边的"不带 id 就落到
// 第一张图"必须让开——不让开就会拿第一张图把它盖掉,而且因为 setGraph
// 标了脏,还会先弹一句莫名其妙的"有未保存的改动"
const convs = await (await fetch(`${API}/conversations`)).json()
let seed = null
for (const c of convs.slice(0, 8)) {
const d = await (await fetch(`${API}/conversations/${c.id}`)).json()
const t = (d.turns || []).find((x) => x.graph?.nodes?.length)
if (t) { seed = { id: c.id, want: t.graph.nodes.length }; break }
}
if (seed) {
const page = await ctx.newPage()
let dialogs = 0
page.on('dialog', async (d) => { dialogs++; await d.dismiss() })
await page.goto(`${WEB}/chat/${seed.id}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(900)
const btn = page.getByRole('button', { name: '在画布里打开' }).first()
if (await btn.count()) {
await btn.click()
await page.waitForTimeout(1600)
check('「在画布里打开」会把人送到画布', page.url().endsWith('/studio'), page.url())
check('送过去的草稿图没被第一张工作流盖掉',
(await page.locator('.react-flow__node').count()) === seed.want,
`${await page.locator('.react-flow__node').count()} / ${seed.want} 个节点`)
check('没有弹莫名其妙的"未保存改动"', dialogs === 0, `弹了 ${dialogs} 次`)
} else {
check('(跳过:这一轮没有「在画布里打开」)', true)
}
await page.close()
} else {
check('(跳过:前 8 个会话里没有带图的轮次)', true)
}
}
{
// 运行列表只取前 100 条,而一条老运行的链接必须也能打开——
// 所以详情是按 id 直接取的,不是在列表里找
const all = await (await fetch(`${API}/runs?limit=200`)).json()
if (all.length > 100) {
const old = all[all.length - 1]
const { page } = await visit(`/runs/${old.id}`)
const body = await page.locator('body').innerText()
check('第 100 条之后的老运行也打得开', body.includes(old.id.slice(0, 6)),
`run ${old.id.slice(0, 8)}`)
await page.close()
} else {
check(`(跳过:库里只有 ${all.length} 条运行,不足以验证越过列表上限)`, true)
}
}
}
console.log('\n=== 三处讲的是同一个故事 ===')
{
// 同一次运行,运行页详情和 preview 里的 dense 渲染应该给出同一批步骤。
// 这是"全站一个翻译层"的实际含义——分两套的话用户没法判断哪个是真的。
const a = await ctx.newPage()
await a.goto(`${WEB}/preview.html?case=loop_approve`, { waitUntil: 'networkidle' })
await a.waitForTimeout(400)
const wide = await a.locator('body').innerText()
const asks = (wide.match(/这条公告可以发吗/g) ?? []).length
// 宽栏和窄栏各渲染一遍,所以是 3×2
check('宽窄两栏各三轮审批,一轮不少', asks === 6, `${asks} 处`)
check('决定折进了同一行而不是另起一行',
!/^你放行了$/m.test(wide) && !/^你驳回了$/m.test(wide))
await a.close()
}
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 界面端到端全部通过')
process.exit(failed ? 1 : 0)