Files
AgentLab/scripts/check-ui.mjs
T
ThearchyHelios 3de7567377 右栏重构:助手常驻,属性改为盖上去的一层
两个问题都源于同一件事——一栏 360px 被要求同时干两件活。

**没地方引导写问题。** Copilot 输入框是个 38px 高的框,挤在一条长长的流
底下,比它上面任何一块都不起眼;例句被截断成 16 个字的 chip,示范"该说到
什么颗粒度"这个作用就没了,只剩装饰。改成一个组件两种体量:没说过话时它
就是这一栏的主体(竖直居中、三行高、自动聚焦、完整句子的例句),并且告诉
用户它能够到什么(N 个数据源 / M 个工具)——不然"查销售库"这种话有没有意义
只能靠猜。有了对话之后它退到底部两行。不做成两个组件:那就是两个实例,
切换时会卸载其中一个,草稿、光标、输入法候选词全没。

**Copilot 和属性挤在一起。** 它们原是 [助手|属性] 两个 tab,而 tab 是互斥的
——跑图跑到一半点开节点看配置,整条执行过程就从眼前消失,切回来滚动位置和
输入草稿也没了(切 tab 会卸载组件)。改成属性滑出来盖在助手上:助手一直在
下面活着,属性是临时造访者。头部给一个明确的"‹ 助手",Esc 关得掉;盖住
期间底部留一条执行进度,点一下回去——不能因为换了实现就把原来最难受的
那个场景原样留着。

运行控件搬去工具栏。它原先和 Copilot 输入框一起常驻助手栏,两个都是"主要
动作"互相压着;而它们是两种意图(跑这张图 / 改这张图)。运行是对整张图的
动作,和保存、发布同类。有输入字段就弹表单,没有就一键直接跑。

顺带修了两处状态说反的地方:
- 停在人工介入时,承载的节点还在转蓝圈。转圈说的是"它在忙,你等着",而
  实际正相反——它在等你。改成待办色 + 手势图标。
- "继续执行"的去重原先靠"界面上还有没有 waiting 步骤",上一条改动多了一处
  产生 waiting 的地方就失效了。改用事件相邻性判断——那是这两条事件的固有
  性质,不会因为别处改了展示而变。

动效:面板滑入、例句依次入场、正在出字的那一步有一道光扫过(比转圈多说
一件事:它在产出,不只是在等)、空态标记轻微呼吸。全部 240ms 以内,缓动
统一 cubic-bezier(.32,.72,0,1)。另外补上了此前完全没有的
prefers-reduced-motion——这套界面里动的东西不少(边在流动、节点在脉冲、
面板在滑),系统里关了还照播,对前庭敏感的人是实打实的难受。

验证:check-ui 重写了助手栏那一节,核心断言是"选中节点再回来,输入的字
还在"——那正是 tab 方案做不到的。新增一条用 emulateMedia 验证关掉动效后
动画确实停了。真跑了一次带人工审批的图,期间打开属性面板确认进度条还在。
四套检查 + 82 个后端测试全过,生产构建通过。
2026-09-18 00:05:22 +08:00

176 lines
7.7 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 = 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)