Files
AgentLab/scripts/check-ui.mjs
T
ThearchyHelios 1444c774cf 模板字段加 {{ }} 补全:从源头消灭那类静默失败
模板取不到值时渲染成空字符串、不报错,所以拼错一个变量名在运行时完全静默。
上一个 commit 是事后校验(标红+建议),这个是事前:打 {{ 就弹出这张图里
实际有的变量,每条带说明和产出它的节点。

**只挂在真正过模板渲染的字段上。** 这一条是整件事最容易做错的地方:
这套里有五个字段长得像但走的是表达式求值——skip_if、loop.condition、
cases[].condition、transform.expression、metrics[].expression——那里要写
`vars.count == 0` 这样的裸路径,不带大括号。在那些字段上挂 {{ 触发器等于
手把手教用户写错。同理 supervisor 的 agents[].system 长得和 llm.system
一模一样,但后端是 cfg.get("system") 直接取值、根本不渲染,那里弹补全会
是个谎;它走的是 AgentList 里的裸 textarea,天然不受影响。

几个不写就会坏的细节:

- 这是完全受控组件。插入后 onChange 会一路走到 store 再把 value 灌回来,
  DOM 光标会跳到末尾——必须等那一帧过去再自己 setSelectionRange 放回去。
  测试里专门验了"插入后继续打字,字落在后面而不是中间"。
- 候选框用文档流里的块,不是绝对定位浮层:属性面板是 overflow-y-auto、
  外层 InspectorSheet 是 overflow-hidden,浮层到边缘直接被切掉。
- 用 onMouseDown 不是 onClick——onClick 在 blur 之后才触发,那时候弹层
  已经关了。
- Escape 要 stopPropagation:外面 InspectorSheet 也在听 Escape,不拦的话
  按一下会连整个属性面板一起关掉。
- 光标前已经打了 | 或 [ 就不再弹——那时候是在写过滤器或下标,不是在选变量。

check-ui 补一组:变量表列得出来、抽屉挤压后画布没被压没(React Flow 容器
高度归零会直接报 error 004)、打 {{ 弹候选、插入成完整写法、光标位置正确。
2026-09-18 10:55:56 +08:00

221 lines
9.8 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 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=== 三处讲的是同一个故事 ===')
{
// 同一次运行,运行页详情和 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)