Files
AgentLab/scripts/check-ui-kit.mjs
ThearchyHelios a56c405c9a check-ui-kit:存量驳回的另三种原话都归成「人工审批驳回」、不给继续运行
为什么:lib/explain 的正则只留给存量 failed+驳回(新的驳回结束在 cancelled,界面按 end_reason 说),以前只认「人工驳回」一种,
动作确认、工具调用审批、代码执行审批的驳回原话落到兜底、给出注定再失败的「继续运行」(05-已知遗留 (a))。这三种要有检查守着。

做了什么:失败归类的样本加三对(「「付款」未确认(被驳回),没有发送:…」「工具 x 的调用已被驳回:…」「代码执行已被驳回:…」),
期望标题「人工审批驳回,运行终止」、continuable=false;「片段还在后端源码里」那一项加上 tools.py 里这三处原话。
验证:check-all --stacks 1 ui-kit 497 项通过。
2026-10-16 11:07:17 +08:00

1586 lines
128 KiB
JavaScript
Raw Permalink 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.
// 基础组件(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),默认连 5273 / 8000。对别的实例(比如一份
// 沙箱拷贝)跑时带上地址:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-ui-kit.mjs
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'
// 不用 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++
}
/**
* 一节一节地跑:某一节里元素找不到、等待超时,只记成这一节失败,接着跑下一节,
* 不让一处卡住把后面的检查一起吞掉。页面是各节共用的:
* 出错那一节停在哪,下一节就从哪接着
*/
async function section(name, fn) {
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
}
}
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 }
})
await section('lib:格式、状态、快捷键、术语、报错', async () => {
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 分')
// 跨天的跨度和相对时刻:记录页、时间轴、HUD 共用这一份(以前在 pages/runs/model 里)
eq('formatSpan 一天以内同 formatDuration', f.formatSpan(74_000), '1 分 14 秒')
eq('formatSpan 跨天:8 天 23 小时', f.formatSpan(8 * 86_400_000 + 23 * 3_600_000 + 5 * 60_000), '8 天 23 小时')
eq('formatSpan 整天不写 00 小时', f.formatSpan(2 * 86_400_000 + 1000), '2 天')
eq('formatSpan 粗粒度:8 天', f.formatSpan(8 * 86_400_000 + 23 * 3_600_000, { coarse: true }), '8 天')
eq('formatSpan 粗粒度:3 小时', f.formatSpan(3 * 3_600_000 + 59 * 60_000, { coarse: true }), '3 小时')
eq('formatSpan 粗粒度:不到 1 分钟', f.formatSpan(40_000, { coarse: true }), '不到 1 分钟')
eq('formatSpan 拿不到写 —', f.formatSpan(null), '—')
// 已等、等人的共用写法:一小时以内是钟面,一小时以上按 formatSpan(3C REQ-19)
eq('formatLapse 一小时以内是钟面', f.formatLapse?.(1_500_300), '25:00.3')
eq('formatLapse 不要十分位', f.formatLapse?.(1_500_300, false), '25:00')
eq('formatLapse 过了一小时按时长写', f.formatLapse?.(3 * 3_600_000 + 5 * 60_000), '3 小时 05 分')
eq('formatLapse 跨天:9 天 03 小时', f.formatLapse?.(9 * 86_400_000 + 3 * 3_600_000 + 34 * 60_000), '9 天 03 小时')
eq('formatLapse 拿不到写 —', f.formatLapse?.(Number.NaN), '—')
eq('formatOffset 一天以内是秒表读数', f.formatOffset(303_400), '05:03.4')
eq('formatOffset 跨天:9 天 00:38:26', f.formatOffset(9 * 86_400_000 + 38 * 60_000 + 26_200), '9 天 00:38:26')
eq('formatOffset 负数写 —', f.formatOffset(-1), '—')
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('token 千分位', f.formatTokens(56034), '56,034 token')
eq('token 紧凑', f.formatTokens(56034, { compact: true }), '56.0k token')
eq('token 紧凑进位到 M', f.formatTokens(999_950, { compact: true }), '1.0M token')
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'), '完整出具')
// 没挂在工作流上的运行:老数据叫「临时图」,界面上一律「未保存的工作流」(3C 返工 D3)
eq('老数据的「临时图」叫未保存的工作流', t.runName?.({ workflow_id: null, workflow_name: '临时图' }), '未保存的工作流')
eq('新后端的名字原样认得', t.isUnsaved?.({ workflow_id: null, workflow_name: '未保存的工作流' }), true)
eq('没有名字也算未保存', t.runName?.({ workflow_id: null, workflow_name: '' }), '未保存的工作流')
eq('工作流删了留下的运行:还叫原来的名字', t.runName?.({ workflow_id: null, workflow_name: 'sales_daily' }), 'sales_daily')
eq('挂在工作流上的,名字叫临时图也照写', t.runName?.({ workflow_id: 'w1', workflow_name: '临时图' }), '临时图')
// 报错
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))
// 后端现在的原话(core/errors.py 的 NETWORK 一类)
const objNow = e.humanizeError({ ok: false, error: '无法连接服务:网络不通或服务未启动', hint: '请检查地址和端口,确认服务已启动且本机可以访问', detail: 'ConnectError: [Errno 61]' })
ok('{error, hint, detail}:后端现在的说法', objNow.title === '无法连接服务:网络不通或服务未启动' && objNow.action === '请检查地址和端口,确认服务已启动且本机可以访问'
&& objNow.raw === 'ConnectError: [Errno 61]' && objNow.kind !== 'network', JSON.stringify(objNow))
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, '操作超时')
// 403 的「怎么办」按开没开登录说:关登录时是署名和权限设置的事;开了登录按角色和授权定,找管理员,不提署名
const denied = () => e.humanizeError(new ApiError(403, '没有权限:这项操作需要搭建者及以上的角色'))
eq('关登录的 403:检查署名和权限', denied().action, '请检查设置中的署名和权限。')
const { useAuth } = window.__ui
const was = useAuth.getState()
useAuth.setState({ me: { mode: 'local', username: 'u1', display_name: 'u1', role: 'user', groups: [], verified: true }, ready: true })
const on = denied()
useAuth.setState({ me: was.me, ready: was.ready })
ok('开了登录的 403:找管理员调整角色或授权,不提署名', on.action.includes('管理员') && !on.action.includes('署名')
&& on.title.includes('搭建者'), JSON.stringify(on))
ok('……原因里不带异常类名', !/ReadTimeout/.test(e.humanizeError('ReadTimeout: timed out').reason ?? ''))
// 失败说明(lib/explain):记录页、助手流、问数据页共用一份
const x = window.__ui.lib.explain
// 下面喂给 explain 的后端原文分两组:老说法是「老运行」样本(库里历史运行的 run.error 存的是它们),
// 前端要继续认得;紧跟着的「后端现在的说法」取自后端源码当前的原文,两组都得认出来
const leak = x.explainRunError('模型输出了工具调用的原始标记,但没有真正调用工具。常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。')
ok('工具调用标记泄漏:说人话、不给接着跑、指到画布', leak.title === '模型未实际调用工具' && !leak.continuable && leak.fix === 'canvas', JSON.stringify(leak))
// 后端现在的说法(engine/toolcalls.py TOOL_MARKUP_ERROR)
const leakNow = x.explainRunError('模型以文本形式输出了工具调用的原始标记,未实际调用工具,这一步没有查询到任何数据。'
+ '常见原因:节点未绑定工具,或模型、服务不支持工具调用。请在画布中为该节点绑定所需工具;如已绑定仍出现此问题,请换用支持工具调用的模型')
ok('工具调用标记泄漏(后端现在的说法):同样认得', leakNow.title === '模型未实际调用工具' && !leakNow.continuable && leakNow.fix === 'canvas', JSON.stringify(leakNow))
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」,但节点未绑定此工具')
// 后端现在的说法(engine/schema.py:带「」、成员一种另说)
eq('提示词点名的工具没绑定(后端现在的说法)', x.explainRunError('提示词要求用「db_query__shop」,但节点没有绑定它').title,
'提示词要求使用「db_query__shop」,但节点未绑定此工具')
eq('……成员的提示词点名的工具没绑定', x.explainRunError('成员「研究员」的提示词要求用「db_query__shop」,但没有给这个成员绑定它').title,
'成员「研究员」的提示词要求使用「db_query__shop」,但该成员未绑定此工具')
eq('老运行的鉴权失败', x.explainRunError('NodeError: AuthenticationError: Error code: 401 - invalid_api_key').title, '模型鉴权失败')
// 后端现在的说法(core/errors.py 的 AUTH 一类)
const authNow = x.explainRunError('鉴权失败(401):服务方拒绝了当前 API Key')
ok('鉴权失败(后端现在的说法):同样认得、给设置入口', authNow.title === '模型鉴权失败' && authNow.fix === 'settings' && authNow.continuable, JSON.stringify(authNow))
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))
// 团队轮数用完:后端原话把「先看成员…调大最多轮数」也写进了冒号后面,原因只要理由和
// 没派到的成员,建议交给 action——否则同样的话在报错里说两遍
const tiredFull = x.explainRunError('NodeError: 协作团队用完 4 轮仍未完成:还没有查到任何订单数据。一次都没被派到的成员:汇总员。'
+ '先看成员有没有绑定要用的工具,再调大「最多轮数」;也可以把「用完轮数时」改成降档交付')
eq('团队轮数用完:原因在「。先看成员」处截断', tiredFull.reason, '还没有查到任何订单数据。一次都没被派到的成员:汇总员')
ok('……action 里有最多轮数,原因里没有', /最多轮数/.test(tiredFull.action) && !/最多轮数|降档交付/.test(tiredFull.reason), JSON.stringify(tiredFull))
eq('团队按降档交付的原话:原因在「。按降档交付」处截断',
x.explainRunError('协作团队用完 3 轮仍未完成:还缺结论。按降档交付:成果是成员最后的原话,不是调度者认可的结论').reason, '还缺结论')
// 后端现在的说法(engine/nodes/multi.py:「。先看成员」「。按降档交付」两个锚点没变,后半句改了)
const tiredNow = x.explainRunError('NodeError: 协作团队用完 4 轮仍未完成:还没有查到任何订单数据。一次都没被派到的成员:汇总员。'
+ '先看成员是否绑定了所需工具,再调大「最多轮数」;也可将「用完轮数时」改为「降档交付」')
eq('团队轮数用完(后端现在的说法):原因在「。先看成员」处截断', tiredNow.reason, '还没有查到任何订单数据。一次都没被派到的成员:汇总员')
ok('……action 里有最多轮数,原因里没有', /最多轮数/.test(tiredNow.action) && !/最多轮数|降档交付/.test(tiredNow.reason) && !tiredNow.continuable,
JSON.stringify(tiredNow))
eq('团队按降档交付(后端现在的说法):原因在「。按降档交付」处截断',
x.explainRunError('协作团队用完 3 轮仍未完成:还缺结论。按降档交付:成果取自成员最后的回复,并非调度者认可的结论').reason, '还缺结论')
// 超时:后端已经给了具体原因和建议的中文原话,不能换成笼统的「下游服务没在限定时间内响应」
const qto = x.explainRunError('KeyError: 查询超时,数据库没有在 30 秒内返回')
ok('中文超时原话:原因照原样留下', qto.reason === '查询超时,数据库没有在 30 秒内返回'
&& !/下游服务/.test(`${qto.reason}${qto.action}`), JSON.stringify(qto))
const qlong = x.explainRunError('查询超过 30s 没有返回,已放弃等待(数据库那边可能还在跑,连接会在后台收回)。加上 WHERE 条件或 LIMIT 缩小范围再查')
ok('中文超时原话:后端的建议进 action,原因里不再重复', /WHERE 条件或 LIMIT/.test(qlong.action ?? '')
&& /查询超过 30s 没有返回/.test(qlong.reason ?? '') && !/WHERE/.test(qlong.reason ?? ''), JSON.stringify(qlong))
// 后端现在的说法(engine/toolcalls.py ToolTimeout)
const qlongNow = x.explainRunError('查询超过 30 秒没有返回,已停止等待(数据库可能仍在执行,连接会在后台回收)。请添加 WHERE 条件或 LIMIT 缩小范围后重试')
ok('中文超时原话(后端现在的说法):建议进 action,原因里不再重复', /WHERE 条件或 LIMIT/.test(qlongNow.action ?? '')
&& /查询超过 30 秒没有返回/.test(qlongNow.reason ?? '') && !/WHERE/.test(qlongNow.reason ?? '') && qlongNow.continuable, JSON.stringify(qlongNow))
const unified = x.explainRunError('等待超时:对方没有在限定时间内响应')
ok('「标题:原因」式的中文超时:拆成标题和原因', unified.title === '等待超时' && unified.reason === '对方没有在限定时间内响应'
&& unified.continuable, JSON.stringify(unified))
// 后端现在的说法(core/errors.py 的 TIMEOUT 一类)
const unifiedNow = x.explainRunError('等待超时:服务方未在限定时间内响应')
ok('「标题:原因」式的中文超时(后端现在的说法):拆成标题和原因', unifiedNow.title === '等待超时' && unifiedNow.reason === '服务方未在限定时间内响应'
&& unifiedNow.continuable, JSON.stringify(unifiedNow))
const en = x.explainRunError('ReadTimeout: timed out')
ok('英文超时原文照旧给通用说法', en.title === '等待超时' && /限定时间/.test(en.reason ?? '') && !/ReadTimeout/.test(en.reason ?? ''), JSON.stringify(en))
// 自定义工具的参数定义写坏了:先去工具页改好(直达那一项),改好之后接着跑能过
const broken = x.explainRunError('自定义工具「lookup_order」的参数定义格式不对:参数 store 要写成 {"type": "string"} 这样的对象,'
+ '不能直接写 "string"。到「工具」页把它的参数定义改好再运行')
ok('坏参数定义:点名是哪个工具、fix 指到工具页', broken.fix === 'tools' && broken.title.includes('lookup_order')
&& /参数 store/.test(broken.reason ?? ''), JSON.stringify(broken))
ok('……主按钮先去修(fixFirst),直达这一项的编辑', broken.fixFirst === true && broken.continuable
&& broken.fixTo === '/tools/custom?edit=lookup_order', JSON.stringify(broken))
ok('……不和「找不到工具」混为一谈', !/找不到/.test(broken.title))
const brokenObj = x.explainRunError('自定义工具「lookup_order」的参数定义要是一个 JSON 对象。到「工具」页把它的参数定义改好再运行')
ok('坏参数定义(不是对象)也认得', brokenObj.fix === 'tools' && brokenObj.fixFirst === true, JSON.stringify(brokenObj))
// 后端把几个坏工具用「;」连成一句(tools/custom.build_tools);单个工具的原因里自己也可能带「;」
const typeProblem = '参数定义格式不对:参数 n 的类型「int」认不出来,是不是想写 integer;只能是 string、integer、number、boolean、array、object 里的一个'
const brokenOne = x.explainRunError(`自定义工具「sales_daily_api」的${typeProblem}。到「工具」页把它的参数定义改好再运行`)
ok('坏参数定义:原因里自带的「;」不把后半句切丢', brokenOne.title.includes('sales_daily_api') && /只能是 string/.test(brokenOne.reason ?? ''),
JSON.stringify(brokenOne))
const brokenTwo = x.explainRunError(`ToolBuildError: 自定义工具「lookup_order」的参数定义要是一个 JSON 对象。到「工具」页把它的参数定义改好再运行;`
+ `自定义工具「sales_daily_api」的${typeProblem}。到「工具」页把它的参数定义改好再运行`)
ok('两个坏工具:标题说有几个,不只认第一个', brokenTwo.title === '2 个自定义工具的参数定义有误', JSON.stringify(brokenTwo))
ok('……原因里两个都点名,各自的原因都在', /lookup_order/.test(brokenTwo.reason ?? '') && /sales_daily_api/.test(brokenTwo.reason ?? '')
&& /JSON 对象/.test(brokenTwo.reason ?? '') && /只能是 string/.test(brokenTwo.reason ?? '') && !/到「工具」页/.test(brokenTwo.reason ?? ''),
JSON.stringify(brokenTwo))
ok('……怎么办里也点名两个,直达第一个的编辑', /lookup_order/.test(brokenTwo.action ?? '') && /sales_daily_api/.test(brokenTwo.action ?? '')
&& brokenTwo.fixTo === '/tools/custom?edit=lookup_order' && brokenTwo.fix === 'tools' && brokenTwo.fixFirst === true && brokenTwo.continuable,
JSON.stringify(brokenTwo))
// 后端现在的说法(tools/custom.py:前缀「格式有误」、结尾「到「工具」页修改参数定义后再运行」,几个坏工具仍用「;」连成一句)
const brokenNow = x.explainRunError('自定义工具「lookup_order」的参数定义格式有误:参数 store 应写成 {"type": "string"} 这样的对象,'
+ '不能直接写 "string"。到「工具」页修改参数定义后再运行')
ok('坏参数定义(后端现在的说法):点名是哪个工具、fix 指到工具页、主按钮先去修', brokenNow.fix === 'tools' && brokenNow.title === '自定义工具「lookup_order」的参数定义有误'
&& /参数 store/.test(brokenNow.reason ?? '') && !/到「工具」页/.test(brokenNow.reason ?? '') && brokenNow.fixFirst === true && brokenNow.continuable
&& brokenNow.fixTo === '/tools/custom?edit=lookup_order', JSON.stringify(brokenNow))
const typeProblemNow = '参数定义格式有误:无法识别参数 n 的类型「int」,是否应为 integer;只能是 string、integer、number、boolean、array、object 中的一个'
const brokenTwoNow = x.explainRunError('ToolBuildError: 自定义工具「lookup_order」的参数定义必须是一个 JSON 对象。到「工具」页修改参数定义后再运行;'
+ `自定义工具「sales_daily_api」的${typeProblemNow}。到「工具」页修改参数定义后再运行`)
ok('两个坏工具(后端现在的说法):标题说有几个,两个的原因都在、原因里自带的「;」不切丢', brokenTwoNow.title === '2 个自定义工具的参数定义有误'
&& /lookup_order/.test(brokenTwoNow.reason ?? '') && /sales_daily_api/.test(brokenTwoNow.reason ?? '') && /JSON 对象/.test(brokenTwoNow.reason ?? '')
&& /只能是 string/.test(brokenTwoNow.reason ?? '') && !/到「工具」页/.test(brokenTwoNow.reason ?? '')
&& brokenTwoNow.fixTo === '/tools/custom?edit=lookup_order' && brokenTwoNow.fixFirst === true, JSON.stringify(brokenTwoNow))
// 超时原话按「;」拆开时,原因的末尾不能挂着半个分号
const semi = x.explainRunError('调用模型超时(60 秒),对方没有响应;换个时段再试')
ok('中文超时按「;」拆:原因以句号收尾,不挂「;」', semi.reason === '调用模型超时(60 秒),对方没有响应。' && semi.action === '换个时段再试。',
JSON.stringify(semi))
// 3b 起查询的时限归数据源管(查询时限 query_timeout_s),不在画布的节点上
ok('查询超时、后端没给建议:指到「数据」页的查询时限,不再说调画布里节点的超时',
/查询时限/.test(qto.action ?? '') && /「数据」页/.test(qto.action ?? '') && /WHERE/.test(qto.action ?? '') && !/画布/.test(qto.action ?? ''),
JSON.stringify(qto))
return out
})) check(name, ok, detail)
})
await section('按后端原文匹配的地方:失败归类认得新旧两种原文,给模型的 for_model 不进界面(文案整改第二轮)', async () => {
// lib/explain 按 run.error 的原文把失败归类(run.error 没有错误码)。开发库里历史运行存的是整改前的文字,
// 新运行是整改后的:两种各喂一遍,归类(标题、能不能继续运行、修复入口)要一样。
// 「新」样本和 backend/app 现在的写法一致(先核对源码片段);「旧」样本是整改前(提交 68fb353)的原话,
// 只用来证明旧文字仍认得,不要跟着后端改
const src = (f) => readFileSync(new URL(`../backend/app/${f}`, import.meta.url), 'utf8')
const SOURCE = [
['engine/launch.py', 'f"绑定的工具不存在:{lines}{more}。{fix}"'],
['engine/toolcalls.py', '秒没有返回,已停止等待'],
['data/engine.py', '秒被中断。请添加 WHERE 条件或 LIMIT 缩小范围。'],
['core/errors.py', '"等待超时:服务方未在限定时间内响应"'],
['tools/custom.py', '参数定义格式有误:'],
['tools/custom.py', '。到「工具」页修改参数定义后再运行'],
['engine/nodes/io.py', '输出的不是合法 JSON({where}附近),{reason};{fix}'],
['engine/nodes/io.py', '是{what},放入 JSON 时请写成 {{{{ {expr} | json }}}}(外面不要再加引号)'],
['engine/toolcalls.py', '模型以文本形式输出了工具调用的原始标记,未实际调用工具'],
['engine/nodes/multi.py', '协作团队用完 {max_rounds} 轮仍未完成:'],
['engine/nodes/human.py', '修复时出现了原文没有的值:'],
['engine/nodes/human.py', 'f"人工驳回:'],
// 存量 failed+驳回的另三种原话(05-已知遗留 (a):新的驳回结束在 cancelled,界面按 end_reason 说,这几条只留给老运行)
['engine/nodes/tools.py', '未确认(被驳回),没有发送'],
['engine/nodes/tools.py', '的调用已被驳回'],
['engine/nodes/tools.py', '"代码执行已被驳回"'],
['core/errors.py', '鉴权失败{code}:服务方拒绝了当前 API Key'],
['core/errors.py', '"请求过于频繁或额度已用完(429)"'],
['core/errors.py', '"无法连接服务:网络不通或服务未启动"'],
['core/errors.py', 'f"服务方内部错误(HTTP {status})"'],
['core/errors.py', 'f"参数不匹配:不接受名为「{m.group(1)}」的参数"'],
['tools/registry.py', 'f"找不到工具 {name}"'],
['engine/schema.py', '至少需要配置一个分支条件'],
['engine/runner.py', '"服务重启,运行已挂起,可从断点继续运行"'],
]
const gone = SOURCE.filter(([f, frag]) => !src(f).includes(frag))
check('「新」样本的片段在后端源码里都还在(后端改了说法就同步这里的样本和 lib/explain 的正则)', gone.length === 0,
gone.map(([f, frag]) => `${f}:${frag}`).join(' | '))
// [说明, 后端现在的原文, 整改前的原文, 期望标题, 能否继续运行, 修复入口]
const PAIRS = [
['绑定的工具不存在(发起时就被拒)',
'绑定的工具不存在:「取数」绑定的 db_query__erp。请到「数据」页接入,或在节点中重新选择',
'绑定的工具在本机不存在:「取数」绑的 db_query__erp。去数据页接入,或在节点里重新选',
'绑定的工具「db_query__erp」不存在', false, 'tools'],
['查询到点放弃',
'查询超过 30 秒没有返回,已停止等待(数据库可能仍在执行,连接会在后台回收)。请添加 WHERE 条件或 LIMIT 缩小范围后重试',
'查询超过 30s 没有返回,已放弃等待(数据库那边可能还在跑,连接会在后台收回)。加上 WHERE 条件或 LIMIT 缩小范围再查',
'查询超时', true, undefined],
['数据库按时限停下语句',
'查询超过 30 秒被中断。请添加 WHERE 条件或 LIMIT 缩小范围。',
'查询超过 30s 被中断。加上 WHERE 条件或 LIMIT 缩小范围。',
'查询超时', true, undefined],
['等待超时(统一的翻译层)', '等待超时:服务方未在限定时间内响应', '等待超时:对方没有在限定时间内响应', '等待超时', true, undefined],
['自定义工具的参数定义有误',
'自定义工具「lookup_order」的参数定义格式有误:参数 store 应写成 {"type": "string"} 这样的对象,不能直接写 "string"。到「工具」页修改参数定义后再运行',
'自定义工具「lookup_order」的参数定义格式不对:参数 store 要写成 {"type": "string"} 这样的对象,不能直接写 "string"。到「工具」页把它的参数定义改好再运行',
'自定义工具「lookup_order」的参数定义有误', true, 'tools'],
['上游模型写的 JSON 解析失败',
'上游「写摘要」输出的不是合法 JSON(第 12 列附近),模型写的文字中包含未转义的英文引号;如需模型输出结构化数据,请配置「结构化输出 Schema」并开启「按出处核对字段」,不要用「数据整形」节点解析模型写的文字',
'上游「写摘要」输出的不是合法 JSON(第 12 列附近),它写的文字里有没转义的英文引号;要模型给结构化数据就配「结构化输出 Schema」并打开「按出处核对字段」,别用整形节点解析模型写的文字',
'上游「写摘要」输出的不是合法 JSON(第 12 列附近)', false, 'canvas'],
['模板渲染出的 JSON 有误、后端点名了那一处',
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)。模板里 {{ nodes.a.text }} 是模型写的文字,放入 JSON 时请写成 {{ nodes.a.text | json }}(外面不要再加引号)',
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)。模板里 {{ nodes.a.text }} 是模型写的文字,放进 JSON 要写成 {{ nodes.a.text | json }}(外面不要再加引号)',
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)', false, 'canvas'],
['模型没真调工具',
'模型以文本形式输出了工具调用的原始标记,未实际调用工具,这一步没有查询到任何数据。常见原因:节点未绑定工具,或模型、服务不支持工具调用。',
'模型输出了工具调用的原始标记,但没有真正调用工具,这一步一次都没查到数据。常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。',
'模型未实际调用工具', false, 'canvas'],
['协作团队用完轮数',
'协作团队用完 3 轮仍未完成:调度者未给出理由。一次都没被派到的成员:汇总员。先看成员是否绑定了所需工具,再调大「最多轮数」;也可将「用完轮数时」改为「降档交付」',
'协作团队用完 3 轮仍未完成:调度者没有给出理由。一次都没被派到的成员:汇总员。先看成员有没有绑定要用的工具,再调大「最多轮数」;也可以把「用完轮数时」改成降档交付',
'协作团队用完 3 轮仍未完成', false, 'canvas'],
['校验修复编出了原文没有的值',
'结构化校验未通过:修复时出现了原文没有的值:total_count=0', '结构化校验没通过:修复时出现了原文没有的值:total_count=0',
'校验修复未采用:修复结果中出现了原文没有的值', false, 'canvas'],
['人工驳回(整改前有一条路径写「人工拒绝」)', '人工驳回:数字对不上', '人工拒绝:数字对不上', '人工审批驳回,运行终止', false, undefined],
// 存量的另三种驳回原话:都不给「继续运行」(接着跑只会读到同一个驳回答复)
['动作确认被驳回(存量)', '「付款」未确认(被驳回),没有发送:金额不对', '「付款」未确认(被驳回),没有发送',
'人工审批驳回,运行终止', false, undefined],
['工具调用审批被驳回(存量)', '工具 calculator 的调用已被驳回:不能算', '工具 calculator 的调用已被驳回',
'人工审批驳回,运行终止', false, undefined],
['代码执行审批被驳回(存量)', '代码执行已被驳回:不安全', '代码执行已被驳回', '人工审批驳回,运行终止', false, undefined],
['模型鉴权失败', '鉴权失败(401):服务方拒绝了当前 API Key', '鉴权没通过(401):对方拒绝了这把密钥', '模型鉴权失败', true, 'settings'],
['限流或额度用完', '请求过于频繁或额度已用完(429)', '请求太频繁,或者额度用完了(429)', '请求过于频繁,或额度已用完', true, undefined],
['连不上外部服务', '无法连接服务:网络不通或服务未启动', '连不上对方的服务:网络不通,或者服务没有启动', '无法连接外部服务', true, undefined],
['外部服务 5xx', '服务方内部错误(HTTP 502)', '对方服务出错了(HTTP 502)', '外部服务返回错误', true, undefined],
['工具参数对不上', '参数不匹配:不接受名为「store」的参数', '参数对不上:它不接受名为「store」的参数', '工具参数不匹配', true, 'canvas'],
['找不到工具', '找不到工具 lookup_order', "找不到工具 'lookup_order'", '找不到工具「lookup_order」', true, 'tools'],
['节点配置不完整', '「条件分支」至少需要配置一个分支条件', '「条件分支」至少要配一个分支条件', '节点配置不完整', false, 'canvas'],
['服务重启挂起', '服务重启,运行已挂起,可从断点继续运行', '服务重启,运行已挂起,可从断点恢复', '服务重启中断了本次运行', true, undefined],
]
const got = await page.evaluate(async (pairs) => {
const x = window.__ui.lib.explain
const pick = (t) => { const r = x.explainRunError(t); return [r.title, r.continuable, r.fix ?? null] }
const at = (path) => performance.getEntriesByType('resource').map((e) => e.name)
.find((n) => { try { return new URL(n).pathname === path } catch { return false } }) ?? path
const client = await import(at('/src/api/client.ts'))
const body = client.parseBody(JSON.stringify({
type: 'report.checked',
data: { violations: [{ code: 'uncited_number', message: '数字「30」没有出处:报告中直接写出了数字,系统无法核对',
for_model: '数字「30」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字' }] },
issues: [{ level: 'error', message: '报告撰写的上游没有可引用数字的证据来源', for_model: '给模型的改法' }],
}))
return {
pairs: pairs.map(([, now, before]) => [pick(now), pick(before)]),
restart: pick('这次运行没有可恢复的断点,可能在服务重启前尚未开始执行。请重新发起运行。'),
pointed: [
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)。模板里 {{ nodes.a.text }} 是模型写的文字,放入 JSON 时请写成 {{ nodes.a.text | json }}(外面不要再加引号)',
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)。模板中的 {{ nodes.a.text }} 位于 JSON 引号内:请改写为 {{ nodes.a.text | json }}(外面不要再加引号)',
].map((t) => x.explainRunError(t).action),
body: JSON.stringify(body),
plain: JSON.stringify(client.parseBody('{"a":[1,{"b":"c"}]}')),
}
}, PAIRS.map(([what, now, before]) => [what, now, before])).catch((e) => ({ error: String(e) }))
if (got.error) { check('lib/explain、api/client 拿得到', false, got.error); return }
PAIRS.forEach(([what, , , title, continuable, fix], i) => {
const want = JSON.stringify([title, continuable, fix ?? null])
const [now, before] = got.pairs[i].map((r) => JSON.stringify(r))
check(`失败归类:${what}(后端现在的原文)`, now === want, now)
check(`失败归类:${what}(整改前的原文)`, before === want, before)
})
check('模板 JSON 有误、后端点名了那一处(运行时「模板里 {{」、校验「模板中的 {{」):照后端说,不再追加改用结构化输出那句',
got.pointed.every((a) => a.endsWith('修改后重新运行。') && !a.includes('结构化输出 Schema')), JSON.stringify(got.pointed))
check('「没有可恢复的断点」虽然提到服务重启,但不归到「可从断点继续运行」', got.restart[0] !== '服务重启中断了本次运行',
JSON.stringify(got.restart))
check('响应里给模型的 for_model 在进前端时就去掉,给人看的 message 留着', !got.body.includes('for_model')
&& !got.body.includes('给模型的改法') && got.body.includes('报告中直接写出了数字,系统无法核对') && got.body.includes('证据来源'), got.body)
check('……没有 for_model 的响应原样解析', got.plain === '{"a":[1,{"b":"c"}]}', got.plain)
})
await section('lib/evidence:证据状态的四通道元数据、报告节点的叫法、复核守卫', async () => {
for (const [name, ok, detail] of await page.evaluate(() => {
const { evidence: ev, terms: t } = window.__ui.lib
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])
const codes = ev.EVIDENCE_STATES
// 方案 6.1 的八种(确定性、概率性四种、无证据、连接性、旧运行候选),加三期无证据的两个变体:
// 可疑实体(疑似不存在的名称)、无法核实(表结构快照不全),加裁判拆档的两种:证据相矛盾、证据不足
ok('十二种状态一种不少', ['deterministic', 'supported', 'partial', 'contradicted', 'insufficient', 'unsupported', 'unjudged', 'none',
'connective', 'candidate', 'suspect', 'unverified'].every((c) => codes.includes(c)) && codes.length === 12, codes.join(','))
eq('可疑实体:点状线(同无证据)、?!、疑似不存在的名称', `${ev.EVIDENCE_STATE.suspect.line}/${ev.EVIDENCE_STATE.suspect.glyph}/${ev.EVIDENCE_STATE.suspect.label}`,
'dotted/?!/疑似不存在的名称')
ok('可疑实体醒目(进 n / N),核对不了只是标注', ev.EVIDENCE_STATE.suspect.alert && !ev.EVIDENCE_STATE.unverified.alert)
eq('片段状态:issue 为 unknown_entity 的是可疑实体', ev.segmentState({ kind: 'entity', state: 'none', issue: 'unknown_entity' }), 'suspect')
eq('片段状态:[[t:编造]] 的 cite 带 unknown 也是可疑实体', ev.segmentState({ kind: 'entity', state: 'none', cite: { unknown: true } }), 'suspect')
eq('片段状态:unverified_entity 是核对不了', ev.segmentState({ kind: 'entity', state: 'none', issue: 'unverified_entity' }), 'unverified')
eq('片段状态:原话对不上的引文仍是无证据', ev.segmentState({ kind: 'quote', state: 'none', issue: 'unresolved_ref' }), 'none')
ok('实体、引文另有字形和说法,线型颜色同「有出处」', ev.EVIDENCE_KIND_STYLE.entity.line === 'solid' && ev.EVIDENCE_KIND_STYLE.quote.line === 'solid'
&& ev.EVIDENCE_KIND_STYLE.quote.glyph === '“' && ev.EVIDENCE_KIND_STYLE.entity.color === ev.EVIDENCE_STATE.deterministic.color
&& /^var\(--st-[\w-]+\)$/.test(ev.EVIDENCE_KIND_STYLE.quote.color))
eq('章的两个数:引用 = 有出处的数字、值、实体、引文;无证据含可疑实体,require_citation 时加没挂依据的结论句',
JSON.stringify(ev.stampCounts({ numbers: 4, numbers_cited: 3, values: 1, entities: 5, quotes: 1, uncited_numbers: 1, unresolved: 1,
unknown_entities: 2, uncited_claims: 1 }, 'require_citation')), JSON.stringify({ cited: 10, none: 5 }))
eq('……结论句策略是 off 时不算没挂依据的结论句', ev.stampCounts({ numbers: 4, numbers_cited: 3, uncited_numbers: 1, unresolved: 0,
uncited_claims: 3 }, 'off')?.none, 1)
eq('……judge 在挂依据上和 require_citation 一样严:没挂依据的结论句也算', ev.stampCounts({ numbers: 4, numbers_cited: 3,
uncited_numbers: 1, unresolved: 0, uncited_claims: 3 }, 'judge')?.none, 4)
eq('可疑实体的提示:目录键、{name, table} 都写成去掉前缀的名字,最多 3 个',
ev.closestNames(['c:orders.amount', { alias: 't:refunds' }, { name: 'region', table: 'orders', kind: 'column' }, 'c:x']).join(','),
'orders.amount,refunds,orders.region')
const lines = new Set(['solid', 'dotted', 'badge', 'none'])
const bad = codes.filter((c) => {
const m = ev.EVIDENCE_STATE[c]
return !m || m.code !== c || !m.label || !lines.has(m.line) || typeof m.glyph !== 'string'
|| !/^var\(--st-[\w-]+\)$/.test(m.color) || !m.decoration || !/^var\(--st-[\w-]+\)$/.test(m.soft) || !m.hint
})
ok('每种都有线型、字形、颜色、文字(颜色只用 --st-*)', !bad.length, bad.join(','))
ok('下划线颜色也只取 --st-*(或透明)', codes.every((c) => {
const d = ev.EVIDENCE_STATE[c].decoration
return d === 'transparent' || /var\(--st-[\w-]+\)/.test(d)
}), codes.map((c) => ev.EVIDENCE_STATE[c].decoration).join(' | '))
// 裁判拆档:旧取值「证据不支持」按规格和证据相矛盾长得一样(同一类判定,拆档前后的叫法不同,文字仍不同),
// 除它之外线型 + 字形两两不同
const pairOf = (c) => `${ev.EVIDENCE_STATE[c].line}/${ev.EVIDENCE_STATE[c].glyph}`
const pairs = codes.filter((c) => c !== 'unsupported').map(pairOf)
ok('去掉颜色也分得开:线型 + 字形两两不同(旧的证据不支持同证据相矛盾)', new Set(pairs).size === codes.length - 1
&& pairOf('unsupported') === pairOf('contradicted'), codes.map(pairOf).join(' '))
const labels = codes.map((c) => ev.EVIDENCE_STATE[c].label)
ok('文字两两不同', new Set(labels).size === codes.length, labels.join(' '))
eq('确定性:细实线、有出处', `${ev.EVIDENCE_STATE.deterministic.line}/${ev.EVIDENCE_STATE.deterministic.label}`, 'solid/有出处')
eq('无证据:点状线、?、无证据', `${ev.EVIDENCE_STATE.none.line}/${ev.EVIDENCE_STATE.none.glyph}/${ev.EVIDENCE_STATE.none.label}`, 'dotted/?/无证据')
ok('概率性的「有依据」不用确定性的绿', ev.EVIDENCE_STATE.supported.color !== ev.EVIDENCE_STATE.deterministic.color)
eq('本期只会出现确定性和无证据', codes.filter((c) => ev.EVIDENCE_STATE[c].phase === 1).join(','), 'deterministic,none')
ok('只有异常态醒目(进 n / N 的跳转)', !ev.EVIDENCE_STATE.deterministic.alert && ev.EVIDENCE_STATE.none.alert)
eq('片段状态:deterministic', ev.segmentState({ kind: 'number', state: 'deterministic' }), 'deterministic')
eq('片段状态:结构片段不画', ev.segmentState({ kind: 'structural', state: 'none' }), null)
eq('片段状态:文字不画', ev.segmentState({ kind: 'text', state: 'neutral' }), null)
eq('片段状态:probabilistic 裁判前按未裁判画', ev.segmentState({ kind: 'text', state: 'probabilistic' }), 'unjudged')
eq('报告节点叫「报告撰写」', t.nodeTypeLabel('report'), '报告撰写')
eq('计数的说法:无证据 = 总数 − 有出处,算得平', t.evidenceTally(7, 12), '7/12 数字有出处 · 无证据 5')
eq('不是数字的无法解析的引用另起一句', t.evidenceTally(7, 12, 1), '7/12 数字有出处 · 无证据 5 · 另有 1 处引用无法解析')
eq('数字全有出处', t.evidenceTally(1234, 1234, 0), '1,234 个数字都有出处')
eq('数字全有出处、另有引用无法解析', t.evidenceTally(3, 3, 2), '3 个数字都有出处 · 另有 2 处引用无法解析')
eq('一个数字都没有、只有无法解析的引用', t.evidenceTally(0, 0, 1), '1 处引用无法解析')
eq('什么都没有时给空串', t.evidenceTally(0, 0, 0), '')
const out1 = { 周报: 'x', _evidence: { report_node: 'write', doc_artifact: 'abc', fields: ['周报'],
others: [{ report_node: 'w2', doc_artifact: 'def', fields: ['附录'] }] } }
const f = ev.evidenceFields(out1)
ok('_evidence 的字段和 others 都认', f.get('周报')?.artifact === 'abc' && f.get('附录')?.report === 'w2', JSON.stringify([...f]))
eq('缺 doc_artifact 的标注当没有', ev.evidenceFields({ _evidence: { fields: ['a'] } }).size, 0)
const review = { verdict: 'rewritten', note: '有缺口', answer: '改写后的', original: '原文', retry: false, severity: 'degraded', signals: [] }
const guarded = ev.guardReview(review, out1)
ok('成果带证据:复核只加说明,不改写', guarded.answer === null && guarded.original === null && guarded.verdict === 'annotated'
&& guarded.note === '有缺口', JSON.stringify(guarded))
ok('成果不带证据:复核照旧可以改写', ev.guardReview(review, { answer: 'x' }).answer === '改写后的')
eq('出处不唯一的说法', ev.matchedSource({ token: '0', metric: null, candidates: ['a', 'b'], ambiguous: true }).text, '出处不唯一:候选 a、b')
eq('出处唯一的照写指标', ev.matchedSource({ token: '12', metric: 'gmv' }).text, 'gmv')
const cp = ev.codePointIndex('📦 15 单')
eq('码点偏移换成 UTF-16:emoji 后面差一位', cp(2), 3)
eq('没有 emoji 时原样', ev.codePointIndex('abc')(2), 2)
const doc = window.__ui.evidenceFixture.doc
const tally = ev.docTally(doc)
const k = (x) => `${x.cited}/${x.total}/${x.none}/${x.other}/${x.hidden}/${x.structural}/${x.noSegment}`
eq('文档计数(有出处/总数/无证据/非数字引用/画不了线/其中结构/其中没对应字)', k(tally), '7/12/5/1/1/1/0')
const { stats: _s, ...bare } = doc
eq('没有 stats 时从片段和违规清单数,口径一样', k(ev.docTally(bare)), '7/12/5/1/1/1/0')
const { violations: _v, ...noList } = doc
eq('有 stats、没有违规清单:非数字引用由 stats 反推', `${ev.docTally(noList).none}/${ev.docTally(noList).other}`, '5/1')
eq('只有 stats(report.checked 的载荷):同一种算法', JSON.stringify(ev.statsTally(doc.stats)),
JSON.stringify({ total: 12, cited: 7, none: 5, other: 1 }))
eq('载荷缺字段:不给计数', ev.statsTally({ uncited_numbers: 1 }), null)
// 句末依据里写错的引用:违规不指任何片段,和列表序号里的数字分开数、分开说
const withSee = { ...doc, stats: { ...doc.stats, unresolved: 4, violations: 7 },
violations: [...doc.violations, { code: 'unresolved_ref', message: '依据 [[see:m:nope]] 无法解析', ref: 'm:nope', unit: 'u1' }] }
const t2 = ev.docTally(withSee)
eq('句末依据的引用:算进非数字引用、算进「没对应字」', k(t2), '7/12/5/2/2/1/1')
const sum = ev.tallySummary(t2, true)
ok('读屏摘要:两种画不了线的分开说在哪', sum.includes('其中 1 个数字位于列表序号') && sum.includes('1 处在正文中没有对应文本')
&& sum.includes('句末依据'), sum)
ok('读屏摘要:只有结构片段时不提「句末依据」', !ev.tallySummary(tally, true).includes('句末依据'), ev.tallySummary(tally, true))
// 封存那一行:顺序是 正文不是封存那份 → 没拿到 → 没封存 → 核对失败 → 不在范围 → 通过
const sv = (seal, opts) => { const v = ev.sealVerdict(seal, opts); return `${v.status}:${v.label}` }
eq('封存被改过(covered 必然也是 false):失败,不是「不在封存范围内」', sv({ sealed: true, ok: false, covered: false }),
'failed:已封存 · 核对不一致')
eq('没封存:灰「尚未封存」', sv({ sealed: false, ok: null, covered: false }), 'idle:尚未封存')
eq('封存完好、这件不在台账里:琥珀', sv({ sealed: true, ok: true, covered: false }), 'waiting:不在封存范围内')
eq('都过了:绿', sv({ sealed: true, ok: true, covered: true }), 'done:已封存 · 核对一致')
ok('正文不是封存的那份:再完好的封存也判失败', sv({ sealed: true, ok: true, covered: true }, { foreign: true }).startsWith('failed:'))
ok('没有证据的片段:落到报告文档上', sv({ sealed: true, ok: true, covered: true }, { docOnly: true }).includes('报告文档已封存'))
ok('链没取到、封存状态从证据图查的:只说文档封存了', sv({ sealed: true, ok: true }, { viaGraph: true }).includes('未能获取本段的证据链'))
eq('逐项复核:没封存时不报「不在封存范围内」', ev.integrityFailures({ sealed: false, hash_ok: true }, { sealed: false }).join(','), '')
eq('逐项复核:封存被改过时也不报', ev.integrityFailures({ sealed: false }, { sealed: true, ok: false }).join(','), '')
eq('逐项复核:封存完好时照报', ev.integrityFailures({ sealed: false, hash_ok: false }, { sealed: true, ok: true }).join(','), 'hash,sealed')
// 证据接口答的是不是正文这份
const seg = { id: 's8', text: '8.7%' }
const mine = { artifact: 'aaa', node: 'write', seg }
eq('同一份:不算外来', ev.docForeign({ report: { doc_artifact: 'aaa', node_id: 'write' }, segment: { id: 's8', text: '8.7%' } }, mine), null)
ok('文档工件 id 不同:外来', !!ev.docForeign({ report: { doc_artifact: 'bbb' }, segment: { id: 's8', text: '8.7%' } }, mine))
eq('这个位置的字不同:外来,并记下封存那份写的是什么',
ev.docForeign({ report: { doc_artifact: 'aaa' }, segment: { id: 's8', text: '9.9%' } }, mine)?.sealedText, '9.9%')
eq('接口没报文档 id(老后端):只比字', ev.docForeign({ segment: { text: '8.7%' } }, mine), null)
eq('证据图兜底:封存的报告里有正文这份', ev.graphDoc({ reports: [{ doc_artifact: 'aaa' }] }, 'aaa'), 'ok')
eq('证据图兜底:没有正文这份', ev.graphDoc({ reports: [{ doc_artifact: 'bbb' }] }, 'aaa'), 'foreign')
eq('证据图兜底:有,但哈希对不上', ev.graphDoc({ reports: [{ doc_artifact: 'aaa', hash_ok: false }] }, 'aaa'), 'tampered')
return out
})) check(name, ok, detail)
})
await section('lib/evidence:结论句裁判(四期)——判定的徽标、计数、答复形状、改写一次', async () => {
for (const [name, ok, detail] of await page.evaluate(() => {
const { evidence: ev, terms: t } = window.__ui.lib
const fj = window.__ui.evidenceJudge
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])
const S = ev.EVIDENCE_STATE
ok('概率性四种都挂在句末徽标上(line=badge),字形 ◆ ◇ ! ?', ['supported', 'partial', 'unsupported', 'unjudged']
.map((c) => `${S[c].line}/${S[c].glyph}`).join(' ') === 'badge/◆ badge/◇ badge/! badge/?',
['supported', 'partial', 'unsupported', 'unjudged'].map((c) => `${S[c].line}/${S[c].glyph}`).join(' '))
// 颜色令牌照方案 6.1:支持用强调色(--st-running 就是 --accent)、部分用 --st-waiting、不支持用 --st-failed、
// 未裁判用暗字色(--st-cancelled 就是 --text-dim)。四种都不是确定性的绿
eq('四种的颜色令牌', ['supported', 'partial', 'unsupported', 'unjudged'].map((c) => S[c].color).join(' '),
'var(--st-running) var(--st-waiting) var(--st-failed) var(--st-cancelled)')
ok('概率性四种都不用确定性的绿(--st-done)', ['supported', 'partial', 'unsupported', 'unjudged']
.every((c) => !S[c].color.includes('--st-done') && !S[c].soft.includes('--st-done')))
ok('证据不支持、部分支持醒目(进 n / N),有依据、未裁判安静', S.unsupported.alert && S.partial.alert && !S.supported.alert && !S.unjudged.alert)
eq('未裁判的叫法不说「有引用」(没挂依据的结论句也会没判)', S.unjudged.label, '未裁判')
eq('判定 → 徽标:不是结论句不挂', [ev.verdictState({ status: 'supported' }), ev.verdictState({ status: 'not_a_claim' }),
ev.verdictState({ status: 'unjudged' }), ev.verdictState({ status: 'maybe' }), ev.verdictState(undefined)].join(','),
'supported,,unjudged,,')
eq('封存后追加的判定盖过文档里的「未裁判 · 按需」', ev.unitVerdict({ id: 'u2', verdict: { status: 'unjudged', reason: 'on_demand' } },
{ u2: { status: 'unsupported', post_seal: true } }).status, 'unsupported')
eq('没有追加的用文档里的', ev.unitVerdict({ id: 'u2', verdict: { status: 'partial' } }, { u9: { status: 'supported' } }).status, 'partial')
eq('到上限没判的认得出是哪个上限', ev.limitOf({ status: 'unjudged', reason: 'daily_max_usd' }), 'daily_max_usd')
eq('按需、出错不是上限', `${ev.limitOf({ status: 'unjudged', reason: 'on_demand' })}|${ev.limitOf({ status: 'unjudged', reason: 'error' })}`, 'null|null')
ok('判过的四种(不含未裁判)', ev.isJudged({ status: 'not_a_claim' }) && ev.isJudged({ status: 'partial' }) && !ev.isJudged({ status: 'unjudged' }))
const k = (c) => (c ? `${c.total}:${c.supported}/${c.partial}/${c.unsupported}/${c.unjudged}/${c.uncited}` : 'null')
eq('正式运行(节点里判好的):不是结论句不算,4 句', k(ev.claimCounts(fj.formal.doc)), '4:1/1/1/1/0')
eq('探索运行按需:5 句都未裁判', k(ev.claimCounts(fj.explore.doc)), '5:0/0/0/5/0')
eq('没开裁判、也没按需判过:不数(横幅照旧)', k(ev.claimCounts(fj.plain.doc)), 'null')
// 开了裁判的文档里预筛放掉的结论句(没有判定、列在 judge.screened 里)不算进结论句,也不算「无证据」
eq('开了裁判:预筛放掉的结论句不数', k(ev.claimCounts({ judge: { mode: 'inline', screened: ['u9'] }, blocks: [{ type: 'paragraph',
units: [{ id: 'u9', kind: 'claim', cites: [], segments: [] }, { id: 'u1', kind: 'claim', cites: ['Q1'], segments: [],
verdict: { status: 'supported' } }] }] })), '1:1/0/0/0/0')
eq('没开裁判、按需判过一句:挂了依据的算未裁判,没挂的算无证据', k(ev.claimCounts(fj.plain.doc,
{ [fj.plain.units.bad]: { status: 'unsupported', post_seal: true } })), '5:0/0/1/3/1')
eq('docTally 带上结论句计数', k(ev.docTally(fj.formal.doc).claims), '4:1/1/1/1/0')
// 问数据页、画布右栏的横幅走的是这条路:EvidenceField 把判定叠进文档(withVerdicts)交出去,Output 再
// docTally——这时没有单独的 overlay,判定写在 unit.verdict 上,也得认得出「按需判过」
eq('没开裁判、按需判过一句:叠好判定的文档交给 docTally 照样数(横幅的路径)',
k(ev.docTally(ev.withVerdicts(fj.plain.doc, { [fj.plain.units.bad]: { status: 'unsupported', post_seal: true } })).claims),
'5:0/0/1/3/1')
eq('叠上判定给横幅数:只换判过的那几句', k(ev.claimCounts(ev.withVerdicts(fj.explore.doc,
{ [fj.explore.units.bad]: { status: 'unsupported', post_seal: true } }))), '5:0/0/1/4/0')
ok('叠上判定不改原文档', ev.withVerdicts(fj.explore.doc, { [fj.explore.units.bad]: { status: 'unsupported' } }) !== fj.explore.doc
&& fj.explore.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fj.explore.units.bad).verdict.status === 'unjudged')
eq('计数的说法:和数字那一段同一种', t.claimTally({ total: 4, supported: 1, partial: 1, unsupported: 1, unjudged: 1, uncited: 0 }),
'结论 4 句(有依据 1 · 部分有依据 1 · 证据不支持 1 · 未裁判 1)')
eq('……方案的例子', t.claimTally({ total: 4, supported: 3, partial: 0, unsupported: 0, unjudged: 0, uncited: 1 }), '结论 4 句(有依据 3 · 无证据 1)')
eq('……没有结论句给空串', t.claimTally({ total: 0, supported: 0, partial: 0, unsupported: 0, unjudged: 0, uncited: 0 }), '')
const unit = fj.formal.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fj.formal.units.bad)
eq('句末徽标的 aria-label:句子、判定写全', ev.claimLabel(unit, unit.verdict), '结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不支持')
ok('……封存后追加的另说', ev.claimLabel(unit, { ...unit.verdict, post_seal: true }).endsWith(',封存后追加'))
ok('……到上限没判的说「已达上限」', ev.claimLabel(unit, { status: 'unjudged', reason: 'max_cost_usd' }).endsWith('未裁判:已达上限'))
// 按需裁判接口的答复:evidence.judged 的载荷({verdicts: {u: …}}),也认列表;缺 post_seal 的记 true,缺 judge 的补模型
const a = ev.judgedVerdicts({ verdicts: { u4: { status: 'supported', rationale: '对' } }, model: 'jm' })
ok('答复 {verdicts: {u: …}}:补上模型、记封存后追加', a.u4?.status === 'supported' && a.u4.judge === 'jm' && a.u4.post_seal === true,
JSON.stringify(a))
const b = ev.judgedVerdicts({ verdicts: [{ unit: 'u2', verdict: 'partial', judge: 'x', post_seal: false }, { unit: 'u3' }] })
ok('答复是列表、判定写在 verdict 里:照认;没有判定的丢掉;接口给了 post_seal 就照它', b.u2?.status === 'partial' && b.u2.post_seal === false
&& !('u3' in b), JSON.stringify(b))
eq('认不出的答复给空表', Object.keys(ev.judgedVerdicts('oops')).length + Object.keys(ev.judgedVerdicts({ verdicts: 3 })).length, 0)
const g = ev.graphVerdicts({ reports: [{ node_id: 'w1', post_seal_verdicts: { u1: { status: 'supported' } } },
{ node_id: 'w2', post_seal_verdicts: { u1: { status: 'unsupported' } } }] }, 'w2')
ok('证据图里按报告取封存后追加的判定(reports[].post_seal_verdicts)', g.u1?.status === 'unsupported' && g.u1.post_seal === true, JSON.stringify(g))
const applied = { judge: { rewrite: { applied: true, units: ['u1', null], sentences: ['原句甲', '原句乙'], changed: ['u5'], reason: null } } }
ok('改写一次采用了:changed 里的句子给出交回改写的原句(units 为 null 的那几句)',
JSON.stringify(ev.rewriteOf(applied, 'u5')) === JSON.stringify({ kind: 'changed', sentences: ['原句乙'] }), JSON.stringify(ev.rewriteOf(applied, 'u5')))
eq('……没改的句子不标', ev.rewriteOf(applied, 'u1'), null)
const rejected = { judge: { rewrite: { applied: false, units: ['u2'], sentences: ['原句'], changed: [], reason: '改写稿新增了 1 处原稿没有的问题(无出处数字),未予采用' } } }
eq('改写稿没采用:交回过的句子写明原因', ev.rewriteOf(rejected, 'u2')?.kind, 'rejected')
eq('……没交回的不标', ev.rewriteOf(rejected, 'u3'), null)
// 设置页:设成不限时写明还受什么约束;别的也不限时照实少说
const all = { claims: true, cost: true, timeout: true, click: true, daily: true }
eq('不限的说法:每日', t.judgeUnlimitedText('daily', all), '不设上限,每日费用仅受每份报告的金额上限和每次点击的金额上限约束')
eq('……每份报告的金额', t.judgeUnlimitedText('cost', all), '不设上限,费用仅受句数上限、时长上限、每日上限约束')
ok('……每日、每份、每次都不限:说到底只受调用次数约束', t.judgeUnlimitedText('daily', { ...all, cost: false, click: false }).includes('调用次数'))
ok('……每日不限时每份报告的金额只剩句数、时长', !t.judgeUnlimitedText('cost', { ...all, daily: false }).includes('每日'))
return out
})) check(name, ok, detail)
})
await section('lib/evidence:裁判拆档(JR)——证据相矛盾、证据不足、计数、跳转轮次、模型提醒、审计行、讲方法的句子', async () => {
for (const [name, ok, detail] of await page.evaluate(() => {
const { evidence: ev, terms: t } = window.__ui.lib
const fv = window.__ui.evidenceVerdict
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])
const S = ev.EVIDENCE_STATE
eq('证据相矛盾、证据不足:句末徽标、字形 ! ○', ['contradicted', 'insufficient'].map((c) => `${S[c].line}/${S[c].glyph}`).join(' '),
'badge/! badge/○')
eq('……颜色令牌:证据相矛盾同原来的证据不支持(--st-failed),证据不足中性(--st-cancelled)',
['contradicted', 'insufficient', 'unsupported'].map((c) => S[c].color).join(' '), 'var(--st-failed) var(--st-cancelled) var(--st-failed)')
ok('……证据不足不用红、不用提醒色,字形不和未裁判的 ? 撞', !/--st-(failed|waiting|done)/.test(S.insufficient.color + S.insufficient.soft)
&& S.insufficient.glyph !== S.unjudged.glyph, JSON.stringify(S.insufficient))
eq('叫法:证据相矛盾、证据不足;旧取值照旧叫证据不支持', [S.contradicted.label, S.insufficient.label, S.unsupported.label].join(' / '),
'模型判断:证据相矛盾 / 模型判断:证据不足 / 模型判断:证据不支持')
eq('判定 → 徽标:新取值各是各的', [ev.verdictState({ status: 'contradicted' }), ev.verdictState({ status: 'insufficient' }),
ev.verdictState({ status: 'unsupported' })].join(','), 'contradicted,insufficient,unsupported')
ok('判过的算上新取值(不再给「请模型判断」)', ev.isJudged({ status: 'contradicted' }) && ev.isJudged({ status: 'insufficient' }))
eq('n / N 的轮次:证据相矛盾、旧的证据不支持、部分有依据、无证据在第 1 轮,证据不足在第 2 轮,有依据、未裁判不进',
['contradicted', 'unsupported', 'partial', 'none', 'suspect', 'insufficient', 'supported', 'unjudged'].map((c) => ev.alertTier(c)).join(''),
'11111200')
ok('整句铺浅底的是证据相矛盾和旧的证据不支持', ev.conflicting('contradicted') && ev.conflicting('unsupported')
&& !ev.conflicting('insufficient') && !ev.conflicting('partial'))
eq('证据不足缺什么:只认 insufficient 的 missing', [ev.verdictMissing({ status: 'insufficient', missing: ' SQL ' }),
ev.verdictMissing({ status: 'contradicted', missing: 'SQL' }), ev.verdictMissing({ status: 'insufficient' })].join('|'), 'SQL||')
const k = (c) => (c ? `${c.total}:${c.supported}/${c.partial}/${c.contradicted}/${c.unsupported}/${c.insufficient}/${c.unjudged}/${c.uncited}` : 'null')
eq('文档计数:证据相矛盾 2、证据不足 1,不是结论句不算', k(ev.claimCounts(fv.formal.doc)), '6:1/1/2/0/1/1/0')
eq('……叠上封存后追加的判定(旧取值 unsupported 照样计入)', k(ev.claimCounts(fv.explore.doc,
ev.graphVerdicts(fv.explore.graph, 'write'))), '7:0/0/0/1/1/5/0')
eq('stats / counts → 计数(report.checked、报告节点的章):旧数据的 unsupported 照样计入,不是结论句不算',
k(ev.claimCountsOf({ supported: 1, partial: 1, contradicted: 2, insufficient: 1, unsupported: 1, not_a_claim: 3, unjudged: 1,
numbers: 9 })), '7:1/1/2/1/1/1/0')
eq('……缺键、不是数的当 0', k(ev.claimCountsOf({ supported: 2, unsupported: 'x' })), '2:2/0/0/0/0/0/0')
eq('计数的说法:证据相矛盾、证据不支持、证据不足各写各的', t.claimTally(ev.claimCountsOf({ supported: 1, contradicted: 2, unsupported: 1,
insufficient: 1, unjudged: 1 })), '结论 6 句(有依据 1 · 证据相矛盾 2 · 证据不支持 1 · 证据不足 1 · 未裁判 1)')
eq('……旧的计数对象(没有新键)照旧能说', t.claimTally({ total: 2, supported: 1, partial: 0, unsupported: 1, unjudged: 0, uncited: 0 }),
'结论 2 句(有依据 1 · 证据不支持 1)')
eq('有问题的句数(横幅、证据条的「定位下一处」):证据相矛盾、旧的证据不支持、部分有依据、证据不足', ev.claimProblems(ev.claimCountsOf(
{ supported: 5, contradicted: 1, unsupported: 1, partial: 1, insufficient: 1, unjudged: 3 })), 4)
const unit = fv.formal.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fv.formal.units.insuff)
eq('读屏名:证据不足接着说缺什么', ev.claimLabel(unit, unit.verdict),
`结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不足,缺少:${fv.missing}`)
const sealed = fv.formal.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fv.formal.units.contra).verdict
const f1 = ev.judgeFlags(sealed, { doc: fv.formal.doc.judge })
ok('模型提醒:节点里当场判的看报告文档的 judge(same_model 真、priced 假)', f1.sameModel && f1.unpriced && f1.writer === fv.model, JSON.stringify(f1))
const late = { status: 'insufficient', post_seal: true, judge: 'm' }
const f2 = ev.judgeFlags(late, { doc: { same_model: true, priced: false }, meta: { same_model: false, priced: true } })
ok('……封存后追加的不看报告文档,看证据图里最近一次按需裁判(judge_meta)', !f2.sameModel && !f2.unpriced, JSON.stringify(f2))
const f3 = ev.judgeFlags(late, { reply: { same_model: true, priced: false }, meta: { same_model: false, priced: true } })
ok('……这一次的答复盖过证据图', f3.sameModel && f3.unpriced, JSON.stringify(f3))
const f4 = ev.judgeFlags({ ...late, same_model: false }, { reply: { same_model: true, priced: false } })
ok('……判定自己带着的盖过答复', !f4.sameModel && f4.unpriced, JSON.stringify(f4))
const f5 = ev.judgeFlags({ status: 'unjudged', reason: 'max_claims' }, { doc: { same_model: true, priced: false } })
ok('……没判的句子不提醒;priced 为 null(不知道)也不提醒', !f5.sameModel && !f5.unpriced
&& !ev.judgeFlags(sealed, { doc: { priced: null } }).unpriced, JSON.stringify(f5))
const rows = ev.auditFromApi(fv.formal.audit)
const claim = (issue) => rows.find((r) => r.kind === 'claim' && fv.formal.audit.groups.flatMap((g) => g.rows)
.some((x) => x.issue === issue && x.unit === r.unit))
const c1 = claim('contradicted_claim')
const c2 = claim('insufficient_claim')
ok('审计行:issue 为 contradicted_claim 的结论句认成证据相矛盾,insufficient_claim 认成证据不足,带着是哪一句',
c1?.state === 'contradicted' && c2?.state === 'insufficient' && c2.unit === fv.formal.units.insuff && c1.group === 'none',
JSON.stringify([c1, c2].map((r) => r && { state: r.state, unit: r.unit, group: r.group })))
ok('……旧取值 unsupported_claim 认成证据不支持;没挂依据的结论句照旧是无证据',
ev.auditFromApi({ groups: [{ key: 'none', rows: [{ kind: 'claim', issue: 'unsupported_claim', unit: 'u2', text: 'x', group: 'none' },
{ kind: 'claim', issue: 'uncited_claim', unit: 'u7', text: 'y', group: 'none', state: 'none' }] }] }).map((r) => r.state).join(',')
=== 'unsupported,none')
const doc = fv.formal.doc
const units = Object.fromEntries(doc.blocks.flatMap((b) => b.units).map((u) => [u.id, u]))
const b = ev.claimBasis(units[fv.formal.units.method], doc)
ok('讲方法的句子(只挂了表名、字段名、整份查询):列表、字段、要列 SQL 的查询(直接挂的在前,再补表名出现过的)',
!!b && b.tables.map((x) => `${x.alias}=${x.name}@${x.seg}`).join() === `t:orders=orders@${fv.formal.segs.orders}`
&& b.columns.map((x) => x.name).join() === 'orders.status,orders.created_at' && b.queries.join() === 'Q1,Q2' && b.more === 0, JSON.stringify(b))
eq('……只挂了整份查询的句子也算', ev.claimBasis(units[fv.formal.units.insuff], doc)?.queries.join(), 'Q1')
eq('……挂了数字(单元格)的句子不算', ev.claimBasis(units[fv.formal.units.ok], doc), null)
eq('……挂了运行输入的句子不算', ev.claimBasis(units[fv.formal.units.not], doc), null)
eq('……一个依据都没挂的不算', ev.claimBasis(units[fv.formal.units.limit], doc), null)
const many = { catalog: { 't:a': { kind: 'table', name: 'a', queries: ['Q1', 'Q2', 'Q3', 'Q4', 'Q5'] },
...Object.fromEntries(['Q1', 'Q2', 'Q3', 'Q4', 'Q5'].map((q) => [q, { kind: 'query' }])) }, blocks: [] }
const bm = ev.claimBasis({ cites: ['t:a'], segments: [] }, many)
ok('……查询多于 3 次:只列前 3 次的 SQL,其余写个数', bm?.queries.join() === 'Q1,Q2,Q3' && bm.more === 2, JSON.stringify(bm))
eq('查询快照里的 SQL:快照本身、工具调用快照的 args / result 都认', [ev.sqlOf({ sql: 'A' }), ev.sqlOf({ tool: 'x', args: { sql: 'B' }, result: {} }),
ev.sqlOf({ result: { sql: 'C' } }), ev.sqlOf({ rows: [] }), ev.sqlOf(null)].join('|'), 'A|B|C||')
return out
})) check(name, ok, detail)
})
await section('lib/actor:本机署名', async () => {
for (const [name, ok, detail] of await page.evaluate(() => {
const a = window.__ui.lib.actor
const out = []
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
const saved = localStorage.getItem(a.ACTOR_KEY)
let heard = 0
const stop = a.subscribeActor(() => { heard++ })
try {
localStorage.removeItem(a.ACTOR_KEY)
ok('没署名是 null', a.localActor() === null, JSON.stringify(a.localActor()))
localStorage.setItem(a.ACTOR_KEY, ' ')
ok('只有空白也算没署名', a.localActor() === null, JSON.stringify(a.localActor()))
a.setLocalActor(' 张工 ')
ok('写入时去掉首尾空白,读回来一致', a.localActor() === '张工' && localStorage.getItem(a.ACTOR_KEY) === '张工')
ok('写入会通知同一标签页的订阅者', heard === 1, `通知了 ${heard} 次`)
a.setLocalActor('')
ok('写空串就是清掉', a.localActor() === null && localStorage.getItem(a.ACTOR_KEY) === null)
} finally {
stop()
if (saved == null) localStorage.removeItem(a.ACTOR_KEY); else localStorage.setItem(a.ACTOR_KEY, saved)
}
return out
})) check(name, ok, detail)
if (process.env.UI_KIT_ONLY === 'lib') {
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ lib 全部通过(UI_KIT_ONLY=lib,只跑了这一段)')
process.exit(failed ? 1 : 0)
}
})
const fireKey = (loc, init) => loc.evaluate((el, init) =>
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })), init)
// 点遮罩:按下和松开都在遮罩上才算。两个方向的拖动,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 onBackdrop = { x: 10, y: 10 }
await section('Modal:焦点、Esc、dirty、遮罩', async () => {
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()
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())
const panelBox = await dlg.boundingBox()
const inPanel = { x: panelBox.x + panelBox.width / 2, y: panelBox.y + 20 }
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())
})
await section('confirmDialog / promptDialog', async () => {
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()) === '"月度经营分析"')
// 空输入:默认(allowEmpty 关)报「不能为空」、确认禁用、回车不提交;打开 allowEmpty(版本页「启用」收可选理由)
// 时空输入和只有空白都能确认,返回空字符串,非空时 validate 照旧生效
const dialogText = (t) => page.locator('[role="dialog"]').getByText(t, { exact: true }).count()
await page.click('#open-prompt')
await page.waitForSelector('[role="dialog"]')
await page.waitForTimeout(80)
const createBtn = page.getByRole('button', { name: '新建', exact: true })
await input.fill(' ')
await page.waitForTimeout(50)
check('默认不允许空:只有空白时报「不能为空」并禁用确认', (await dialogText('不能为空')) === 1 && await createBtn.isDisabled())
await input.fill('')
await input.press('Enter')
await page.waitForTimeout(100)
check('……清空后回车不提交,确认仍禁用', (await dialogOpen()) === 1 && await createBtn.isDisabled())
await page.getByRole('button', { name: '取消' }).click()
await page.waitForTimeout(100)
check('……取消返回 null', (await result()) === 'null')
const openOptional = () => page.evaluate(() => {
window.__optional = undefined
void window.__ui.promptDialog({
title: '启用这个版本?', label: '理由(可选)', confirmLabel: '启用', allowEmpty: true,
validate: (v) => (v.length > 6 ? '理由太长' : null),
}).then((v) => { window.__optional = v })
})
const optional = () => page.evaluate(() => window.__optional)
const enableBtn = page.getByRole('button', { name: '启用', exact: true })
await openOptional()
await page.waitForSelector('[role="dialog"]')
await page.waitForTimeout(80)
check('allowEmpty:空输入时确认可用,不报「不能为空」', !(await enableBtn.isDisabled()) && (await dialogText('不能为空')) === 0)
await input.fill(' ')
await page.waitForTimeout(50)
check('……只有空白也可用,不报「不能为空」', !(await enableBtn.isDisabled()) && (await dialogText('不能为空')) === 0)
await input.fill('这段理由写得太长了')
await page.waitForTimeout(50)
check('……填了内容时 validate 照旧生效(报原因、禁用)', (await dialogText('理由太长')) === 1 && await enableBtn.isDisabled())
await input.fill(' ')
await input.press('Enter')
await page.waitForTimeout(100)
check('……空着回车确认,返回空字符串(不是 null)', (await dialogOpen()) === 0 && (await optional()) === '', JSON.stringify(await optional()))
await openOptional()
await page.waitForSelector('[role="dialog"]')
await page.waitForTimeout(80)
await input.fill(' 传错了 ')
await enableBtn.click()
await page.waitForTimeout(100)
check('……填了就返回去掉首尾空白的文字', (await optional()) === '传错了', JSON.stringify(await optional()))
await openOptional()
await page.waitForSelector('[role="dialog"]')
await page.waitForTimeout(80)
await page.getByRole('button', { name: '取消' }).click()
await page.waitForTimeout(100)
check('……取消仍返回 null', (await optional()) === null, JSON.stringify(await optional()))
// 嵌套:弹窗里再弹确认,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)
})
await section('toast', async () => {
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())
})
await section('离线:横幅、空态', async () => {
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(/\d+ 秒后自动重试/).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(/\d+ 秒后自动重试/).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'))) === '')
})
await section('Tabs · Field · IconButton', async () => {
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)
})
await section('管理页共用件:页头、连通胶囊、单选组、删除后撤销', async () => {
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())
})
await section('提示条 Notice、回执块(期 3 共用件:导入向导、修复面板、版本页都用这一份)', async () => {
await page.locator('#notice-demo').scrollIntoViewIfNeeded()
const notices = page.locator('#notice-demo [data-notice]')
check('Notice:三种语气各一条', (await notices.count()) === 3)
const tones = await notices.evaluateAll((els) => els.map((el) => ({
tone: el.getAttribute('data-notice'), demo: el.getAttribute('data-demo-notice'), role: el.getAttribute('role'),
icons: el.querySelectorAll(':scope > svg[aria-hidden="true"]').length,
border: getComputedStyle(el).borderTopColor, cls: el.className,
})))
const sameNode = tones.every((t) => t.tone === t.demo)
check('attr 原样挂在根节点上(与 data-notice 同一个元素)', sameNode, sameNode ? '' : JSON.stringify(tones.map((t) => [t.tone, t.demo])))
check('info 是 status(礼貌播报),warn、err 是 alert', tones.map((t) => `${t.tone}:${t.role}`).join(',') === 'info:status,warn:alert,err:alert')
check('每条一个图标,不进读屏', tones.every((t) => t.icons === 1))
const distinct = new Set(tones.map((t) => t.border)).size === 3
check('三种语气的边框颜色互不相同', distinct, distinct ? '' : tones.map((t) => t.border).join(' | '))
const merged = /\brounded-lg\b/.test(tones[2].cls) && /\bmt-1\b/.test(tones[2].cls)
check('className 追加在默认样式之后', merged, merged ? '' : tones[2].cls)
check('children 原样渲染(第二行的细节在提示条里)', (await page.locator('#notice-demo [data-notice="err"] #notice-detail').count()) === 1)
const sum = page.locator('#receipt-demo [data-receipt-summary]')
await sum.scrollIntoViewIfNeeded()
check('回执摘要:非空单元格总数和「全部有去处」', /非空单元格 771 个,全部有去处/.test(await sum.locator('[data-summary-ledger]').innerText()))
check('回执摘要:表与行数', (await sum.locator('[data-summary-table="日客流"]').getAttribute('data-rows')) === '30')
check('回执摘要:占位符、规范写法', (await sum.locator('[data-summary-placeholders]').innerText()).includes('「·」30 格')
&& (await sum.locator('[data-summary-canonicalized]').getAttribute('data-summary-canonicalized')) === '17')
check('排除的行:逐项列出', (await sum.locator('[data-excluded-reason]').count()) === 3
&& (await sum.locator('[data-summary-excluded]').getAttribute('data-summary-excluded')) === '3')
const text = await sum.innerText()
const reasons = !/ignored_rows|total_not_kept|mystery_reason/.test(text)
&& text.includes('按配方忽略的行') && text.includes('只核对、不另存的合计行') && text.includes('其他原因')
check('排除的行:原因写中文,不露枚举值;认不出的写「其他原因」', reasons, reasons ? '' : text.replace(/\s+/g, ' ').slice(0, 200))
check('排除的行:锚点原文、行号区间、格数', text.includes('「补录(人次)」') && text.includes('第 32 行')
&& text.includes('第 28–30 行') && text.includes('93 格'))
check('排除的行的根节点同时带 data-rows-excluded 和规格里的旧名 data-rows-dropped',
(await sum.locator('[data-rows-excluded][data-rows-dropped]').count()) === 1)
await sum.locator('[data-excluded-rows="32-32"]').click()
check('点行号定位到那一行(工作表!A行号)', (await page.locator('#receipt-focus').innerText()) === '客流汇总!A32')
const unrecorded = page.locator('#receipt-unrecorded [data-summary-excluded]')
check('回执里没有 rows_excluded(期 3 之前的导入):写「未记录」,不当作「没有」',
(await unrecorded.getAttribute('data-summary-excluded')) === 'unrecorded'
&& (await unrecorded.innerText()).includes('这次导入未记录排除的行'))
})
await section('空态:catalog 的表没取回来时不说「还没有」', async () => {
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)
})
let cat
await section('catalog:一张表卡住不拖累别的表', async () => {
// /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(() => {})
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, settled ? `${hangMs}ms` : `${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)
})
await section('catalog:数据源目录与单表重拉', async () => {
// 数据源的查询工具不在 /api/tools 里:检查器挑工具、问数据的范围、助手的数据源提示都从
// catalog 的 datasources 取一份。数据页改了它就 reload('datasources'),别处立刻跟上
const isSources = (u) => new URL(u).pathname === '/api/datasources'
const fakeSource = (n) => ({ id: `ui-kit-${n}`, name: `demo_${n}`, kind: 'sqlite', host: null, port: null, database: '/tmp/demo.db',
username: null, options: {}, readonly: true, description: '', enabled: true, tools: [`db_query__demo_${n}`, `db_schema__demo_${n}`] })
let sourceRows = [fakeSource(1)]
let sourceHits = 0
await page.route(isSources, (r) => { sourceHits++; return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(sourceRows) }) })
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
cat = await page.evaluate(() => {
const s = window.__ui.useCatalog.getState()
return { n: s.datasources.length, at: s.loadedAt.datasources, check: s.checks.find((c) => c.key === 'datasources')?.label }
})
check('refresh 顺带取回数据源,启动清单里有「数据源」一项', cat.n === 1 && cat.at > 0 && cat.check === '数据源', JSON.stringify(cat))
sourceRows = [fakeSource(1), fakeSource(2)]
const hitsBefore = sourceHits
await page.evaluate(() => window.__ui.useCatalog.getState().reload('datasources'))
cat = await page.evaluate(() => {
const s = window.__ui.useCatalog.getState()
return { names: s.datasources.map((d) => d.name).join(','), checks: s.checks.length }
})
check('reload 只重拉这一张表', cat.names === 'demo_1,demo_2' && sourceHits === hitsBefore + 1, `${cat.names} · 请求 ${sourceHits - hitsBefore} 次`)
await page.unroute(isSources)
await page.route(isSources, (r) => r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ detail: '数据库锁住了' }) }))
await page.evaluate(() => window.__ui.useCatalog.getState().reload('datasources'))
cat = await page.evaluate(() => {
const s = window.__ui.useCatalog.getState()
return { n: s.datasources.length, backend: s.backend }
})
check('reload 失败时列表保持原样,也不判后端断开', cat.n === 2 && cat.backend === 'ok', JSON.stringify(cat))
// 交叠:先发的慢请求后到,不能盖掉后发、先到的新结果
await page.unroute(isSources)
let slowFirst = true
await page.route(isSources, async (r) => {
const slow = slowFirst
slowFirst = false
if (slow) await new Promise((res) => setTimeout(res, 600))
return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(slow ? [fakeSource(9)] : [fakeSource(3)]) })
})
await page.evaluate(() => {
const s = window.__ui.useCatalog.getState()
window.__slowReload = s.reload('datasources')
return new Promise((r) => setTimeout(r, 50)).then(() => s.reload('datasources'))
})
await page.evaluate(() => window.__slowReload)
cat = await page.evaluate(() => window.__ui.useCatalog.getState().datasources.map((d) => d.name).join(','))
check('两次 reload 交叠:后发的结果不被先发、后到的盖掉', cat === 'demo_3', cat)
await page.unroute(isSources)
// useDatasources(检查器、问数据页的输入框挂它):过期才拉,同一时刻几处挂载只发一个请求;
// 启动的 refresh 落定前不拉,落定时这张表没取回来就补拉一次——以前只看 maxAge,要等重新挂载
let hookHits = 0
await page.route(isSources, (r) => { hookHits++; return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([fakeSource(1)]) }) })
const mountSources = async (n) => { await page.evaluate((n) => window.__ui.mountDatasources(n), n); await page.waitForTimeout(400) }
await page.evaluate(() => {
const c = window.__ui.useCatalog
c.setState({ loaded: true, loadedAt: { ...c.getState().loadedAt, datasources: Date.now() - 60_000 } })
})
hookHits = 0
await mountSources(2)
check('useDatasources:目录过期时两处同时挂载,只发一个请求', hookHits === 1, `${hookHits} 次`)
await mountSources(0)
hookHits = 0
await mountSources(2)
check('……刚取过、没过期:挂载不拉', hookHits === 0, `${hookHits} 次`)
await mountSources(0)
await page.evaluate(() => {
const c = window.__ui.useCatalog
const { datasources: _, ...rest } = c.getState().loadedAt
c.setState({ loaded: false, loadedAt: rest })
})
hookHits = 0
await mountSources(2)
const hitsBeforeLoaded = hookHits
await page.evaluate(() => window.__ui.useCatalog.setState({ loaded: true }))
await page.waitForTimeout(400)
check('……启动的 refresh 落定前不拉;落定时这张表没取回来,已挂着的补拉一次',
hitsBeforeLoaded === 0 && hookHits === 1, `落定前 ${hitsBeforeLoaded} 次,落定后共 ${hookHits} 次`)
await mountSources(0)
await page.unroute(isSources)
await page.evaluate(() => window.__ui.useCatalog.getState().reload('datasources'))
})
await section('后端的机读码跟着 ApiError 走', async () => {
// {detail, code}:detail 是给人看的话、随时会改写,按种类分支要认 code(3C REQ-20)。伪造响应,不碰后端
const isGen = (u) => new URL(u).pathname === '/api/copilot/generate'
await page.route(isGen, (r) => r.fulfill({ status: 400, contentType: 'application/json',
body: JSON.stringify({ detail: '限定的数据源都已不可用,可能已被删除或停用。请取消限定后重试,或到「数据」页确认数据源存在且已启用', code: 'datasource_scope_empty' }) }))
const coded = await page.evaluate(async () => {
try { await window.__ui.api.copilot.generate({ instruction: '探针', datasource_ids: ['__gone__'] }); return null }
catch (e) { return { isApi: e instanceof window.__ui.ApiError, status: e.status, code: e.code ?? null, message: e.message } }
})
check('ApiError 带上后端的 code,message 仍是那句话', coded?.isApi && coded.status === 400 && coded.code === 'datasource_scope_empty'
&& coded.message === '限定的数据源都已不可用,可能已被删除或停用。请取消限定后重试,或到「数据」页确认数据源存在且已启用', JSON.stringify(coded))
await page.unroute(isGen)
})
await section('FastAPI 422:校验错误说中文,英文原文收进详情', async () => {
// pydantic 的 msg 是英文、loc 是内部键名,原样拼出来就是「name:Field required」(验收 NEW)。
// 样例照沙箱后端真实回的 422 摘的,伪造响应,不碰后端
const detailOf = {
'/api/providers': [{ type: 'missing', loc: ['body', 'name'], msg: 'Field required', input: {} }],
'/api/datasources': [
{ type: 'string_pattern_mismatch', loc: ['body', 'name'], msg: "String should match pattern '^[a-z][a-z0-9_]{0,40}$'", input: 'X', ctx: { pattern: '^[a-z][a-z0-9_]{0,40}$' } },
{ type: 'int_parsing', loc: ['body', 'port'], msg: 'Input should be a valid integer, unable to parse string as an integer', input: 'abc' },
],
'/api/memory': [{ type: 'less_than_equal', loc: ['body', 'importance'], msg: 'Input should be less than or equal to 1', input: 3, ctx: { le: 1.0 } }],
'/api/conversations': [{ type: 'literal_error', loc: ['body', 'kind'], msg: "Input should be 'chat' or 'canvas'", input: 'x', ctx: { expected: "'chat' or 'canvas'" } }],
'/api/workflows': [{ type: 'some_future_type', loc: ['body', 'graph', 'nodes', 0, 'shape'], msg: 'Something went wrong in English', input: 1 }],
'/api/copilot/generate-stream': [{ type: 'string_too_short', loc: ['body', 'instruction'], msg: 'String should have at least 1 character', input: '', ctx: { min_length: 1 } }],
}
const is422 = (u) => Object.hasOwn(detailOf, new URL(u).pathname)
await page.route(is422, (r) => r.fulfill({ status: 422, contentType: 'application/json',
body: JSON.stringify({ detail: detailOf[new URL(r.request().url()).pathname] }) }))
const got = await page.evaluate(async () => {
const { api, ApiError, streamCopilot } = window.__ui
const { errors } = window.__ui.lib
const call = async (fn) => {
try { await fn(); return null } catch (e) {
const h = errors.humanizeError(e)
return { isApi: e instanceof ApiError, status: e.status, message: e.message, raw: e.raw ?? '', arr: Array.isArray(e.detail),
title: h.title, reason: h.reason ?? '', hraw: h.raw ?? '' }
}
}
const stream = await new Promise((resolve) => {
if (!streamCopilot) return resolve(null)
streamCopilot({ instruction: '' }, () => {}, (error, info) => resolve({ error: error ?? '', status: info?.status }))
})
return {
provider: await call(() => api.providers.create({})),
source: await call(() => api.datasources.create({ name: 'X', kind: 'sqlite', port: 'abc' })),
memory: await call(() => api.memory.add({ content: 'x', importance: 3 })),
conv: await call(() => api.conversations.create({ kind: 'x' })),
wf: await call(() => api.workflows.create({ name: 'x' })),
stream,
}
})
await page.unroute(is422)
// 英文原文里的词(Field required、String should…、Input should…)一个都不能出现在给人看的话里
const english = /Field required|should|Input|valid|String|Something|went wrong/
const p = got.provider
check('缺必填:字段按表单叫法、说「为必填项」', p?.message === '提交的内容不符合要求:「名称」为必填项', JSON.stringify(p?.message))
check('……humanizeError 拆成标题和原因,英文原文进 raw(「详情」里看得到)',
p?.title === '提交的内容不符合要求' && p.reason === '「名称」为必填项' && /Field required/.test(p.hraw) && p.arr, JSON.stringify(p))
const s = got.source
check('同一个键在不同表单上叫法不同:数据源的 name 是「标识」;格式不正确、应为整数逐条说',
s?.message === '提交的内容不符合要求:「标识」格式不正确;「端口」应为整数', JSON.stringify(s?.message))
check('数值上限:不能大于 1(1.0 不写成 1.0)', got.memory?.message === '提交的内容不符合要求:「重要度」不能大于 1', JSON.stringify(got.memory?.message))
check('只能取几个值之一:把可选值列出来', got.conv?.message === '提交的内容不符合要求:「类型」只能取 chat 或 canvas', JSON.stringify(got.conv?.message))
const w = got.wf
check('没见过的错误类型、查不到叫法的嵌套字段:写字段路径 + 笼统说法,不露英文',
/「graph\.nodes\.0\.shape」不符合要求/.test(w?.message ?? '') && !english.test(w?.message ?? ''), JSON.stringify(w?.message))
check('……英文原文还在 raw 里', /Something went wrong in English/.test(w?.raw ?? ''), (w?.raw ?? '').slice(0, 80))
for (const [k, v] of Object.entries(got)) {
const said = k === 'stream' ? v?.error : `${v?.message ?? ''} ${v?.title ?? ''} ${v?.reason ?? ''}`
check(`${k}:给人看的话里没有英文原文`, !!said && !english.test(said), said)
}
check('Copilot 流式接口开流前被 422 拒:onEnd 的话同样说中文', got.stream?.status === 422
&& got.stream.error === '提交的内容不符合要求:「输入的内容」不能为空', JSON.stringify(got.stream))
const unit = await page.evaluate(() => {
const v = window.__ui.lib.validation
if (!v) return null
const d = (type, loc, extra = {}) => ({ type, loc, msg: 'English original', input: null, ...extra })
const one = (item, path) => v.describeValidation([item], path)[0]
return {
query: one(d('int_parsing', ['query', 'limit']), '/runs'),
pathParam: one(d('missing', ['path', 'run_id']), '/runs/x'),
json: one(d('json_invalid', ['body', 1], { ctx: { error: 'Expecting value' } }), '/providers'),
bodyMissing: one(d('missing', ['body']), '/providers'),
zhValue: one(d('value_error', ['body', 'graph'], { msg: 'Value error, 节点 ID 只能包含字母、数字、下划线和连字符,当前为「node 1」' }), '/workflows'),
enValue: one(d('value_error', ['body', 'name'], { msg: 'Value error, bad name' }), '/providers'),
tooLong: one(d('string_too_long', ['body', 'name'], { ctx: { max_length: 100 } }), '/skills'),
listItem: one(d('dict_type', ['body', 'examples', 0]), '/skills'),
gt: one(d('greater_than', ['body', 'importance'], { ctx: { gt: 0 } }), '/memory'),
manyChoices: one(d('literal_error', ['body', 'run_class'], { ctx: { expected: "'a', 'b', 'c', 'd', 'e', 'f', 'g' or 'h'" } }), '/runs'),
enumInts: one(d('enum', ['body', 'level'], { ctx: { expected: '1, 2 or 3' } }), '/x'),
bool: one(d('bool_parsing', ['body', 'readonly']), '/datasources'),
unknownPath: one(d('missing', ['body', 'foo_bar']), '/nowhere'),
}
})
check('lib/validation 挂在预览页上', !!unit)
if (unit) {
const want = {
query: '参数「limit」应为整数',
pathParam: '地址里的「run_id」为必填项',
json: '提交的内容不是合法的 JSON',
bodyMissing: '未收到提交的内容',
zhValue: '「工作流」:节点 ID 只能包含字母、数字、下划线和连字符,当前为「node 1」',
enValue: '「名称」取值无效',
tooLong: '「名称」过长,最多 100 个字符',
listItem: '「示例」第 1 项应为键值对象',
gt: '「重要度」应大于 0',
manyChoices: '「运行类别」不在允许的取值范围内',
enumInts: '「level」只能取 1、2 或 3',
bool: '「只读」只能是「是」或「否」',
unknownPath: '「foo_bar」为必填项',
}
for (const [k, w] of Object.entries(want)) {
check(`describeValidation · ${k}:${w}`, unit[k] === w, unit[k] === w ? '' : `得到 ${JSON.stringify(unit[k])}`)
}
}
})
await section('上传进度(kb.upload 走 XHR)', async () => {
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())
})
const harnessReloaded = reloads > 0
await section('连接状态:心跳、退避、恢复', async () => {
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、测到延迟、7 项清单都 ok', s.backend === 'ok' && s.latency != null && s.checks.length === 7 && 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(/\d+ 秒后自动重试/).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 === 7 && 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())
})
await section('真实页面:toast 不压工具栏', async () => {
// 工具栏是各页自己的,谁把它加高了,常驻的出错 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)