mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:41:59 +00:00
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)、打 {{ 弹候选、插入成完整写法、光标位置正确。
221 lines
9.8 KiB
JavaScript
221 lines
9.8 KiB
JavaScript
// 三个页面的端到端检查:真浏览器、真后端、真数据。
|
||
//
|
||
// 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)
|