Files
AgentLab/scripts/check-ui.mjs
T
ThearchyHelios 3e311ec551 第三波收尾:跨模块整合、全站扫尾、检查脚本整合;网页工具认得本机代理的 fake-ip
整合(3c-integrate)
- 公共层:跨天时长(formatSpan / formatOffset / formatLapse)、本机署名(lib/actor)、检索分类色令牌、
  数据源目录(useDatasources)收进公共层;失败说明能一次列出多个写坏的自定义工具,查询超时指到数据源的查询时限
- 管理页收尾:设置页接入统一的离开前确认;检查脚本不再把沙箱里的真实值打进输出
- 画布 ↔ 右栏:点出具印章、点节点能跳到右栏对应位置;「定位」直接聚焦到出问题的字段
- 回答流 / 问数据 / 记录页:失败信息带「去哪修」且不再被翻译两遍;协作团队的结局按执行截取;
  等审批时断流重连、而运行已在空档里跑完的,补全事件

扫尾(3c-sweep)
- 术语统一:开始运行 / 继续运行、工作流最大步数;未保存的运行统一叫「未保存的工作流」(后端新运行同步)
- 沙箱报错不再带 Python 异常类名;新增 check-tokens,未定义的 CSS 变量为 0,两个旧别名迁移后删除

检查脚本(3c-checks)
- check-ui 跟上新界面(运行列表按 data 属性找行),加上令牌与选中态的回归断言,输出里的名字打码
- 新增 check-all:按顺序跑全部检查、汇总退出码,--repeat 列出偶发项
- 单节出错只记这一节失败、不再带崩整个脚本;外壳遥测浮层「重新检测」时焦点不再掉到 body

网页工具与 fake-ip
- Clash / mihomo 一类代理开 TUN + fake-ip 时,查什么都先拿到 198.18 段的假地址,被当成内网拦截。
  新增 AGENTLAB_HTTP_TOOL_FAKE_IP_RANGES(默认空):落在段里的解析结果改向公共 DNS(DoH)核实真实地址,
  公网放行、内网照拦、核实不了按拦截处理。DoH 默认先问境外的,境内公共 DNS 会对被封锁的域名返回污染结果
- 列表型环境变量接受逗号分隔(原来注释说逗号分隔、实际只认 JSON 数组);域名解析改为异步,不再阻塞事件循环

验证(沙箱):tsc 干净,pytest 866 passed(两遍),检查脚本全绿。
2026-09-27 23:37:32 +08:00

741 lines
38 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)。默认连 5273 / 8000;对别的实例(比如一份
// 沙箱拷贝)跑时带上地址:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-ui.mjs
// 只跑其中几节:CHECK_ONLY=运行历史,样式层叠(按节名包含匹配)。SHOT_DIR=<目录> 时各节存一张截图。
//
// 读的是真数据,写一律拦在浏览器里:非 GET 的接口请求只放行纯计算的校验和变量分析,
// 其余的要么由那一节自己伪造回应,要么直接掐掉。所以这份检查对哪个库跑都不留痕迹。
import { readFileSync } from 'node:fs'
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 ?? ''
const ONLY = process.env.CHECK_ONLY?.split(',').map((x) => x.trim()).filter(Boolean)
// 仓库是公开的,这份输出会被贴进报告:跑的是真数据,对话标题、工作流名、节点名、
// 文档标题、数据源名一律换成占位符再打印。本脚本源码里自己写着的串(夹具)本来就公开,不换
const ownSource = readFileSync(new URL(import.meta.url), 'utf8')
const listOf = async (path) => {
try { const r = await fetch(API + path); return r.ok ? await r.json() : [] } catch { return [] }
}
const secrets = []
for (const c of await listOf('/conversations?kind=chat&include_archived=true')) secrets.push([c.title, '‹对话标题›'])
for (const w of await listOf('/workflows')) {
secrets.push([w.name, '‹工作流名›'])
for (const n of w.graph?.nodes ?? []) secrets.push([n.data?.label ?? n.label, '‹节点名›'])
}
for (const d of await listOf('/kb/documents')) secrets.push([d.title, '‹文档标题›'])
for (const d of await listOf('/datasources')) secrets.push([d.name, '‹数据源名›'], [d.description, '‹数据源描述›'])
const maskList = [...new Map(secrets
.filter(([v]) => typeof v === 'string' && v.trim().length >= 2 && !ownSource.includes(v))).entries()]
.sort((a, b) => b[0].length - a[0].length)
const masked = (t) => maskList.reduce((out, [v, tag]) => out.split(v).join(tag), String(t))
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${masked(detail)}` : ''}`)
if (!cond) failed++
}
const browser = await chromium.launch({ executablePath: CHROME })
const ctx = await browser.newContext({ viewport: { width: 1500, height: 940 } })
// 写操作的闸门。各节自己用 page.route 伪造的接口优先于这里(页面级路由先匹配),
// 漏到这里的非 GET 请求记下来再掐掉:逛页面本身会触发哪些写,一眼看得见
const COMPUTE_ONLY = [/\/api\/workflows\/(validate|variables)$/]
const blocked = []
const blockedUrls = new Set()
await ctx.route('**/api/**', (route) => {
const r = route.request()
const path = new URL(r.url()).pathname
if (['GET', 'HEAD'].includes(r.method()) || COMPUTE_ONLY.some((re) => re.test(path))) return route.continue()
blocked.push(`${r.method()} ${path}`)
blockedUrls.add(r.url())
return route.abort('blockedbyclient')
})
/**
* 一节一节地跑:某一节里元素找不到、等待超时,只记成这一节失败,关掉它开的页面,
* 接着跑下一节——不让一处卡住把后面几十项一起吞掉
*/
const opened = new Set()
async function section(name, fn) {
if (ONLY?.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${name} ===`)
const mark = blocked.length
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const p of opened) await p.close().catch(() => {})
opened.clear()
}
// 页面自己发出、被闸门掐掉的写请求:逛页面就想写库的地方都在这里,列出来备查,不算失败
const writes = [...new Set(blocked.slice(mark).map((w) => w.replace(/[0-9a-f]{32}/g, '…')))]
if (writes.length) console.log(` · 拦下的写请求:${writes.join('、')}`)
}
/** 开一页并收集真实报错。favicon 的 404 不算,被上面的闸门掐掉的写请求也不算 */
async function newPage() {
const page = await ctx.newPage()
opened.add(page)
const errors = []
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
page.on('console', (m) => {
if (m.type() !== 'error' || m.text().includes('404')) return
if (blockedUrls.has(m.location()?.url)) return
errors.push(m.text())
})
return { page, errors }
}
async function visit(path) {
const { page, errors } = await newPage()
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` })
}
const overflowOf = (page) => page.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth)
/** 等一句提示出现(toast 约 4 秒就走,固定等一段再数会错过),再留一小段看它有没有重复 */
async function countNotice(page, text, settle = 1200) {
const seen = await page.getByText(text).first().waitFor({ timeout: 8000 }).then(() => true, () => false)
if (!seen) return 0
await page.waitForTimeout(settle)
return ((await page.locator('body').innerText()).match(new RegExp(text, 'g')) ?? []).length
}
await section('画布助手栏', async () => {
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 overflowOf(page)
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await shot(page, 'studio')
})
await section('变量表与模板补全', async () => {
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 section('动效对前庭敏感者可关', async () => {
// 这套界面里动的东西不少(边在流动、节点在脉冲、面板在滑)。
// 系统里关了动效还照播,对前庭敏感的人是实打实的难受
const { page } = await 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 section('运行历史', async () => {
const { page, errors } = await visit('/runs')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
// 行是按天分组的按钮,时间只写「11:36」「9/25 20:52」,所以按结构认,不按日期格式认
const rows = page.locator('[data-runs-list] [data-run-id]')
await rows.first().waitFor({ timeout: 10000 }).catch(() => {})
const n = await rows.count()
if (!n && !(await listOf('/runs?limit=1')).length) {
check('(跳过:库里还没有运行记录)', true)
return
}
check('列出了运行记录', n > 0, `${n} 条`)
if (!n) return
// 列表原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样。现在第二行是这一条
// 自己的事:成功的写摘要,失败的写原因,等人的写等谁
const withContext = await rows.evaluateAll((els) =>
els.filter((el) => el.querySelectorAll(':scope > div').length >= 3).length)
check('行里有这一条自己的内容,不只是耗时和 token', withContext > 0, `${withContext}/${n} 行带第二行`)
const first = rows.first()
const id = await first.getAttribute('data-run-id')
await first.click()
await page.waitForURL(`**/runs/${id}**`, { timeout: 5000 }).catch(() => {})
check('点开一条,地址跟着换成它', new URL(page.url()).pathname === `/runs/${id}`, page.url())
check('列表里标着正在看的是哪一条', await page.locator(`[data-runs-list] [data-run-id="${id}"][aria-current="true"]`)
.waitFor({ timeout: 4000 }).then(() => true, () => false))
const pane = page.locator('[data-view-pane="stream"]')
await pane.waitFor({ timeout: 8000 }).catch(() => {})
await pane.locator('[data-turn]').first().waitFor({ timeout: 6000 }).catch(() => {})
await page.waitForTimeout(400)
const body = await pane.innerText().catch(() => '')
check('详情走的是可读视图,不是事件表',
!/\bnode\.(started|finished)\b/.test(body) && !/\brun\.started\b/.test(body))
// 按结构认,不按字数:最新一条可能只是个「hi」,正确的视图也就三十几个字。
// 有轮次、轮次头说得出状态、跑过节点的就列得出节点行
const view = await pane.evaluate((el) => ({
turns: el.querySelectorAll('[data-turn]').length,
status: [...el.querySelectorAll('[data-turn-status]')].map((s) => s.textContent.trim()),
nodes: el.querySelectorAll('[data-node-id]').length,
})).catch(() => ({ turns: 0, status: [], nodes: 0 }))
const ran = (await listOf(`/runs/${id}/events?after=0`)).some((e) => e.type === 'node.started')
check('详情有内容:轮次、状态、节点行都在',
view.turns > 0 && view.status.length > 0 && view.status.every(Boolean) && (!ran || view.nodes > 0),
`${view.turns} 轮 · 状态「${view.status.join('、')}」· ${view.nodes} 个节点行${ran ? '' : '(这条没跑到节点)'}`)
// 原始事件不是常驻 tab,但必须还能看到
const raw = page.locator('[data-action="raw"]')
await raw.click()
const rawShown = await page.locator('[data-raw-events]').waitFor({ timeout: 4000 }).then(() => true, () => false)
check('切得到原始事件', rawShown && /node\.(started|finished)|run\.started/.test(await pane.innerText()))
check('开关说得出现在是开着的', await raw.getAttribute('aria-pressed') === 'true')
await raw.click()
await page.waitForTimeout(300)
check('切得回来', await page.locator('[data-raw-events]').count() === 0
&& !/\bnode\.started\b/.test(await pane.innerText()))
await shot(page, 'runs')
})
await section('问数据', async () => {
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 overflowOf(page)
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await shot(page, 'chat')
})
await section('向量模型设置', async () => {
// 以前这里只有三个写死的选项(本地 / 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 overflowOf(page)
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await shot(page, 'embedding')
})
await section('向量模型:配了语义模型却退回本地', async () => {
// 配的是本机 LM Studio 上的模型,服务启动时它没起,进程就一直用本地哈希。
// 以前页面按"配的是不是 local"判断:不说没有语义能力,还高亮"重建索引"——
// 那些"对不上"的向量正是那个模型建好的,一点就被哈希覆盖。真实环境撞不撞得上
// 这个状态看运气,所以这里伪造状态接口,几种情况各看一遍;写请求一律拦下
const embeddingPage = async (initial, onPut) => {
let status = initial
const { page, errors } = await newPage()
const sent = []
await page.route('**/api/**', (route) => {
const r = route.request()
if (new URL(r.url()).pathname.endsWith('/api/kb/embedding')) {
if (r.method() === 'GET') return route.fulfill({ json: status })
if (r.method() === 'PUT') {
sent.push(r.postDataJSON())
status = onPut(status)
return route.fulfill({ json: {
embedder: status.embedder, dim: status.dim,
stale_chunks: status.stale_chunks, stale_memories: status.stale_memories } })
}
}
return r.method() === 'GET' ? route.continue() : route.abort()
})
await page.goto(`${WEB}/knowledge`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
return { page, errors, sent, body: () => page.locator('body').innerText() }
}
const down = {
embedder: 'local-hashing', dim: 512, configured: true, kind: 'openai',
model: 'text-embedding-qwen3-embedding-4b', base_url: 'http://127.0.0.1:1234/v1',
stale_chunks: 68, stale_memories: 5, unindexed_chunks: 0,
has_semantics: false, default_alpha: 0, fallback: true,
fallback_reason: '用不了 http://127.0.0.1:1234/v1 上的 embedding 模型 '
+ 'text-embedding-qwen3-embedding-4b:APIConnectionError: Connection error.。检查服务在不在',
}
const up = {
...down, embedder: 'openai:text-embedding-qwen3-embedding-4b', dim: 2560,
stale_chunks: 0, stale_memories: 0, has_semantics: true, default_alpha: 0.5,
fallback: false, fallback_reason: '',
}
const a = await embeddingPage(down, () => up)
let body = await a.body()
check('退回本地时标出没有语义能力', body.includes('没有语义能力'))
check('说清配的是哪个、为什么没用上',
body.includes('text-embedding-qwen3-embedding-4b') && body.includes('没连上'))
check('没连上的原因原样给出来', body.includes('APIConnectionError'))
check('不再劝人重建(会把建好的向量覆盖掉)',
await a.page.getByRole('button', { name: /重建索引/ }).count() === 0)
check('存量提示改口成先别重建', body.includes('先别重建'))
const reconnect = a.page.getByRole('button', { name: '重新连接' })
check('给得出「重新连接」', await reconnect.count() === 1)
if (await reconnect.count()) {
await reconnect.click()
await a.page.waitForTimeout(800)
}
const req = a.sent[0]
check('重新连接发的是保存着的那份配置',
req?.kind === 'openai' && req.model === down.model && req.base_url === down.base_url,
JSON.stringify(req))
body = await a.body()
check('连上了说一声', body.includes('已重新连上'))
check('连上之后提示消失', !body.includes('没有语义能力') && !body.includes('没连上'))
check('没有运行时报错', !a.errors.some((e) => e.startsWith('pageerror')), a.errors.slice(0, 2).join(' | '))
await a.page.close()
// 明确选了本地哈希是正常情况:照旧标没有语义能力,重建按钮也还在
const b = await embeddingPage(
{ ...down, kind: 'local', model: '', base_url: '', fallback: false, fallback_reason: '' },
(s) => s)
body = await b.body()
check('明确选了本地哈希:照旧标没有语义能力', body.includes('没有语义能力'))
check('……不冒出"没连上"', !body.includes('没连上'))
check('……重建按钮还在,正常情况别一起藏了',
await b.page.getByRole('button', { name: /重建索引/ }).count() === 1)
})
await section('会话', async () => {
// 对话以前只活在内存里,刷新就没了。这一组守的是它真的落了库:
// 列表能列出来、切过去内容跟着变、刷新还在。
//
// 最后一项守的是一个真踩过的坑:进页面时自动建一条空会话,而 create 是
// 异步的、StrictMode 又把 effect 跑两遍,于是每次访问都留下两三条"新对话"。
// 写请求被闸门掐掉了,库里的条数不会变,所以直接数页面有没有想去建
const before = await listOf('/conversations')
const mark = blocked.length
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 creates = blocked.slice(mark).filter((w) => /^POST \/api\/conversations$/.test(w))
check('逛一圈没有想凭空建会话', creates.length === 0, `${creates.length} 次`)
await shot(page, 'conversations')
})
await section('URL 指得到', async () => {
// 在此之前「在看哪个对话/哪次运行/哪一屏」全在 store 和 localStorage 里:
// 一次对话没有地址,发给同事只能发截图;刷新靠 localStorage,换台机器就丢;
// 浏览器后退会直接离开整个页面而不是回到上一个对话。
const convs = await listOf('/conversations')
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 newPage()
await page.goto(`${WEB}/chat/这个id根本不存在`)
// 认整句提示,不认"不在了"三个字——会话标题里碰巧有这仨字就会把它蒙对
const times = await countNotice(page, '那个对话不在了')
await page.waitForURL((u) => !u.pathname.includes(encodeURIComponent('这个id')), { timeout: 5000 }).catch(() => {})
const landed = new URL(page.url()).pathname
const body = await page.locator('body').innerText()
check('指向不存在的对话时不白屏', body.length > 40, `${body.length} 字`)
check(convs.length ? '落到了一个真实对话' : '没有别的对话:回到首屏',
convs.length ? convs.some((c) => landed === `/chat/${c.id}`) : landed === '/chat', page.url())
check('说清楚了为什么换了地方,只说一次', times === 1, `提示 ${times} 次`)
await page.close()
}
{
// 数据源在第二波搬出了设置页,自成一页 /data;老链接 /settings/datasources 重定向过去
const selected = (page) => page.getByRole('tab', { selected: true }).allInnerTexts()
const direct = await visit('/data/databases')
check('深链接直达数据库那一屏',
new URL(direct.page.url()).pathname === '/data/databases'
&& (await selected(direct.page)).join() === '数据库', direct.page.url())
await direct.page.close()
const legacy = await visit('/settings/datasources')
check('设置页的老链接落到同一屏',
new URL(legacy.page.url()).pathname === '/data/databases'
&& (await selected(legacy.page)).join() === '数据库', legacy.page.url())
await legacy.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 listOf('/workflows')
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 newPage()
await page.goto(`${WEB}/studio/根本没这张图`)
// 说一次就够。这里真弹过三次——effect 在请求回来之前又跑了两遍
const times = await countNotice(page, '那张工作流不在了')
await page.waitForURL(/\/studio\/[0-9a-f]{8,}$/, { timeout: 5000 }).catch(() => {})
check('指向不存在的图时说一次并让开', times === 1 && /\/studio\/[0-9a-f]{8,}$/.test(page.url()),
`提示 ${times} 次,落在 ${page.url()}`)
await page.close()
}
{
// 问数据页的「在画布里打开」:建成一张新工作流、送去它自己的地址。
// 以前只是把节点塞进画布,store 里的 workflow 还是上一张图,⌘S 就把那张
// 整张覆盖了。要守的是:送到的是新图的地址、图没被别的工作流盖掉、
// 没弹莫名其妙的"有未保存的改动"。
//
// 这个按钮会写库。拦下建图那次 POST,拿它自己发出去的图回一个"已建好",
// 之后对这张图的读取也由这里答;校验、变量分析只算不写,放行
const DRAFT = 'c0ffee00c0ffee00c0ffee00c0ffee00'
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 before = await listOf('/workflows')
const { page } = await newPage()
let dialogs = 0
page.on('dialog', async (d) => { dialogs++; await d.dismiss() })
let created = null
await page.route('**/api/workflows**', (route) => {
const r = route.request()
const path = new URL(r.url()).pathname
if (r.method() === 'POST' && path.endsWith('/api/workflows')) {
const body = r.postDataJSON()
const now = new Date().toISOString().replace('Z', '')
created = { id: DRAFT, name: body.name, description: '', graph: body.graph, tags: [],
version: 1, is_template: false, status: 'draft', published_version: null,
created_at: now, updated_at: now, run_count: 0 }
return route.fulfill({ status: 201, json: created })
}
if (path.includes(`/workflows/${DRAFT}`)) {
if (path.endsWith('/versions')) return route.fulfill({ json: [] })
if (r.method() === 'GET' && created) return route.fulfill({ json: created })
}
return route.fallback() // 其余交给上下文那道闸门:读放行、写掐掉
})
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.waitForURL(`**/studio/${DRAFT}`, { timeout: 5000 }).catch(() => {})
await page.waitForTimeout(1200)
check('「在画布里打开」建了新图、送到它自己的地址',
!!created && page.url().endsWith(`/studio/${DRAFT}`), page.url())
check('建的是这一轮的图,名字带着问题',
created?.graph?.nodes?.length === seed.want && created.name.startsWith('问数据:'),
created?.name)
check('送过去的草稿图没被第一张工作流盖掉',
(await page.locator('.react-flow__node').count()) === seed.want,
`${await page.locator('.react-flow__node').count()} / ${seed.want} 个节点`)
check('没有弹莫名其妙的"未保存改动"', dialogs === 0, `弹了 ${dialogs} 次`)
const after = await listOf('/workflows')
check('检查本身没往库里写图', after.length === before.length,
`${before.length} → ${after.length}`)
} else {
check('(跳过:这一轮没有「在画布里打开」)', true)
}
await page.close()
} else {
check('(跳过:前 8 个会话里没有带图的轮次)', true)
}
}
{
// 单个文档 / 单个工具也要能指。文档那边原来连详情视图都没有——
// 列表里只有标题和片段数,点不开,而切块结果(重叠、表头续接)
// 是检索不准时第一个该看的东西
const docs = await listOf('/kb/documents')
const ready = docs.find((d) => d.status !== 'processing' && d.chunk_count > 0)
if (ready) {
const { page } = await visit(`/knowledge/kb/${ready.id}`)
const body = await page.locator('body').innerText()
check('深链接直达那份文档', body.includes(ready.title), ready.title)
check('看得到切块结果', /片段 \d+/.test(body) && /\d+ 字/.test(body),
body.slice(0, 120).replace(/\n/g, ' '))
check('回得去列表', (await page.getByRole('button', { name: /回到知识库/ }).count()) === 1)
await page.close()
const gone = await visit('/knowledge/kb/根本没这份文档')
const goneBody = await gone.page.locator('body').innerText()
check('文档不在了要说出来而不是白屏', goneBody.includes('不在了'),
goneBody.slice(-120).replace(/\n/g, ' '))
await gone.page.close()
} else {
check('(跳过:库里没有切完块的文档)', true)
}
const tools = await listOf('/tools')
if (tools.length) {
const t = tools[0]
const { page } = await visit(`/tools/library/${t.id}`)
const body = await page.locator('body').innerText()
check('深链接直达那个工具', body.includes(t.name), t.name)
check('参数说明跟着出来', body.includes('参数'), '')
await page.close()
}
}
{
// 列表一页 50 条,往后靠「加载更多」按游标翻;一条老运行的链接必须也能打开——
// 所以详情是按 id 直接取的,不是在已经取回来的那几页里找
const PAGE = 50
const all = await listOf('/runs?limit=200')
if (all.length > PAGE) {
const old = all[all.length - 1]
const { page } = await visit(`/runs/${old.id}`)
const body = await page.locator('body').innerText()
check('第一页之外的老运行也打得开', body.includes(old.id.slice(0, 6)),
`第 ${all.length} 条 · run ${old.id.slice(0, 8)}`)
await page.close()
} else {
check(`(跳过:库里只有 ${all.length} 条运行,不足以越过第一页)`, true)
}
}
})
await section('样式层叠:工具类盖得过组件类(F1 回归)', async () => {
// .field / .btn 以前不在任何 @layer 里,而 Tailwind 的工具类在 utilities 层——层外的
// 规则永远赢,加上去的工具类全被静默吞掉:受管卡的 border-[var(--accent)] 算出来还是
// --border,两张卡看不出选了哪个;field w-44 占满整行;btn-xs 压根没定义
const { page, errors } = await visit('/studio')
const publish = page.getByRole('button', { name: /^发布$/ })
const ready = await publish.isEnabled().catch(() => false)
check('发布入口可用(没有未保存的改动)', ready)
if (ready) {
await publish.click()
const governed = page.getByRole('radio', { name: /^受管/ })
await governed.click()
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(400) // 卡片带 transition-colors,等它落定
const cards = await page.locator('[role=radiogroup][aria-label="发布等级"] [role=radio]').evaluateAll((els) => {
const probe = document.createElement('i')
probe.style.borderTop = '1px solid var(--accent)'
els[0].parentElement.appendChild(probe)
const accent = getComputedStyle(probe).borderTopColor
probe.remove()
return els.map((el) => ({ on: el.getAttribute('aria-checked') === 'true', border: getComputedStyle(el).borderTopColor, accent }))
})
const on = cards.find((c) => c.on)
const off = cards.find((c) => !c.on)
check(`${theme}:选中「受管」后边框就是 --accent`, !!on && on.border === on.accent,
`${on?.border} / --accent ${on?.accent}`)
check(`${theme}:没选中的那张不是 --accent,两张看得出区别`, !!off && off.border !== off.accent, off?.border)
}
await page.keyboard.press('Escape')
}
const probe = await page.evaluate(() => {
// 在 utilities 层里声明一个和 Tailwind 的 w-44 一模一样的宽度:守的是层的先后,
// 不依赖源码里哪一处碰巧用了 w-44
const style = document.createElement('style')
style.textContent = '@layer utilities { .check-ui-w-44 { width: 11rem } }'
document.head.appendChild(style)
const field = document.createElement('input')
field.className = 'field check-ui-w-44'
const xs = document.createElement('button')
xs.className = 'btn btn-xs'
xs.textContent = '展开'
const base = document.createElement('button')
base.className = 'btn'
document.body.append(field, xs, base)
const f = field.getBoundingClientRect().width
const s = getComputedStyle(xs)
const out = { field: f, xs: s.fontSize, pad: `${s.paddingTop} ${s.paddingLeft}`, btn: getComputedStyle(base).fontSize }
field.remove(); xs.remove(); base.remove(); style.remove()
return out
})
check('field 上的 w-44 生效,窄于 200px', probe.field < 200, `${Math.round(probe.field)}px`)
check('.btn-xs 是 11px', probe.xs === '11px', `${probe.xs},普通 .btn ${probe.btn}`)
check('.btn-xs 有自己的内边距,不是按普通按钮渲染', probe.pad === '2px 6px', probe.pad)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
// 真页面上挂着宽度工具类的 .field 都按工具类的宽度画(w-N = N × 4px)
for (const path of ['/tools/sandbox', '/knowledge/memory']) {
const { page: p } = await visit(path)
const fields = await p.locator('.field').evaluateAll((els) => els
.map((el) => ({ w: Number(el.className.match(/(?:^|\s)w-(\d+)(?:\s|$)/)?.[1]), got: el.getBoundingClientRect().width }))
.filter((x) => x.w))
const off = fields.filter((x) => Math.abs(x.got - x.w * 4) > 1)
check(`${path}:.field 上的宽度工具类都生效`, fields.length > 0 && off.length === 0,
fields.map((x) => `w-${x.w}=${Math.round(x.got)}px`).join(' '))
await p.close()
}
})
await section('三处讲的是同一个故事', async () => {
// 同一次运行,运行页详情和 preview 里的 dense 渲染应该给出同一批步骤。
// 这是"全站一个翻译层"的实际含义——分两套的话用户没法判断哪个是真的。
const { page } = await newPage()
await page.goto(`${WEB}/preview.html?case=loop_approve`, { waitUntil: 'networkidle' })
await page.waitForTimeout(400)
const wide = await page.locator('body').innerText()
const asks = (wide.match(/这条公告可以发吗/g) ?? []).length
// 宽栏和窄栏各渲染一遍,所以是 3×2
check('宽窄两栏各三轮审批,一轮不少', asks === 6, `${asks} 处`)
check('决定折进了同一行而不是另起一行',
!/^你放行了$/m.test(wide) && !/^你驳回了$/m.test(wide))
})
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 界面端到端全部通过')
process.exit(failed ? 1 : 0)