mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:51:58 +00:00
587bf7e27c
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」, 校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮, 轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。 平台可信度(先写失败测试) - Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则, 工具集合变小时自查报警、改图回执列出变化 - 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修 - 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量 - 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因 - 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员 - 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行 后端 - 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、 SQLite progress handler),可按数据源配置;超时报错与实际耗时一致 - 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存; 自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存; 检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors 前端 - 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时, 一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子 - 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚; 回放用量取那一刻的值 - 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口 - 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对 - 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条 - 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问 验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿; check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
655 lines
44 KiB
JavaScript
655 lines
44 KiB
JavaScript
// 基础组件(components/ui.tsx)、前端工具库(lib/*)、连接状态(store/catalog.ts)
|
||
// 和 api/client 的上传进度的回归检查。管理页共用件(页头、连通胶囊、单选组、删除
|
||
// 后撤销)和失败说明(lib/explain)也在这里:它们从页面挪进了公共层,页面级检查
|
||
// 只走得到其中一两种情况。
|
||
//
|
||
// 这几样是全站的地基,坏了不会在哪一页上报错,只会悄悄变样:弹窗的焦点漏到页面
|
||
// 上、组字时按 Esc 把半屏草稿关掉、toast 压住工具栏按钮、后端一抖整站说「还没有
|
||
// 工作流」、恢复以后页面自己的列表还停在假的空态上。页面级的 check-ui 喂的都是
|
||
// 正常路径,对这些一个字都不会说。
|
||
//
|
||
// 跑在预览页 /ui-harness.html 上(只有 dev server 有,不进生产包)。lib 用的是
|
||
// 预览页挂在 window.__ui.lib 上的那一份:和组件同一个模块实例,instanceof ApiError
|
||
// 才靠得住——从页面里另外 import 一次,vite 热更新过的模块会带 ?t= 成为第二个实例。
|
||
// 断网用 page.route 拦 /api,不停后端;非 GET 一律拦掉,不写库。
|
||
// 时间格式按 Asia/Shanghai 断言,浏览器上下文钉了这个时区,和本机设置无关。
|
||
//
|
||
// 跑之前前端得起着:./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'
|
||
// 不用 playwright install:它既下不动也会动到已有缓存。系统 Chrome 就够了。
|
||
const CHROME = process.env.CHROME_PATH
|
||
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
|
||
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: 1280, height: 900 }, timezoneId: 'Asia/Shanghai' })
|
||
const page = await ctx.newPage()
|
||
const isApi = (u) => new URL(u).pathname.startsWith('/api/')
|
||
await page.route(isApi, (r) => (r.request().method() === 'GET' ? r.continue() : r.abort()))
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push(e.message))
|
||
|
||
try {
|
||
await page.goto(`${WEB}/ui-harness.html`, { waitUntil: 'networkidle' })
|
||
await page.waitForSelector('#badges-color', { timeout: 8000 })
|
||
} catch (e) {
|
||
console.error(`✗ 打不开预览页(${WEB}/ui-harness.html)——前端没起?先跑 ./scripts/dev.sh\n ${e.message}`)
|
||
await browser.close()
|
||
process.exit(1)
|
||
}
|
||
|
||
// 别的改动让 vite 整页刷新时,预览页的状态(心跳、catalog)全没了,后面的断言会
|
||
// 莫名其妙地挂。记下来,失败时说清楚是这个原因
|
||
let reloads = 0
|
||
page.on('load', () => { reloads++ })
|
||
|
||
const active = () => page.evaluate(() => {
|
||
const el = document.activeElement
|
||
return el ? `${el.tagName}${el.id ? '#' + el.id : ''}:${(el.textContent || el.getAttribute('placeholder') || '').trim().slice(0, 12)}` : 'null'
|
||
})
|
||
const dialogOpen = () => page.locator('[role="dialog"]').count()
|
||
const asking = () => page.getByText('有未保存的修改').count()
|
||
const state = () => page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
return { backend: s.backend, err: s.backendError, retryIn: s.retryAt ? s.retryAt - Date.now() : null, reconnects: s.reconnects }
|
||
})
|
||
|
||
console.log('=== lib:格式、状态、快捷键、术语、报错 ===')
|
||
for (const [name, ok, detail] of await page.evaluate(() => {
|
||
const { format: f, status: st, keys: k, terms: t, errors: e } = window.__ui.lib
|
||
const { ApiError } = window.__ui
|
||
const out = []
|
||
const eq = (name, got, want) => out.push([name, got === want, got === want ? '' : `得到 ${JSON.stringify(got)},应为 ${JSON.stringify(want)}`])
|
||
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
|
||
|
||
// 耗时:先按目标精度取整再判档(旧实现 119600 显示成「1m60s」)
|
||
eq('820 ms', f.formatDuration(820), '820 ms')
|
||
eq('999.6 ms 进位到秒档', f.formatDuration(999.6), '1.0 s')
|
||
eq('7.6 s', f.formatDuration(7600), '7.6 s')
|
||
eq('59.96 s 进位到分钟档', f.formatDuration(59_960), '1 分 00 秒')
|
||
eq('1 分 14 秒', f.formatDuration(74_000), '1 分 14 秒')
|
||
eq('119600 → 2 分 00 秒', f.formatDuration(119_600), '2 分 00 秒')
|
||
eq('1 小时 02 分', f.formatDuration(3_720_000), '1 小时 02 分')
|
||
eq('拿不到的耗时写 —', f.formatDuration(undefined), '—')
|
||
eq('0 是 0 ms 不是 —', f.formatDuration(0), '0 ms')
|
||
eq('计时器 mm:ss.s', f.formatClock(74_310), '01:14.3')
|
||
eq('计时器过小时', f.formatClock(3_723_400), '1:02:03.4')
|
||
eq('tokens 千分位', f.formatTokens(56034), '56,034 tokens')
|
||
eq('tokens 紧凑', f.formatTokens(56034, { compact: true }), '56.0k tok')
|
||
eq('tokens 紧凑进位到 M', f.formatTokens(999_950, { compact: true }), '1.0M tok')
|
||
eq('成本', f.formatCost(0.0312), '$0.031')
|
||
eq('成本不足 0.001', f.formatCost(0.0004), '<$0.001')
|
||
eq('成本为 0', f.formatCost(0), '$0')
|
||
eq('成本拿不到', f.formatCost(null), '—')
|
||
|
||
// 时间:不带时区的服务器时间按 UTC
|
||
eq('不带时区按 UTC', f.parseServerTime('2026-09-26T01:11:19.891698')?.toISOString(), '2026-09-26T01:11:19.891Z')
|
||
eq('空格分隔也按 UTC', f.parseServerTime('2026-09-26 01:11:19')?.toISOString(), '2026-09-26T01:11:19.000Z')
|
||
eq('小于 1e12 的数当秒', f.parseServerTime(1790385079.89)?.toISOString(), '2026-09-26T01:11:19.890Z')
|
||
eq('解析不了是 null', f.parseServerTime('garbage'), null)
|
||
const now = new Date('2026-09-26T02:00:00Z') // 上海 10:00
|
||
eq('今天只写时分', f.formatTime('2026-09-26T01:11:19', now), '09:11')
|
||
eq('更早写月/日', f.formatTime('2026-09-25T04:52:00Z', now), '9/25 12:52')
|
||
eq('跨年带年份', f.formatTime('2025-09-25T04:52:00Z', now), '2025/9/25 12:52')
|
||
eq('完整时间带时区', f.formatDateTime('2026-09-26T01:11:19.891698'), '2026-09-26 09:11:19 (UTC+08:00)')
|
||
eq('今天 / 昨天', `${f.formatDay('2026-09-26T01:11:19Z', now)}/${f.formatDay('2026-09-25T01:11:19Z', now)}`, '今天/昨天')
|
||
const ago = (ms) => f.formatRelative(new Date(now.getTime() - ms), now)
|
||
eq('相对时间:刚刚', ago(30_000), '刚刚')
|
||
eq('相对时间:52 分钟前', ago(52 * 60_000), '52 分钟前')
|
||
eq('相对时间:59.6 分钟进位到小时档', ago(59.6 * 60_000), '1 小时前')
|
||
ok('相对时间:23.6 小时不写「24 小时前」', !/24 小时前/.test(ago(23.6 * 3_600_000)), ago(23.6 * 3_600_000))
|
||
eq('短 id', f.shortId('66a5a6ff0011'), '#66a5a6')
|
||
eq('文件大小:字节', f.formatBytes(820), '820 B')
|
||
eq('文件大小:KB', f.formatBytes(12_700), '12.4 KB')
|
||
eq('文件大小:MB', f.formatBytes(3.1 * 1024 * 1024), '3.1 MB')
|
||
eq('文件大小拿不到写 —', f.formatBytes(undefined), '—')
|
||
eq('短名去掉括号里的补充', f.shortLabel('OpenAI 兼容(DeepSeek、通义…)'), 'OpenAI 兼容')
|
||
|
||
// 状态
|
||
eq('succeeded = 已完成', st.statusLabel('succeeded'), '已完成')
|
||
eq('interrupted + 有审批 = 等待审批', st.statusLabel('interrupted', { pendingApproval: true }), '等待审批')
|
||
eq('interrupted + 没审批 = 已挂起', st.statusLabel('interrupted', { pendingApproval: false }), '已挂起 · 可续跑')
|
||
eq('suspended', st.statusLabel('suspended'), '已中断(服务重启)')
|
||
eq('未知状态原样写', st.statusLabel('weird'), 'weird')
|
||
const shapes = new Set(['running', 'queued', 'done', 'waiting', 'failed', 'skipped', 'cancelled', 'blocked', 'unreached'].map((s) => st.statusMeta(s).shape))
|
||
eq('九种剪影各不相同', shapes.size, 9)
|
||
// 显示码不能直接当查询参数:waiting / held 在后端都是 interrupted
|
||
eq('waiting → interrupted', st.serverStatusOf('waiting'), 'interrupted')
|
||
eq('held → interrupted', st.serverStatusOf('held'), 'interrupted')
|
||
eq('done → succeeded', st.serverStatusOf('done'), 'succeeded')
|
||
eq('节点状态不是运行状态', st.serverStatusOf('skipped'), null)
|
||
ok('筛选分段的每一档都查得到', st.RUN_STATUS_ORDER.every((c) => st.serverStatusOf(c)),
|
||
st.RUN_STATUS_ORDER.filter((c) => !st.serverStatusOf(c)).join(','))
|
||
|
||
// 快捷键:按本机平台断言
|
||
const mac = k.isMac
|
||
eq('Mod+Enter 的显示', k.formatShortcut('Mod+Enter'), mac ? '⌘⏎' : 'Ctrl+Enter')
|
||
eq('Alt+V 的显示', k.formatShortcut('Alt+V'), mac ? '⌥V' : 'Alt+V')
|
||
eq('aria-keyshortcuts:Mod 按平台', k.ariaShortcut('Mod+K'), mac ? 'Meta+K' : 'Control+K')
|
||
eq('aria-keyshortcuts:Ctrl 写成 Control', k.ariaShortcut('Ctrl+Shift+Enter'), 'Control+Shift+Enter')
|
||
const ev = (o) => ({ key: '', code: '', metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...o })
|
||
ok('⌥V 的 e.key 是 √ 也认', k.matchShortcut(ev({ key: '√', code: 'KeyV', altKey: true }), 'Alt+V'))
|
||
ok('多了 Shift 不算', !k.matchShortcut(ev({ key: 's', code: 'KeyS', [k.modKey]: true, shiftKey: true }), 'Mod+S'))
|
||
ok('? 不强求 Shift 一致', k.matchShortcut(ev({ key: '?', code: 'Slash', shiftKey: true }), '?'))
|
||
|
||
// 术语
|
||
eq('human = 人工审批', t.nodeTypeLabel('human'), '人工审批')
|
||
eq('正式运行 v3', t.runClassLabel('formal', 3), '正式运行 v3')
|
||
eq('完整出具', t.issuanceLabel('formal'), '完整出具')
|
||
|
||
// 报错
|
||
const net = e.humanizeError(new ApiError(0, '连不上后端服务(可能没启动或正在重启),稍后重试', { kind: 'network', raw: 'TypeError: Failed to fetch' }))
|
||
ok('网络失败:连不上后端服务 + 怎么办', net.title === '连不上后端服务' && net.kind === 'network' && net.action, JSON.stringify(net))
|
||
eq('TypeError: Failed to fetch 翻成人话', e.humanizeError(new TypeError('Failed to fetch')).title, '连不上后端服务')
|
||
ok('errorMessage 不露 Failed to fetch', !e.errorMessage(new TypeError('Failed to fetch')).includes('Failed to fetch'))
|
||
eq('exit undefined', e.humanizeError('✕ 失败 (exit undefined)').title, '请求没到沙箱')
|
||
const pyd = e.humanizeError("2 validation errors for GraphSpec\nnodes.1.type\n Input should be 'input'")
|
||
ok('pydantic 原文翻成人话,原文进 raw', pyd.title === '生成的工作流里有节点类型不认识' && pyd.raw.includes('GraphSpec'), pyd.title)
|
||
ok('Python 异常类名不进标题', !e.humanizeError("KeyError: 'rows'").title.includes('KeyError'))
|
||
const obj = e.humanizeError({ ok: false, error: '连不上对方的服务', hint: '核对地址和端口', detail: 'ConnectError: [Errno 61]' })
|
||
ok('{error, hint, detail}', obj.title === '连不上对方的服务' && obj.action === '核对地址和端口' && obj.raw === 'ConnectError: [Errno 61]', JSON.stringify(obj))
|
||
eq('超时和连不上分开说', e.humanizeError(new ApiError(0, 'x', { kind: 'network', timeoutMs: 5000 })).title, '后端没有响应')
|
||
eq('取消不是错误', e.humanizeError(new DOMException('aborted', 'AbortError')).title, '已取消')
|
||
// 后端回了话,话里提到 fetch failed,说的是它够不着的下游,不是我们连不上它
|
||
const down = e.humanizeError(new ApiError(502, '模型服务连不上:httpx.ConnectError: fetch failed'))
|
||
ok('HTTP 错误里的网络字眼不算连不上后端', down.kind === 'http' && down.title !== '连不上后端服务' && down.status === 502, JSON.stringify(down))
|
||
ok('isNetworkError 对 HTTP 错误为假', !e.isNetworkError(new ApiError(500, 'NetworkError when attempting to fetch resource')))
|
||
ok('测连接结果里的 ECONNREFUSED 不算连不上后端',
|
||
e.humanizeError({ ok: false, error: 'connect ECONNREFUSED 10.0.0.5:5432' }).title !== '连不上后端服务')
|
||
// 已经是「标题:原因」的中文人话,不再套一个「操作超时」、把整句当原因(标题说两遍)
|
||
const zh = e.humanizeError(new ApiError(504, '数据库查询超时:超过 30 秒没有返回,已中断。缩小查询范围后重试。'))
|
||
ok('中文人话里的「超时」不套「操作超时」', zh.title !== '操作超时' && !(zh.reason ?? '').includes('操作超时')
|
||
&& !(zh.reason && zh.reason.includes(zh.title)), JSON.stringify(zh))
|
||
eq('英文 timeout 原文照样翻成「操作超时」', e.humanizeError('ReadTimeout: timed out').title, '操作超时')
|
||
ok('……原因里不带异常类名', !/ReadTimeout/.test(e.humanizeError('ReadTimeout: timed out').reason ?? ''))
|
||
|
||
// 失败说明(lib/explain):记录页、助手流、问数据页共用一份
|
||
const x = window.__ui.lib.explain
|
||
const leak = x.explainRunError('模型输出了工具调用的原始标记,但没有真正调用工具。常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。')
|
||
ok('工具调用标记泄漏:说人话、不给接着跑、指到画布', leak.title === '模型没有真正调用工具' && !leak.continuable && leak.fix === 'canvas', JSON.stringify(leak))
|
||
const tired = x.explainRunError('NodeError: 协作团队用完 4 轮仍未完成:还缺汇总员的结论')
|
||
ok('团队轮数用完:标题带轮数,原因是调度者最后的理由', tired.title === '协作团队用完 4 轮仍未完成'
|
||
&& tired.reason === '还缺汇总员的结论' && !tired.continuable, JSON.stringify(tired))
|
||
const made = x.explainRunError('校验修复无效:修复时出现了原文没有的值:total_count=0')
|
||
ok('校验修复编造数值:点出是哪个值、不给接着跑', made.reason.includes('total_count=0') && !made.continuable, JSON.stringify(made))
|
||
eq('提示词点名的工具没绑定', x.explainRunError('提示词要求用 db_query__shop,但节点没有绑定它').title,
|
||
'提示词要求用「db_query__shop」,但节点没有绑定它')
|
||
eq('老运行的鉴权失败', x.explainRunError('NodeError: AuthenticationError: Error code: 401 - invalid_api_key').title, '模型鉴权没通过')
|
||
const miss = x.explainRunError('缺少必填输入:region')
|
||
ok('缺必填输入:给出是哪一项、重新发起', miss.missingInput === 'region' && miss.fix === 'rerun' && !miss.continuable)
|
||
eq('没留原因', x.explainRunError(null).title, '运行失败,但没有留下原因')
|
||
ok('兜底:句中的异常类名也去掉', !/ValueError/.test(x.explainRunError('结构不对:ValueError: bad').title))
|
||
return out
|
||
})) check(name, ok, detail)
|
||
|
||
console.log('\n=== Modal:焦点、Esc、dirty、遮罩 ===')
|
||
await page.click('#open-dirty')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
const dlg = page.locator('[role="dialog"]')
|
||
check('role=dialog + aria-modal', (await dlg.getAttribute('aria-modal')) === 'true')
|
||
const labelledby = await dlg.getAttribute('aria-labelledby')
|
||
check('aria-labelledby 指向标题', !!labelledby && (await page.locator(`[id="${labelledby}"]`).innerText()) === '编辑 Skill')
|
||
check('打开时聚焦第一个输入框', (await active()).startsWith('INPUT'), await active())
|
||
check('× 按钮有名字', (await page.locator('[role="dialog"] button[aria-label="关闭"]').count()) === 1)
|
||
let escaped = false
|
||
for (const key of [...Array(12).fill('Tab'), ...Array(6).fill('Shift+Tab')]) {
|
||
await page.keyboard.press(key)
|
||
if (!(await page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]')))) escaped = true
|
||
}
|
||
check('Tab / Shift+Tab 焦点困在弹窗里', !escaped)
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(80)
|
||
check('没改动时 Esc 直接关', (await dialogOpen()) === 0)
|
||
check('关闭后焦点回到打开按钮', (await active()).startsWith('BUTTON#open-dirty'), await active())
|
||
|
||
await page.click('#open-dirty')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
const ta = page.locator('[role="dialog"] textarea')
|
||
await ta.fill('多行指令\n第二行')
|
||
await ta.focus()
|
||
const fireKey = (loc, init) => loc.evaluate((el, init) =>
|
||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })), init)
|
||
await fireKey(ta, { key: 'Escape', isComposing: true })
|
||
await page.waitForTimeout(100)
|
||
check('组字中的 Esc(isComposing)不关也不问', (await dialogOpen()) === 1 && (await asking()) === 0)
|
||
await fireKey(ta, { key: 'Escape', keyCode: 229 })
|
||
await page.waitForTimeout(100)
|
||
check('Safari 式 keyCode=229 的 Esc 不关也不问', (await dialogOpen()) === 1 && (await asking()) === 0)
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(100)
|
||
check('dirty 时 Esc 先问', (await dialogOpen()) === 1 && (await asking()) === 1)
|
||
check('询问时焦点在「继续编辑」', (await active()).includes('继续编辑'), await active())
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(120)
|
||
check('再按 Esc = 继续编辑', (await asking()) === 0 && (await dialogOpen()) === 1)
|
||
check('焦点回到刚才的输入框,内容还在', (await active()).startsWith('TEXTAREA') && (await ta.inputValue()) === '多行指令\n第二行', await active())
|
||
|
||
// 点遮罩:按下和松开都在遮罩上才算。两个方向的拖动,click 都会派给公共祖先(遮罩)
|
||
const drag = async (from, to) => {
|
||
await page.mouse.move(from.x, from.y)
|
||
await page.mouse.down()
|
||
await page.mouse.move(to.x, to.y, { steps: 4 })
|
||
await page.mouse.up()
|
||
await page.waitForTimeout(100)
|
||
}
|
||
const panelBox = await dlg.boundingBox()
|
||
const inPanel = { x: panelBox.x + panelBox.width / 2, y: panelBox.y + 20 }
|
||
const onBackdrop = { x: 10, y: 10 }
|
||
const taBox = await ta.boundingBox()
|
||
await drag({ x: taBox.x + 20, y: taBox.y + 10 }, onBackdrop)
|
||
check('从输入框拖到遮罩松开:不关不问', (await dialogOpen()) === 1 && (await asking()) === 0)
|
||
await drag(onBackdrop, inPanel)
|
||
check('从遮罩按下、拖进面板松开:不关不问', (await dialogOpen()) === 1 && (await asking()) === 0)
|
||
await page.mouse.click(onBackdrop.x, onBackdrop.y)
|
||
await page.waitForTimeout(100)
|
||
check('dirty 时点遮罩先问', (await asking()) === 1)
|
||
await page.getByRole('button', { name: '继续编辑' }).click()
|
||
await page.locator('[role="dialog"] button[aria-label="关闭"]').click()
|
||
await page.waitForTimeout(100)
|
||
check('dirty 时点 × 先问', (await asking()) === 1)
|
||
await page.getByRole('button', { name: '放弃修改' }).click()
|
||
await page.waitForTimeout(100)
|
||
check('放弃修改后关闭,焦点回到打开按钮', (await dialogOpen()) === 0 && (await active()).startsWith('BUTTON#open-dirty'), await active())
|
||
|
||
console.log('\n=== confirmDialog / promptDialog ===')
|
||
const result = () => page.locator('#dialog-result').innerText()
|
||
await page.click('#open-danger')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
check('危险确认:初始焦点在「取消」', (await active()).includes('取消'), await active())
|
||
check('危险确认:列出后果', (await page.getByText('连同 4 条运行记录一起删除').count()) === 1)
|
||
await page.keyboard.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('危险确认:回车 = 取消', (await result()) === 'false')
|
||
check('关闭后焦点回到触发按钮', (await active()).startsWith('BUTTON#open-danger'), await active())
|
||
|
||
await page.click('#open-confirm')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
check('普通确认:初始焦点在确认按钮', (await active()).includes('发布 v4'), await active())
|
||
const confirmBox = await page.locator('[role="dialog"]').boundingBox()
|
||
await drag(onBackdrop, { x: confirmBox.x + confirmBox.width / 2, y: confirmBox.y + 20 })
|
||
check('普通确认:从遮罩拖进面板松开不关', (await dialogOpen()) === 1)
|
||
await page.keyboard.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('回车确认', (await result()) === 'true')
|
||
|
||
await page.click('#open-require')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
const requireBtn = page.getByRole('button', { name: '删除凭证' })
|
||
check('照抄确认:没输入时禁用、焦点在输入框', (await requireBtn.isDisabled()) && (await active()).startsWith('INPUT'), await active())
|
||
await page.keyboard.type('a3f9c')
|
||
check('照抄确认:输错仍禁用', await requireBtn.isDisabled())
|
||
await page.keyboard.type('2')
|
||
await page.keyboard.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('照抄确认:抄对后回车确认', (await result()) === 'true')
|
||
|
||
await page.click('#open-prompt')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
const input = page.locator('[role="dialog"] input')
|
||
check('输入框:初值带上并全选', (await input.inputValue()) === '未命名工作流 09-26 10:05'
|
||
&& await input.evaluate((el) => el.selectionStart === 0 && el.selectionEnd === el.value.length))
|
||
await input.fill('新工作流')
|
||
await page.waitForTimeout(50)
|
||
check('validate 不通过时说原因并禁用', (await page.getByText('已经有一个同名的了').count()) === 1
|
||
&& await page.getByRole('button', { name: '新建', exact: true }).isDisabled())
|
||
await input.fill(' 月度经营分析 ')
|
||
await fireKey(input, { key: 'Enter', isComposing: true })
|
||
await fireKey(input, { key: 'Escape', keyCode: 229 })
|
||
await page.waitForTimeout(100)
|
||
check('组字中的回车、Esc 都不动', (await dialogOpen()) === 1)
|
||
await input.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('回车提交,返回去掉首尾空白的名字', (await result()) === '"月度经营分析"')
|
||
|
||
// 嵌套:弹窗里再弹确认,Esc 只关一层
|
||
await page.click('#open-dirty')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.locator('[role="dialog"] textarea').fill('x')
|
||
await page.evaluate(() => { void window.__ui.confirmDialog({ title: '里面那层', danger: true }).then((v) => { window.__inner = v }) })
|
||
await page.waitForTimeout(150)
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(120)
|
||
check('嵌套时 Esc 只关上面那层,下面那层没被问', (await dialogOpen()) === 1
|
||
&& (await page.evaluate(() => window.__inner)) === false && (await asking()) === 0)
|
||
await page.getByRole('button', { name: '取消' }).click()
|
||
await page.waitForTimeout(80)
|
||
|
||
console.log('\n=== toast ===')
|
||
await page.evaluate(() => window.__ui.toast.dismiss())
|
||
await page.click('#toast-error')
|
||
await page.getByRole('button', { name: '普通' }).click()
|
||
await page.waitForTimeout(100)
|
||
check('出错的在 assertive 区', (await page.locator('[role="alert"][aria-live="assertive"]').innerText()).includes('保存失败'))
|
||
check('普通的在 polite 区', (await page.locator('[role="status"][aria-live="polite"]').first().innerText()).includes('已复制到剪贴板'))
|
||
check('出错的能复制', (await page.getByRole('button', { name: '复制详情' }).count()) >= 1)
|
||
// 位置:内容区(让出 64px 的导航,App 里的 w-16)居中,顶边在 48px 工具栏之下
|
||
const NAV_W = 64
|
||
const stackBox = await page.locator('[role="alert"][aria-live="assertive"]').boundingBox()
|
||
const vw = page.viewportSize().width
|
||
check('toast 在工具栏下沿之下(≥ 56px)', stackBox.y >= 56, `top=${Math.round(stackBox.y)}`)
|
||
check('toast 在内容区里居中', Math.abs(stackBox.x + stackBox.width / 2 - (NAV_W + vw) / 2) < 2,
|
||
`中线 ${Math.round(stackBox.x + stackBox.width / 2)},内容区中线 ${(NAV_W + vw) / 2}`)
|
||
const infoCard = page.getByText('已复制到剪贴板')
|
||
await infoCard.hover()
|
||
await page.waitForTimeout(4600)
|
||
check('悬停时不消失', (await infoCard.count()) === 1)
|
||
await page.mouse.move(640, 880)
|
||
await page.waitForTimeout(4400)
|
||
check('移开后约 4 秒消失', (await infoCard.count()) === 0)
|
||
check('出错的常驻', (await page.getByText('保存失败:工作流名称不能为空').count()) === 1)
|
||
await page.click('#toast-error')
|
||
await page.waitForTimeout(80)
|
||
check('同样的内容不叠,计数 ×2', (await page.getByText('保存失败:工作流名称不能为空').count()) === 1 && (await page.getByText('×2').count()) === 1)
|
||
await page.getByRole('button', { name: '关闭提示' }).first().click()
|
||
await page.waitForTimeout(80)
|
||
check('× 关掉', (await page.getByText('保存失败').count()) === 0)
|
||
await page.click('#toast-network')
|
||
await page.waitForTimeout(80)
|
||
const netText = await page.locator('[role="alert"][aria-live="assertive"]').innerText()
|
||
check('网络错误不露 Failed to fetch', netText.includes('连不上后端服务') && !netText.includes('Failed to fetch'), netText.replace(/\n/g, ' | '))
|
||
await page.getByRole('button', { name: '叠 6 条' }).click()
|
||
await page.waitForTimeout(80)
|
||
check('最多露 3 条,其余收起', (await page.getByText(/还有 \d+ 条/).count()) === 1)
|
||
await page.evaluate(() => window.__ui.toast.dismiss())
|
||
|
||
console.log('\n=== 离线:横幅、空态 ===')
|
||
const emptyBlock = page.locator('[data-block="空态 EmptyState(模拟离线时会变)"]')
|
||
await page.click('#toggle-offline')
|
||
await page.waitForTimeout(100)
|
||
check('横幅出现', (await page.getByText('后端未连接').count()) === 1)
|
||
// 横幅早就挂着(连着时什么都不画),计时器是这一刻才走起来的:头一个数得按此刻算
|
||
const firstSecs = Number((await page.getByText(/秒后自动重试/).innerText()).match(/\d+/)?.[0])
|
||
check('刚断开时倒计时从此刻算(8 秒)', firstSecs === 8 || firstSecs === 7, `横幅 ${firstSecs} 秒`)
|
||
check('空态改说拿不到数据', (await emptyBlock.getByText('暂时拿不到数据').count()) >= 1)
|
||
check('离线空态不许诺「会自动刷新」', (await emptyBlock.getByText(/自动刷新/).count()) === 0)
|
||
check('离线时收起「新建」', (await page.getByRole('button', { name: '新建工作流' }).count()) === 0)
|
||
check('本地空态不受影响', (await page.getByText('没有匹配的工具').count()) === 1)
|
||
// role=alert 整块重念:倒计时一秒一跳,放在里面读屏就一秒念一遍整条横幅
|
||
const bannerAlert = page.locator('[data-offline-banner] [role="alert"]')
|
||
check('横幅外层不是 role=alert', (await page.locator('[data-offline-banner]').getAttribute('role')) === null)
|
||
check('横幅的播报区只有不变的那几句', (await bannerAlert.count()) === 1
|
||
&& (await bannerAlert.innerText()).includes('后端未连接') && !/秒后自动重试/.test(await bannerAlert.innerText()),
|
||
await bannerAlert.innerText().catch(() => ''))
|
||
check('倒计时对读屏隐藏', (await page.getByText(/秒后自动重试/).getAttribute('aria-hidden')) === 'true')
|
||
// 横幅把工具栏往下推了多少,toast 就跟着让多少
|
||
await page.click('#toast-error')
|
||
await page.waitForTimeout(80)
|
||
const bannerH = (await page.locator('[data-offline-banner]').boundingBox()).height
|
||
const offTop = (await page.locator('[role="alert"][aria-live="assertive"]').boundingBox()).y
|
||
check('离线时 toast 再让出横幅的高度', Math.abs(offTop - (56 + bannerH)) < 2, `top=${Math.round(offTop)},横幅 ${Math.round(bannerH)}px`)
|
||
await page.evaluate(() => window.__ui.toast.dismiss())
|
||
await page.click('#toggle-offline')
|
||
await page.waitForTimeout(80)
|
||
check('恢复后横幅高度变量清掉', (await page.evaluate(() => document.documentElement.style.getPropertyValue('--offline-banner-h'))) === '')
|
||
|
||
console.log('\n=== Tabs · Field · IconButton ===')
|
||
await page.getByRole('tab', { name: '模型接入' }).focus()
|
||
await page.keyboard.press('ArrowRight')
|
||
check('→ 切到下一个标签', (await page.getByRole('tab', { name: /数据源/ }).getAttribute('aria-selected')) === 'true')
|
||
check('tabpanel 与 tab 互相指认', (await page.locator('[role="tabpanel"]').getAttribute('aria-labelledby')) === 'demo-tab-b')
|
||
check('Field:label 关联输入框', (await page.getByLabel('Base URL').count()) === 1 && (await page.getByLabel('标识').count()) === 1)
|
||
const wantAria = await page.evaluate(() => window.__ui.lib.keys.ariaShortcut('Mod+R'))
|
||
const gotAria = await page.getByRole('button', { name: '刷新列表' }).getAttribute('aria-keyshortcuts')
|
||
check('IconButton 的 aria-keyshortcuts 按平台写', gotAria === wantAria && !/Ctrl|Mod/.test(gotAria), gotAria)
|
||
|
||
console.log('\n=== 管理页共用件:页头、连通胶囊、单选组、删除后撤销 ===')
|
||
check('页头 48px 高', Math.round((await page.locator('#page-header-demo header').boundingBox()).height) === 48)
|
||
const pills = await page.locator('#health-demo [data-health]').evaluateAll((els) => els.map((el) => el.textContent))
|
||
// 「3 分钟前」是从预览页挂载时算的,跑到这里可能已经过了一分钟
|
||
check('连通胶囊五种说法', pills[0] === '未测试' && /^正在测 · /.test(pills[1]) && /^已连通 · 42 ms · [34] 分钟前测$/.test(pills[2])
|
||
&& pills[3] === '连不上 · 1 小时前测' && /已连通 · 380 ms.*配置改过了/.test(pills[4]), pills.join(' | '))
|
||
check('不知道测的时刻:不留一个悬空的「 · 」', pills[5] === '连不上', JSON.stringify(pills[5]))
|
||
const spoken = await page.locator('#health-demo [role="status"]').evaluateAll((els) => els.map((el) => el.textContent))
|
||
check('读屏那一句不带跳动的计时和相对时间', spoken[1] === '正在测连接' && /^已连通 42 ms,\d\d:\d\d 测的$/.test(spoken[2]), spoken.join(' | '))
|
||
|
||
const radios = page.locator('#radio-demo [role="radio"]')
|
||
check('单选组只占一个 Tab 位', JSON.stringify(await radios.evaluateAll((els) => els.map((el) => el.tabIndex))) === '[0,-1,-1]')
|
||
const checkedRadio = () => page.locator('#radio-demo [role="radio"][aria-checked="true"]').innerText()
|
||
const focusedRadio = () => page.evaluate(() => document.activeElement?.textContent)
|
||
await radios.first().focus()
|
||
await page.keyboard.press('ArrowRight')
|
||
check('→ 选中下一项并聚焦', (await checkedRadio()) === 'sid' && (await focusedRadio()) === 'sid')
|
||
await page.keyboard.press('End')
|
||
check('End 到最后一项', (await checkedRadio()) === 'dsn')
|
||
await page.keyboard.press('ArrowDown')
|
||
check('↓ 首尾相接', (await checkedRadio()) === 'service_name')
|
||
await page.keyboard.press('ArrowUp')
|
||
await page.keyboard.press('Home')
|
||
check('↑ 往回、Home 到第一项', (await checkedRadio()) === 'service_name' && (await focusedRadio()) === 'service_name')
|
||
await page.keyboard.press('Alt+ArrowRight')
|
||
check('带 Alt 的方向键不接(那是浏览器后退 / 前进)', (await checkedRadio()) === 'service_name')
|
||
await page.getByRole('tab', { name: '模型接入' }).focus()
|
||
await page.keyboard.press('End')
|
||
check('标签页:End 到最后一个', (await page.getByRole('tab', { name: '偏好' }).getAttribute('aria-selected')) === 'true')
|
||
await page.keyboard.press('Home')
|
||
|
||
let deletes = 0
|
||
page.on('request', (r) => { if (r.method() === 'DELETE') deletes++ })
|
||
const rowShown = (id) => page.locator(`#defer-demo [data-row="${id}"]`).count()
|
||
const undoBtn = page.locator('[role="status"][aria-live="polite"]').getByRole('button', { name: '撤销' })
|
||
await page.getByRole('button', { name: '删除 orders' }).click()
|
||
await page.waitForTimeout(80)
|
||
check('删除:行先拿掉,toast 给「撤销」', (await rowShown('orders')) === 0
|
||
&& (await page.getByText('已删除「orders」').count()) === 1 && (await undoBtn.count()) === 1)
|
||
await page.click('#defer-reload')
|
||
await page.waitForTimeout(50)
|
||
check('撤销窗口里重拉列表,删掉的行不回来', (await rowShown('orders')) === 0)
|
||
await undoBtn.click()
|
||
await page.waitForTimeout(80)
|
||
check('撤销:行回来了,一个 DELETE 都没发', (await rowShown('orders')) === 1 && deletes === 0)
|
||
await page.evaluate(() => window.__ui.toast.dismiss())
|
||
|
||
console.log('\n=== 空态:catalog 的表没取回来时不说「还没有」 ===')
|
||
const sourceEmpty = page.locator('#empty-source-demo')
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
|
||
check('取回来了:照常说「还没有工作流」、给新建', (await sourceEmpty.getByText('还没有工作流').count()) === 1
|
||
&& (await sourceEmpty.locator('#empty-source-action').count()) === 1)
|
||
const realCatalog = await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
return { loadedAt: s.loadedAt, checks: s.checks }
|
||
})
|
||
await page.evaluate(() => window.__ui.useCatalog.setState({
|
||
loadedAt: {}, checks: [{ key: 'workflows', label: '工作流', state: 'pending' }],
|
||
}))
|
||
await page.waitForTimeout(50)
|
||
check('从没取回来过、还在路上:说「正在读取工作流」、收起新建', (await sourceEmpty.getByText('正在读取工作流').count()) === 1
|
||
&& (await sourceEmpty.locator('#empty-source-action').count()) === 0
|
||
&& (await sourceEmpty.locator('[data-empty-unknown="loading"]').getAttribute('role')) === 'status')
|
||
await page.evaluate(() => window.__ui.useCatalog.setState({
|
||
checks: [{ key: 'workflows', label: '工作流', state: 'error', error: '后端 15 秒没有响应,稍后重试' }],
|
||
}))
|
||
await page.waitForTimeout(50)
|
||
check('超时或报错:说「工作流没取回来」和原因、给重新读取', (await sourceEmpty.getByText('工作流没取回来').count()) === 1
|
||
&& (await sourceEmpty.getByText(/15 秒没有响应.*这里显示为空不代表没有数据/).count()) === 1
|
||
&& (await sourceEmpty.getByRole('button', { name: '重新读取' }).count()) === 1
|
||
&& (await sourceEmpty.locator('#empty-source-action').count()) === 0)
|
||
await page.evaluate((c) => window.__ui.useCatalog.setState(c), realCatalog)
|
||
|
||
console.log('\n=== catalog:一张表卡住不拖累别的表 ===')
|
||
// /tools 连得上但不回(MCP 服务不应答时就是这样):以前 Promise.all 等它,工作流也一直空着
|
||
const toolsBefore = await page.evaluate(() => window.__ui.useCatalog.getState().tools.length)
|
||
// unroute 要拿同一个函数引用才摘得掉
|
||
const isTools = (u) => new URL(u).pathname === '/api/tools'
|
||
await page.route(isTools, () => { /* 永远不回 */ })
|
||
const expectWf = await fetch(`${API}/workflows`).then((r) => r.json()).then((l) => l.length, () => null)
|
||
await page.evaluate(() => window.__ui.useCatalog.setState({ workflows: [] }))
|
||
const tHang = Date.now()
|
||
let settled = false
|
||
const hung = page.evaluate(() => window.__ui.useCatalog.getState().refresh()).then(() => { settled = true })
|
||
await page.waitForFunction(() => window.__ui.useCatalog.getState().workflows.length > 0, null, { timeout: 8000 }).catch(() => {})
|
||
let cat = await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
return { wf: s.workflows.length, wfAt: s.loadedAt.workflows, tools: s.checks.find((c) => c.key === 'tools')?.state }
|
||
})
|
||
if (expectWf) {
|
||
check('工作流先回来先填,不等卡住的工具', cat.wf === expectWf && cat.wfAt >= tHang && cat.tools === 'pending',
|
||
`${JSON.stringify(cat)},后端有 ${expectWf} 个工作流`)
|
||
}
|
||
// 最多等 20 秒:没有超时的话 refresh 永远不落定,检查自己也不能跟着挂死
|
||
await Promise.race([hung, new Promise((r) => setTimeout(r, 20_000))])
|
||
const hangMs = Date.now() - tHang
|
||
check('refresh 在超时之后落定', settled, `${hangMs}ms 还没落定`)
|
||
cat = await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
const c = s.checks.find((x) => x.key === 'tools')
|
||
return { state: c?.state, error: c?.error, backend: s.backend, tools: s.tools.length, loaded: s.loaded }
|
||
})
|
||
check('卡住的那张 15 秒超时,记成出错并说清', cat.state === 'error' && /15 秒/.test(cat.error ?? '') && hangMs > 14_500 && hangMs < 18_000,
|
||
`${cat.state} ${cat.error} ${hangMs}ms`)
|
||
check('一张表超时不算后端断开', cat.backend === 'ok', cat.backend)
|
||
check('超时不清空手上已有的那份', cat.tools === toolsBefore, `${toolsBefore} → ${cat.tools}`)
|
||
await page.unroute(isTools)
|
||
|
||
console.log('\n=== 上传进度(kb.upload 走 XHR) ===')
|
||
const isUpload = (u) => new URL(u).pathname === '/api/kb/upload'
|
||
const uploadVia = async (handler) => {
|
||
await page.unroute(isUpload)
|
||
await page.route(isUpload, handler)
|
||
}
|
||
const tryUpload = (size) => page.evaluate(async (size) => {
|
||
const events = []
|
||
try {
|
||
const doc = await window.__ui.api.kb.upload(new File([new Uint8Array(size)], 'orders.csv', { type: 'text/csv' }), 'default',
|
||
{ onProgress: (p) => events.push(p) })
|
||
return { events, doc }
|
||
} catch (e) {
|
||
return { events, err: { isApi: e instanceof window.__ui.ApiError, status: e.status, kind: e.kind, message: e.message } }
|
||
}
|
||
}, size)
|
||
// 上传进度只有真的走网络才有(page.route 直接 fulfill 时浏览器不发进度事件)。
|
||
// 转给 dev server 上一个不存在的地址:字节真发出去,回 404,什么都不写
|
||
await uploadVia((r) => r.continue({ url: `${WEB}/__upload_probe__` }))
|
||
let up = await tryUpload(300_000)
|
||
const lastUp = up.events.at(-1)
|
||
check('字节发完报 sent,已发 = 总数,且不小于文件大小', !!lastUp?.sent && lastUp.loaded === lastUp.total && lastUp.total >= 300_000,
|
||
JSON.stringify(up.events.slice(-2)))
|
||
check('sent 只在最后', up.events.filter((p) => p.sent).length === 1 && !up.events.slice(0, -1).some((p) => p.sent))
|
||
check('非 JSON 的错误响应:http 类的 ApiError', up.err?.isApi && up.err.kind === 'http' && up.err.status === 404, JSON.stringify(up.err))
|
||
await uploadVia((r) => r.fulfill({
|
||
status: 200, contentType: 'application/json',
|
||
body: JSON.stringify({ id: 'doc_demo', collection: 'default', title: 'orders.csv', source: 'upload', mime: 'text/csv', chunk_count: 0, status: 'processing' }),
|
||
}))
|
||
up = await tryUpload(10)
|
||
check('成功:返回值照常解析', up.doc?.id === 'doc_demo', JSON.stringify(up.err ?? up.doc))
|
||
await uploadVia((r) => r.fulfill({
|
||
status: 409, contentType: 'application/json', body: JSON.stringify({ detail: '同名文档已经在处理:等它处理完再传' }),
|
||
}))
|
||
up = await tryUpload(10)
|
||
check('后端拒绝:和 fetch 那条路一样的 ApiError,message 是 detail', up.err?.isApi && up.err.status === 409
|
||
&& up.err.kind === 'http' && up.err.message === '同名文档已经在处理:等它处理完再传', JSON.stringify(up.err))
|
||
await uploadVia((r) => r.abort('connectionrefused'))
|
||
up = await tryUpload(10)
|
||
check('连不上:network 类的 ApiError', up.err?.isApi && up.err.kind === 'network' && up.err.status === 0, JSON.stringify(up.err))
|
||
await page.unroute(isUpload)
|
||
// 网络失败会顺手探一次活,等它落定再往下,免得下一段的探活计数多一次
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().checkBackend())
|
||
|
||
console.log('\n=== 连接状态:心跳、退避、恢复 ===')
|
||
const expectRuns = await fetch(`${API}/runs?limit=3`).then((r) => r.json()).then((r) => r.length, () => null)
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
|
||
let s = await page.evaluate(() => { const s = window.__ui.useCatalog.getState(); return { backend: s.backend, latency: s.latencyMs, checks: s.checks.map((c) => c.state) } })
|
||
check('refresh 成功:backend=ok、测到延迟、6 项清单都 ok', s.backend === 'ok' && s.latency != null && s.checks.length === 6 && s.checks.every((c) => c === 'ok'), JSON.stringify(s))
|
||
const reconnects0 = (await state()).reconnects
|
||
const loads = async () => Number(await page.locator('#local-list-loads').innerText())
|
||
const count = () => page.locator('#local-list-count').innerText()
|
||
|
||
let probes = 0
|
||
await page.unroute(isApi)
|
||
await page.route(isApi, (r) => {
|
||
if (new URL(r.request().url()).pathname === '/api/health') probes++
|
||
return r.abort('connectionrefused')
|
||
})
|
||
await page.evaluate(() => { window.__stopHeartbeat = window.__ui.useCatalog.getState().startHeartbeat() })
|
||
// 第一次断开:refresh 六个全失败,第一个失败已经起了一次探活——只能记一笔
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
|
||
s = await state()
|
||
check('断开判成 down,原因是人话', s.backend === 'down' && s.err && !/failed to fetch/i.test(s.err), JSON.stringify(s))
|
||
check('第一次断开只探活一次', probes === 1, `探了 ${probes} 次`)
|
||
check('第一次重试在 4 秒后(不是 8 秒)', s.retryIn > 3000 && s.retryIn <= 4100, `${s.retryIn}ms`)
|
||
// 页面自己拉的列表在断开期间拿到 []
|
||
await page.click('#local-list-reload')
|
||
await page.waitForTimeout(300)
|
||
check('断开期间页面自己的列表是空的', (await count()) === '0')
|
||
await page.waitForTimeout(s.retryIn + 700)
|
||
s = await state()
|
||
check('第二次重试在 8 秒后', s.backend === 'down' && s.retryIn > 5500 && s.retryIn <= 8100 && probes === 2, `${s.retryIn}ms,探了 ${probes} 次`)
|
||
// 横幅每秒刷一次,读到的可能是上一秒的数
|
||
const shown = Number((await page.getByText(/秒后自动重试/).innerText()).match(/\d+/)?.[0])
|
||
const secs = Math.ceil(s.retryIn / 1000)
|
||
check('横幅倒计时和实际重试一致', shown === secs || shown === secs + 1, `横幅 ${shown} 秒,实际 ${secs} 秒`)
|
||
await page.waitForTimeout(s.retryIn + 700)
|
||
s = await state()
|
||
check('第三次重试在 16 秒后', s.backend === 'down' && s.retryIn > 12000 && s.retryIn <= 16100 && probes === 3, `${s.retryIn}ms,探了 ${probes} 次`)
|
||
|
||
await page.unroute(isApi)
|
||
await page.route(isApi, (r) => (r.request().method() === 'GET' ? r.continue() : r.abort()))
|
||
const loadsBefore = await loads()
|
||
await page.getByRole('button', { name: '立即重试' }).first().click()
|
||
await page.waitForTimeout(1500)
|
||
s = await state()
|
||
const checks = await page.evaluate(() => window.__ui.useCatalog.getState().checks.map((c) => c.state))
|
||
check('恢复后 backend=ok,catalog 自动 refresh', s.backend === 'ok' && checks.length === 6 && checks.every((c) => c === 'ok'), JSON.stringify(checks))
|
||
check('恢复算一次重连', s.reconnects === reconnects0 + 1, `${reconnects0} → ${s.reconnects}`)
|
||
check('useOnReconnect:页面自己的列表重拉了一次', (await loads()) === loadsBefore + 1, `${loadsBefore} → ${await loads()}`)
|
||
if (expectRuns != null) {
|
||
check('页面自己的列表不再是假的空', (await count()) === String(expectRuns), `${await count()} 条,后端有 ${expectRuns} 条`)
|
||
}
|
||
check('横幅消失', (await page.getByText('后端未连接').count()) === 0)
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
|
||
check('连着时再 refresh 不算重连', (await state()).reconnects === reconnects0 + 1)
|
||
|
||
// 后端卡死:连得上但不回
|
||
await page.route((u) => new URL(u).pathname === '/api/health', () => { /* 永远不回 */ })
|
||
await page.route((u) => new URL(u).pathname.startsWith('/api/approvals'), (r) => r.abort('connectionrefused'))
|
||
const t0 = Date.now()
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refreshApprovals())
|
||
s = await state()
|
||
check('健康检查卡住时 5 秒超时判 down', s.backend === 'down' && Date.now() - t0 < 8000, `${JSON.stringify(s)} ${Date.now() - t0}ms`)
|
||
await page.evaluate(() => window.__stopHeartbeat())
|
||
|
||
const harnessReloaded = reloads > 0
|
||
console.log('\n=== 真实页面:toast 不压工具栏 ===')
|
||
// 工具栏是各页自己的,谁把它加高了,常驻的出错 toast 就会盖住按钮
|
||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||
await page.route(isApi, (r) => (r.request().method() === 'GET' ? r.continue() : r.abort()))
|
||
const wf = await fetch(`${API}/workflows`).then((r) => r.json()).then((l) => l[0]?.id, () => null)
|
||
for (const width of [1280, 1180]) {
|
||
await page.setViewportSize({ width, height: 800 })
|
||
for (const path of ['/chat', wf ? `/studio/${wf}` : '/studio', '/runs', '/tools', '/knowledge', '/settings']) {
|
||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(300)
|
||
const r = await page.evaluate(() => {
|
||
const stack = document.querySelector('[role="alert"][aria-live="assertive"]')?.parentElement
|
||
if (!stack) return null
|
||
const box = stack.getBoundingClientRect()
|
||
// 从顶部 48px 那一条里起头的可点元素 = 工具栏。下面列表的行被盖住一角是
|
||
// 浮层的本分,不算
|
||
const hits = [...document.querySelectorAll('main button, main a, main input, main select, main [role="tab"]')]
|
||
.map((el) => ({ el, b: el.getBoundingClientRect() }))
|
||
.filter(({ b }) => b.width && b.height && b.top < 48 && b.bottom > box.top && b.right > box.left && b.left < box.right)
|
||
.map(({ el }) => (el.getAttribute('aria-label') || el.textContent || el.tagName).trim().slice(0, 16))
|
||
return { top: Math.round(box.top), hits }
|
||
})
|
||
check(`${width}px ${path.replace(/[0-9a-f]{32}/, ':id')}:toast 区不压工具栏`, r && r.hits.length === 0, r ? (r.hits.join('、') || `top=${r.top}`) : '页面上没有 ToastHost')
|
||
}
|
||
}
|
||
|
||
check('没有未捕获的运行时错误', errors.length === 0, errors[0] ?? '')
|
||
if (harnessReloaded) console.log('\n!预览页中途被 vite 整页刷新过(有别的文件在改),上面的失败可能是它引起的,重跑一次')
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 基础组件全部通过')
|
||
process.exit(failed ? 1 : 0)
|