Files
AgentLab/scripts/check-manage.mjs
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

814 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 管理页(工具 / 知识 / 数据 / 设置)的交互回归。
//
// 这几页的毛病都不在"渲染不出来",而在交互的细处:写操作失败一声不响、中文
// 输入法选词的回车把半句话写进长期记忆、原生 confirm 不说后果、测完连接只剩
// 4 秒 toast、Oracle 的 service_name 绑错了字段、传完表格回显一帧都看不到……
// 页面级的 check-ui 走的都是正常路径,这些一个字都不会说。
//
// 不写库:GET 放行到后端(读真实数据),其余一律 page.route 拦下伪造——断言
// 请求体对不对,再回一个像样的响应。所以对哪个后端跑都不会改数据;但照规矩
// 还是只对沙箱跑:
// AGENTLAB_WEB=http://localhost:5373 AGENTLAB_API=http://localhost:8100/api node scripts/check-manage.mjs
// 截图:CHECK_SHOTS=/某个目录 时把关键状态存下来;CHECK_THEME=light 换浅色跑一遍。
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const SHOTS = process.env.CHECK_SHOTS ?? ''
// 交互检查跑在哪套主题上(截图用):dark / light
const THEME = process.env.CHECK_THEME === 'light' ? 'light' : 'dark'
// 不用 playwright install:它既下不动也会动到已有缓存。系统 Chrome 就够了。
const CHROME = process.env.CHROME_PATH
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const get = (p) => fetch(`${API}${p}`).then((r) => r.json())
let providers, sources, tools, formats, embedding, memories, collections
try {
;[providers, sources, tools, formats, embedding, memories, collections] = await Promise.all([
get('/providers'), get('/datasources'), get('/tools'), get('/kb/formats'), get('/kb/embedding'),
get('/memory?scope=default'), get('/kb/collections'),
])
} catch (e) {
console.error(`✗ 连不上后端(${API})——先起沙箱后端\n ${e.message}`)
process.exit(1)
}
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一页:GET 放行,写请求交给 handlers(按「METHOD 路径正则」匹配),没配的一律
* 拦成 503——既不写库,也顺带检验「写失败有反馈」。所有原生对话框都算失败。
*/
async function open(path, { handlers = [], theme = THEME, viewport = { width: 1280, height: 860 } } = {}) {
const ctx = await browser.newContext({ viewport, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
await ctx.addInitScript((t) => {
try { localStorage.setItem('agentlab.theme', t); localStorage.removeItem('agentlab.health') } catch { /* noop */ }
}, theme)
const page = await ctx.newPage()
const sent = []
const errors = []
const natives = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { natives.push(`${d.type()}: ${d.message().slice(0, 40)}`); void d.dismiss() })
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
const r = route.request()
const url = new URL(r.url())
const key = `${r.method()} ${url.pathname.replace(/^\/api/, '')}`
for (const [pattern, handle] of handlers) {
if (pattern.test(key)) {
let body = null
try { body = r.postDataJSON() } catch { body = r.postData() }
sent.push({ key, url: url.pathname + url.search, body })
return handle(route, { body, url, key })
}
}
if (r.method() === 'GET') return route.continue()
sent.push({ key, url: url.pathname + url.search, body: null, blocked: true })
return route.fulfill({ status: 503, body: '' })
})
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
// App 会拿后端 settings 里的主题覆盖一次:钉回来,截图才是想看的那一套
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(400)
return { page, ctx, sent, errors, natives, close: () => ctx.close() }
}
// 页内换地址:App 会拿后端 settings 里的主题再覆盖一次,跟 open() 一样钉回来
const goto = async (page, path) => {
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), THEME)
await page.waitForTimeout(300)
}
const json = (data, status = 200) => (route) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) })
const delayed = (ms, data, status = 200) => async (route) => { await new Promise((r) => setTimeout(r, ms)); return json(data, status)(route) }
const shot = async (page, name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png` }) }
const text = (page) => page.locator('main').innerText()
const dialog = (page) => page.locator('[role="dialog"]').last()
const composingEnter = (locator) => locator.evaluate((el) => {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 229, isComposing: true, bubbles: true, cancelable: true }))
})
try {
const probe = await open('/data')
const overlay = await probe.page.locator('vite-error-overlay').count()
await probe.close()
if (overlay) {
console.error('✗ 页面上是 vite 的报错层——有文件正在改,等一会儿再跑')
process.exit(1)
}
} catch (e) {
console.error(`✗ 打不开前端(${WEB})——先起沙箱前端\n ${e.message}`)
process.exit(1)
}
console.log('=== 页面骨架:页头、标签、地址 ===')
for (const [path, title] of [['/tools', '工具'], ['/knowledge', '知识'], ['/data', '数据'], ['/settings', '设置']]) {
const { page, errors, close } = await open(path)
const h1 = await page.locator('main h1').first().innerText().catch(() => '')
const box = await page.locator('main header').first().boundingBox()
check(`${path} 有页头「${title}」`, h1 === title, h1)
check(`${path} 页头不高于 48px(toast 从 56px 起)`, !!box && box.height <= 48, box ? `${box.height}px` : '没有页头')
check(`${path} 标签页有 tablist 语义`, await page.locator('main [role="tablist"]').count() === 1)
check(`${path} 没有运行时报错`, errors.length === 0, errors[0] ?? '')
await close()
}
{
const { page, close } = await open('/settings')
const tabs = await page.locator('main [role="tab"]').allInnerTexts()
check('设置页不再有数据源标签', !tabs.some((t) => t.includes('数据源')), tabs.join(' / '))
await close()
const old = await open('/settings/datasources')
check('/settings/datasources 跳到 /data', new URL(old.page.url()).pathname.startsWith('/data'), old.page.url())
await old.close()
const data = await open('/data')
const dtabs = await data.page.locator('main [role="tab"]').allInnerTexts()
check('/data 有「数据库」「表格」两个标签', dtabs.join('|') === '数据库|表格', dtabs.join(' / '))
check('/data 落到 /data/databases', data.page.url().endsWith('/data/databases'), data.page.url())
await data.close()
}
console.log('\n=== 设置 · 模型接入 ===')
{
const p0 = providers[0]
const { page, sent, natives, errors, close } = await open('/settings/providers', {
handlers: [
[/^POST \/providers\/[^/]+\/test$/, delayed(300, { ok: true, latency_ms: 128, model: p0?.default_model ?? 'm', reply: 'pong' })],
[/^POST \/providers\/test$/, json({ ok: false, error: '测试没通过:连不上对方的服务', hint: '核对地址和端口,确认服务已经启动', detail: 'OpenAIConnectionError: Connection error.' })],
[/^POST \/providers\/models$/, json({ ok: true, models: ['qwen-max', 'qwen-plus'], url: 'x' })],
],
})
if (p0) {
const card = page.locator(`[data-provider="${p0.name}"]`)
check('卡片上的状态点说的是连通,没测过写「未测试」', (await card.locator('[data-health]').getAttribute('data-health')) === 'idle')
const disabled = providers.find((p) => !p.enabled)
if (disabled) {
check('停用单独标「已停用」,不再借圆点表达',
(await page.locator(`[data-provider="${disabled.name}"]`).innerText()).includes('已停用'))
}
await card.getByRole('button', { name: /测试/ }).click()
await page.waitForTimeout(80)
check('测试中状态点在转、写已用时间', (await card.locator('[data-health]').getAttribute('data-health')) === 'checking')
await page.waitForTimeout(600)
const pill = await card.locator('[data-health]').innerText()
check('测完留在卡片上,写明是什么时候测的:已连通 · 128 ms · 刚测过', /已连通.*128 ms.*刚测过/.test(pill), pill)
await shot(page, 'providers-tested')
await page.getByRole('tab', { name: '运行环境' }).click()
await page.waitForTimeout(300)
await page.getByRole('tab', { name: '模型接入' }).click()
await page.waitForTimeout(300)
const again = await page.locator(`[data-provider="${p0.name}"] [data-health]`).innerText()
check('切走再回来,测试结果还在', again.includes('已连通'), again)
}
await page.getByRole('button', { name: /添加接入/ }).first().click()
const dlg = dialog(page)
await page.waitForTimeout(200)
const kinds = dlg.locator('[role="radiogroup"] [role="radio"]')
const checkedAt = await kinds.evaluateAll((els) => els.findIndex((el) => el.getAttribute('aria-checked') === 'true'))
check('类型单选组只占一个 Tab 位(落在选中项上)',
(await kinds.evaluateAll((els) => els.filter((el) => el.tabIndex === 0).length)) === 1
&& (await kinds.nth(checkedAt).getAttribute('tabindex')) === '0')
await kinds.nth(checkedAt).focus()
await page.keyboard.press('ArrowRight')
const movedTo = await kinds.evaluateAll((els) => els.findIndex((el) => el.getAttribute('aria-checked') === 'true'))
check('……→ 选中下一种,焦点跟过去', movedTo === (checkedAt + 1) % await kinds.count()
&& await kinds.nth(movedTo).evaluate((el) => el === document.activeElement), `${checkedAt} → ${movedTo}`)
await dlg.getByRole('radio', { name: /OpenAI 兼容/ }).click()
check('选中的类型卡 aria-checked,且带勾', (await dlg.getByRole('radio', { name: /OpenAI 兼容/ }).getAttribute('aria-checked')) === 'true'
&& await dlg.locator('[role="radio"][aria-checked="true"] svg').count() > 0)
const name = await dlg.locator('input').first().inputValue()
check('自动名称用短名,不带一长串括号', name === 'OpenAI 兼容', name)
const saveBtn = dlg.getByRole('button', { name: '保存' })
check('Base URL 没填时保存不可点,并说缺什么', await saveBtn.isDisabled() && ((await saveBtn.getAttribute('title')) ?? '').includes('Base URL'))
const urlInput = dlg.getByLabel(/Base URL/)
check('Base URL 的占位只放示例地址,「必填」交给标签上的 *', !((await urlInput.getAttribute('placeholder')) ?? '').includes('必填'))
await urlInput.fill('http://127.0.0.1:9/v1')
await dlg.getByRole('button', { name: /测试连接/ }).click()
await page.waitForTimeout(400)
const testReq = sent.find((s) => s.key === 'POST /providers/test')
check('弹窗里测的是未保存的草稿(providers.testConfig)', testReq?.body?.kind === 'openai_compatible' && testReq?.body?.base_url === 'http://127.0.0.1:9/v1', JSON.stringify(testReq?.body ?? {}).slice(0, 120))
const body = await dlg.innerText()
check('测试失败的原因和怎么办留在弹窗里', body.includes('连不上对方的服务') && body.includes('核对地址'))
await dlg.getByRole('button', { name: /从端点拉取模型/ }).click()
await page.waitForTimeout(300)
await dlg.locator('[data-fetched-models] button', { hasText: 'qwen-max' }).click()
check('拉到的模型点一下就加进可选模型', (await dlg.getByLabel('去掉模型 qwen-max').count()) === 1)
const manual = dlg.getByLabel('手动添加模型 id')
await manual.fill('pinyin')
await composingEnter(manual)
check('模型 id 框:输入法组字时的回车不提交', (await dlg.getByLabel('去掉模型 pinyin').count()) === 0)
await shot(page, 'provider-editor')
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('填了一半按 Esc 先问「放弃修改」', (await dlg.innerText()).includes('有未保存的修改'))
await dlg.getByRole('button', { name: '放弃修改' }).click()
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
}
console.log('\n=== 设置 · 偏好 ===')
{
const settings = await get('/settings')
const { page, sent, natives, close } = await open('/settings/prefs', {
handlers: [[/^PUT \/settings$/, (route, { body }) => json({ ...settings, ...body.values })(route)]],
})
// 选一个和这次要截图的主题一致的值(浅色跑选「跟随系统」,上下文的 colorScheme
// 是浅色);它已经是选中的就先绕一下别的,点选中项不会发请求
const [label, value] = THEME === 'dark' ? ['深色', 'dark'] : ['跟随系统', 'system']
const radio = page.getByRole('radio', { name: label })
if ((await radio.getAttribute('aria-checked')) === 'true') {
await page.getByRole('radio', { name: '浅色' }).click()
await page.waitForTimeout(300)
}
await radio.click()
await page.waitForTimeout(400)
const put = sent.filter((s) => s.key === 'PUT /settings').at(-1)
check('主题选中即保存,只 PUT ui 一组', !!put && Object.keys(put.body?.values ?? {}).join() === 'ui' && put.body.values.ui.theme === value,
JSON.stringify(put?.body ?? {}).slice(0, 100))
check('主题旁显示「已保存」', (await page.locator('[data-theme-save]').innerText()).includes('已保存'))
await page.waitForTimeout(1600)
check('……约 1.6 秒后淡出,不一直挂着', await page.getByText('已保存', { exact: true }).count() === 0
&& await page.locator('[data-theme-save="saved"]').count() === 0)
check('默认知识库是下拉,选项来自已有知识库',
await page.locator('#pref-collection').evaluate((el) => el.tagName) === 'SELECT'
&& (await page.locator('#pref-collection option').allInnerTexts()).some((t) => t.startsWith(collections[0]?.collection ?? 'default')))
check('危险工具开关写明只影响探索运行', (await text(page)).includes('只影响探索运行'))
check('没改时没有保存条', await page.locator('[data-prefs-bar]').count() === 0)
await page.locator('#pref-actor').fill('检查脚本')
check('改了署名:底部出现「有 1 项未保存」', (await page.locator('[data-prefs-bar]').innerText()).includes('有 1 项未保存'))
await shot(page, 'prefs-dirty')
// 左侧导航离开:先问
await page.locator('nav a[href="/tools"]').first().click()
await page.waitForTimeout(300)
check('有未保存的改动时点导航先问', (await dialog(page).innerText().catch(() => '')).includes('还没保存'))
await dialog(page).getByRole('button', { name: '留下来保存' }).click()
await page.waitForTimeout(200)
check('选「留下」就还在偏好页', page.url().endsWith('/settings/prefs'), page.url())
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(400)
const put2 = sent.filter((s) => s.key === 'PUT /settings').at(-1)
check('保存设置只 PUT run 一组', Object.keys(put2?.body?.values ?? {}).join() === 'run', JSON.stringify(put2?.body ?? {}).slice(0, 100))
check('保存后说「已保存」', (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).includes('已保存'))
check('署名跟着保存写进本机', await page.evaluate(() => localStorage.getItem('agentlab_actor')) === '检查脚本')
check('没有原生对话框', natives.length === 0, natives.join(' | '))
await close()
// 保存失败要看得见(以前 pageerror 一条,界面毫无变化)
const failing = await open('/settings/prefs')
await failing.page.locator('#pref-actor').fill('x')
await failing.page.getByRole('button', { name: /保存设置/ }).click()
await failing.page.waitForTimeout(600)
check('保存失败:条上写「没存上」', (await failing.page.locator('[data-prefs-bar]').innerText()).includes('没存上'))
check('保存失败不抛未捕获异常', failing.errors.length === 0, failing.errors[0] ?? '')
await failing.close()
}
console.log('\n=== 数据 · 数据库 ===')
{
const dbs = sources.filter((s) => !/[\\/]uploads[\\/]tables[\\/]/.test(s.database ?? ''))
const first = dbs.find((s) => s.table_count > 0) ?? dbs[0]
const oracle = dbs.find((s) => s.kind === 'oracle')
// 一个配置里的 schema 探不出对象的假库:只有这种库才给「换个 schema 探查」。
// 顺带是个免密库(has_password:false),验证密码不再拦保存
const empty = {
id: 'check-manage-empty', name: 'zz_probe', kind: 'postgres', host: '10.0.0.9', port: null, database: 'mes',
username: 'reader', options: { schema: 'ods' }, readonly: true, description: '检查脚本的假库', enabled: true,
password_masked: '', has_password: false, table_count: 0, schema_synced_at: null,
schema_error: 'NoSuchTableError: ods', available_schemas: ['mes', 'ods', 'public'], tools: ['db_query__zz_probe'],
}
const probed = { ...empty, table_count: 3, schema_error: '', schema_synced_at: new Date().toISOString() }
const { page, sent, natives, errors, close } = await open('/data/databases', {
handlers: [
[/^GET \/datasources$/, json([...sources, empty])],
[/^GET \/datasources\/check-manage-empty\/schema$/, json({ tables: ['mes.work_order', 'mes.line', 'mes.shift'], summary: '', synced_at: probed.schema_synced_at })],
[/^POST \/datasources\/check-manage-empty\/introspect$/, (route, { url }) =>
json(url.searchParams.get('schema') === 'mes' ? probed : empty)(route)],
[/^PATCH \/datasources\/check-manage-empty$/, (route, { body }) => json({ ...empty, ...body, options: body?.options ?? empty.options })(route)],
[/^POST \/datasources\/[^/]+\/test$/, delayed(250, { ok: true, elapsed_ms: 42, url: 'x' })],
[/^POST \/datasources\/test$/, json({ ok: false, error: '连不上:认证失败', hint: '核对用户名和密码', detail: 'ORA-01017' })],
[/^POST \/datasources\/[^/]+\/introspect/, (route, { url }) => {
const id = url.pathname.split('/')[3]
const row = sources.find((s) => s.id === id)
return json({ ...row, schema_synced_at: new Date().toISOString() })(route)
}],
],
})
if (!first) {
check('(跳过:沙箱里没有数据库)', true)
} else {
const card = page.locator(`[data-source="${first.name}"]`)
const addrs = await page.locator('article[data-source] .mono').allInnerTexts()
check('地址不留尾巴冒号', !addrs.some((a) => /:\s*$/.test(a)), addrs.join(' | ').slice(0, 160))
await card.getByRole('button', { name: /测连接/ }).click()
await page.waitForTimeout(40)
const spoken0 = await card.locator('[role="status"]').first().innerText().catch(() => '')
await page.waitForTimeout(160)
const spoken1 = await card.locator('[role="status"]').first().innerText().catch(() => '')
check('测连接期间播报区只说一句「正在测连接」,不跟着计时一跳一跳地念',
spoken0 === '正在测连接' && spoken1 === spoken0, `${spoken0} / ${spoken1}`)
check('……看得见的胶囊不是播报区(里面有计时)',
(await card.locator('[data-health]').first().getAttribute('aria-hidden')) === 'true'
&& (await card.locator('[data-health]').first().getAttribute('role')) === null)
await page.waitForTimeout(400)
const pill = await card.locator('[data-health]').first().innerText()
check('测连接的结果留在卡片上:已连通 · 42 ms', /已连通.*42 ms/.test(pill), pill)
const spoken = await card.locator('[role="status"]').first().innerText()
check('……播报区说结果和钟点,不说「几分钟前」', /^已连通 42 ms,\d{1,2}:\d{2} 测的$/.test(spoken), spoken)
check('卡片上写着结构同步于何时', (await card.innerText()).includes('结构同步于'))
if (first.table_count > 0) {
await card.getByRole('button', { name: /个对象/ }).click()
await page.waitForTimeout(700)
const rows = card.locator('[data-schema-browser] button[aria-expanded]')
const n = await rows.count()
check('表清单一行一张表', n === Math.min(first.table_count, 300), `${n} 行 / ${first.table_count} 个对象`)
await rows.first().click()
await page.waitForTimeout(800)
const cols = await card.locator('[data-schema-browser] tbody tr').count()
check('点表名懒加载出逐列信息', cols > 0, `${cols} 列`)
const scroller = card.locator('[data-schema-browser] .overflow-y-auto')
await scroller.evaluate((el) => { el.scrollTop = 60 })
const before = await scroller.evaluate((el) => el.scrollTop)
await card.getByRole('button', { name: /探查结构/ }).click()
await page.waitForTimeout(900)
check('探查完不整页 Spinner:结构浏览器还开着', await card.locator('[data-schema-browser]').count() === 1)
check('……展开的那张表也还开着', await card.locator('[data-schema-browser] button[aria-expanded="true"]').count() >= 1)
const after = await card.locator('[data-schema-browser] .overflow-y-auto').evaluate((el) => el.scrollTop).catch(() => -1)
check('……滚动位置没跳回顶部', Math.abs(after - before) < 5, `${before} → ${after}`)
await shot(page, 'datasource-schema')
}
// 好好的库不给「换个 schema 探查」:后端探查会落进缓存,换着探就把助手看到的结构换掉了
check('结构正常的库不给「换个 schema 探查」', await card.locator('[data-probe-schema]').count() === 0)
await card.getByRole('button', { name: `删除数据源 ${first.name}` }).click()
const del = dialog(page)
const delText = await del.innerText()
check('删除数据源:说明后果(工具、工作流)', delText.includes('工具') && delText.includes('工作流'))
check('删除数据源:要照抄名字才能确认', await del.getByRole('button', { name: '删除数据源' }).isDisabled())
await del.getByRole('button', { name: '取消' }).click()
check('取消后没有发删除', !sent.some((s) => s.key.startsWith('DELETE')))
}
{
// 配置里的 schema 探不出东西:才给「换个 schema 探查」
const ec = page.locator(`[data-source="${empty.name}"]`)
check('探查失败的库给「换个 schema 探查」', await ec.locator('[data-probe-schema]').count() === 1)
await ec.locator('[data-probe-schema]').click()
const pd = dialog(page)
const pdText = await pd.innerText()
check('……先说清楚:探到的结构会换掉缓存,不写进配置就换回来', pdText.includes('换掉') && pdText.includes('换回「ods」'), pdText.slice(0, 120))
await pd.locator('input').fill('mes')
await pd.getByRole('button', { name: '探查', exact: true }).click()
await page.waitForTimeout(500)
const probeReq = sent.filter((s) => s.key.endsWith('/introspect')).at(-1)
check('……请求带上指定的 schema', !!probeReq?.url.includes('schema=mes'), probeReq?.url ?? '')
const ask = dialog(page)
const askText = await ask.innerText().catch(() => '')
check('……探到了再问要不要写进配置,并举出探到的表名', askText.includes('mes') && askText.includes('work_order'), askText.slice(0, 160))
await shot(page, 'datasource-probe-schema')
const before = sent.filter((s) => s.key.endsWith('/introspect')).length
await ask.getByRole('button', { name: '不改,换回 ods' }).click()
await page.waitForTimeout(500)
const restore = sent.filter((s) => s.key.endsWith('/introspect')).slice(before)
check('……不改:马上按配置里的 schema 重探一次,缓存和配置对得上',
restore.length === 1 && !restore[0].url.includes('schema='), restore.map((r) => r.url).join(' | '))
check('……不改就不发 PATCH', !sent.some((s) => s.key.startsWith('PATCH /datasources')))
check('……卡片回到配置里那份(探查失败、0 个对象)', (await ec.innerText()).includes('上次探查失败'))
// 失败框里的候选 chip:探到了选「改」就写进配置
await ec.getByRole('button', { name: '用 mes 探查' }).click()
await page.waitForTimeout(500)
await dialog(page).getByRole('button', { name: '改成 mes' }).click()
await page.waitForTimeout(400)
const patch = sent.filter((s) => s.key === 'PATCH /datasources/check-manage-empty').at(-1)
check('……选「改成 mes」:PATCH 写进 options.schema', patch?.body?.options?.schema === 'mes', JSON.stringify(patch?.body ?? {}))
// 免密库:密码不拦保存
await ec.getByRole('button', { name: '编辑' }).click()
const ed = dialog(page)
await ed.getByLabel('说明', { exact: true }).fill('检查脚本的假库(改过说明)')
const saveBtn = ed.getByRole('button', { name: '保存' })
check('免密库改说明:保存可点,不再要「还缺:密码」',
await saveBtn.isEnabled() && !((await saveBtn.getAttribute('title')) ?? '').includes('密码'),
(await saveBtn.getAttribute('title')) ?? '')
check('……密码框下软提示「只有免密登录的库才能这样连」', (await ed.innerText()).includes('免密登录'))
await shot(page, 'datasource-nopw-edit')
if (await saveBtn.isEnabled()) await saveBtn.click()
else await ed.getByRole('button', { name: '取消' }).click()
await page.waitForTimeout(400)
const saved = sent.filter((s) => s.key === 'PATCH /datasources/check-manage-empty').at(-1)
check('……保存时不带 password(不动它)', !!saved && !('password' in (saved.body ?? {})) && saved.body?.description?.includes('改过说明'),
JSON.stringify(saved?.body ?? {}).slice(0, 120))
}
if (oracle) {
await page.locator(`[data-source="${oracle.name}"]`).getByRole('button', { name: '编辑' }).click()
const dlg = dialog(page)
const target = dlg.locator('#ds-oracle-target')
const svc = oracle.options?.service_name ?? oracle.database ?? ''
check('Oracle 编辑框回显 service_name(以前绑在 database 上,显示为空)', (await target.inputValue()) === svc, `「${await target.inputValue()}」应为「${svc}」`)
check('类型提示不再指向表单上没有的 options', !(await dlg.innerText()).includes('options.'))
const svcRadio = dlg.getByRole('radio', { name: 'service_name' })
check('service_name / SID 单选组只占一个 Tab 位',
(await svcRadio.getAttribute('tabindex')) === '0' && (await dlg.getByRole('radio', { name: 'SID' }).getAttribute('tabindex')) === '-1')
await svcRadio.focus()
await page.keyboard.press('ArrowRight')
check('……→ 切到 SID,焦点跟过去', (await dlg.getByRole('radio', { name: 'SID' }).getAttribute('aria-checked')) === 'true'
&& await dlg.getByRole('radio', { name: 'SID' }).evaluate((el) => el === document.activeElement))
check('切到 SID:框里是 SID 的值(空)', (await target.inputValue()) === '')
check('SID 为空时保存不可点', await dlg.getByRole('button', { name: '保存' }).isDisabled())
await target.fill('ORCL')
await dlg.getByRole('button', { name: /测试连接/ }).click()
await page.waitForTimeout(400)
const req = sent.find((s) => s.key === 'POST /datasources/test')
check('弹窗里先测连接:带 id、只写 options.sid、不写 database',
req?.body?.id === oracle.id && req?.body?.options?.sid === 'ORCL' && !('service_name' in (req?.body?.options ?? {})) && !('database' in (req?.body ?? {})),
JSON.stringify(req?.body ?? {}).slice(0, 160))
check('测试失败的原因留在弹窗里', (await dlg.innerText()).includes('核对用户名和密码'))
await dlg.getByLabel(/主机/).fill('')
const title = (await dlg.getByRole('button', { name: '保存' }).getAttribute('title')) ?? ''
check('缺必填项时保存不可点,悬停说缺什么', await dlg.getByRole('button', { name: '保存' }).isDisabled() && title.includes('主机'), title)
await shot(page, 'datasource-oracle-edit')
await dlg.getByRole('button', { name: '取消' }).click()
} else {
check('(跳过:沙箱里没有 Oracle 数据源)', true)
}
// 术语表:右栏那位叫「助手」,「Copilot」只能出现在按钮提示里
check('正文里不写「Copilot」,统一叫「助手」', !(await text(page)).includes('Copilot'))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
}
console.log('\n=== 数据 · 表格 ===')
{
const fake = {
source: {
id: 'check-manage-fake', name: 'sales_demo', kind: 'sqlite', host: null, port: null,
database: '/tmp/x/uploads/tables/sales_demo.db', username: null, options: {}, readonly: true,
description: '', enabled: true, password_masked: '', has_password: false, table_count: 1,
schema_synced_at: new Date().toISOString(), schema_error: '', available_schemas: [], tools: ['db_query__sales_demo'],
},
replaced: false,
tables: [{ name: 'sales_demo', sheet: 'sales_demo', rows: 12, columns: [
{ name: '2026-01', type: 'REAL' }, { name: 'region', type: 'TEXT' }, { name: 'Unnamed: 2', type: 'TEXT' },
] }],
}
const { page, sent, natives, close } = await open('/data/tables', {
handlers: [
[/^POST \/datasources\/upload$/, delayed(400, fake, 201)],
// 传上来的表只有一两张,卡片会自动摊开结构:假的那张也得有结构可取
[/^GET \/datasources\/check-manage-fake\/schema$/, (route, { url }) => json(url.searchParams.get('table')
? { table: 'sales_demo', detail: '表 sales_demo\n 2026-01 REAL\n region TEXT\n Unnamed: 2 TEXT' }
: { tables: ['sales_demo'], summary: '', synced_at: fake.source.schema_synced_at })(route)],
],
})
await page.getByRole('button', { name: /传表格/ }).first().click()
const dlg = dialog(page)
await dlg.locator('input[type="file"]').setInputFiles({ name: 'sales_demo.csv', mimeType: 'text/csv', buffer: Buffer.from('2026-01,region\n1,east\n') })
check('文件名自动变成数据源名', (await dlg.locator('input.mono').first().inputValue()) === 'sales_demo')
await dlg.getByRole('button', { name: /导入/ }).click()
await page.waitForTimeout(100)
check('导入中按钮写已用时间', /导入中/.test(await dlg.getByRole('button', { name: /导入中/ }).innerText().catch(() => '')))
await page.waitForTimeout(700)
const res = await dialog(page).innerText()
check('导入完弹窗不关,停在列名和类型上', res.includes('region') && res.includes('TEXT'))
check('像数据的列名标黄并提示改表头行号', await dialog(page).locator('[data-suspicious]').count() === 2 && res.includes('第 2 行'))
check('两个出口:列名对了 · 完成 / 表头不对 · 改行号重传',
await dialog(page).getByRole('button', { name: '列名对了 · 完成' }).count() === 1
&& await dialog(page).getByRole('button', { name: /表头不对/ }).count() === 1)
check('后台列表里已经有了这张表', await page.locator('[data-source="sales_demo"]').count() === 1)
await shot(page, 'table-upload-result')
await dialog(page).getByRole('button', { name: /表头不对/ }).click()
await page.waitForTimeout(200)
check('改行号重传:回到表单,行号 +1', (await dialog(page).locator('input[type="number"]').inputValue()) === '2')
await dialog(page).getByRole('button', { name: '取消' }).click()
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('上传只发了一次', sent.filter((s) => s.key === 'POST /datasources/upload').length === 1)
await close()
}
console.log('\n=== 知识库 ===')
{
const { page, sent, natives, errors, close } = await open('/knowledge/kb', {
handlers: [[/^POST \/kb\/upload$/, delayed(1200, {
id: 'fake-doc', collection: 'default', title: 'note.md', source: 'note.md', mime: 'text/markdown',
chunk_count: 0, status: 'processing', meta: { progress: { done: 1, total: 4 } },
}, 201)]],
})
const accept = await page.locator('input[data-kb-upload]').getAttribute('accept')
check('上传框的 accept 取自 /kb/formats', accept === formats.accept, accept ?? '')
check('……放行 .pptx,不放 .csv', accept?.includes('.pptx') && !accept?.includes('.csv'))
const alpha = Number(await page.locator('input[data-alpha]').inputValue())
check('检索调试台的 α 初值是运行时的 default_alpha', alpha === embedding.default_alpha, `${alpha} vs ${embedding.default_alpha}`)
await page.locator('input[data-alpha]').fill('0.2')
await page.waitForTimeout(100)
check('偏离运行时值要标出来', (await page.locator('[data-alpha-off]').count()) === 1)
await page.locator('[data-alpha-off] button', { hasText: '重置' }).click()
check('一键回到运行时值', Number(await page.locator('input[data-alpha]').inputValue()) === embedding.default_alpha)
await page.locator('input[data-kb-upload]').setInputFiles({ name: 'note.md', mimeType: 'text/markdown', buffer: Buffer.from('# hi') })
await page.waitForTimeout(250)
check('上传中列表顶上有占位行', await page.locator('[data-uploading]').count() === 1)
check('上传按钮写着上传中', (await page.getByRole('button', { name: /上传中/ }).count()) === 1)
await shot(page, 'kb-uploading')
await page.waitForTimeout(1300)
check('传完占位行消失', await page.locator('[data-uploading]').count() === 0)
await page.locator('input[data-kb-upload]').setInputFiles({ name: 'data.csv', mimeType: 'text/csv', buffer: Buffer.from('a,b\n1,2') })
await page.waitForTimeout(300)
const toData = page.getByRole('button', { name: '去数据源传表格' })
check('表格被拦下,toast 给「去数据源传表格」', await toData.count() === 1)
check('……表格不会发到知识库', sent.filter((s) => s.key === 'POST /kb/upload').length === 1)
await toData.click()
await page.waitForTimeout(300)
check('……点了就到 /data/tables', page.url().endsWith('/data/tables'), page.url())
await goto(page, '/knowledge/kb')
await page.getByRole('button', { name: '换一个' }).click()
await page.getByRole('button', { name: /本地哈希向量/ }).click()
const dlg = dialog(page)
const body = await dlg.innerText().catch(() => '')
check('切本地哈希先确认,并写出影响范围', body.includes('段知识') && body.includes('条记忆'))
check('……要照抄「本地哈希」才能确认', await dlg.getByRole('button', { name: '切到本地哈希' }).isDisabled())
await page.waitForTimeout(300)
await shot(page, 'kb-local-confirm')
await dlg.getByRole('button', { name: '取消' }).click()
check('……取消了就什么都没发', !sent.some((s) => s.key === 'PUT /kb/embedding'))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
}
console.log('\n=== 长期记忆 ===')
{
const withRun = memories.find((m) => m.source?.kind === 'run' && m.source?.run_exists)
const target = memories[0]
const { page, sent, natives, errors, close } = await open('/knowledge/memory', {
handlers: [
[/^POST \/memory$/, (route, { body }) => json({ id: 'fake-mem', scope: 'default', kind: body.kind, content: body.content, importance: 0.5, use_count: 0, meta: {}, source: { kind: 'manual' }, created_at: new Date().toISOString(), last_used_at: null }, 201)(route)],
[/^PATCH \/memory\//, (route, { body }) => json({ ...target, ...body })(route)],
[/^GET \/memory\/search$/, json({ results: [], peek: true })],
],
})
const input = page.locator('[data-memory-input]')
await input.fill('出勤率 = 实际 / 计划')
await composingEnter(input)
await page.waitForTimeout(200)
check('输入法组字时的回车不写记忆', !sent.some((s) => s.key === 'POST /memory'))
await input.press('Enter')
await page.waitForTimeout(400)
const add = sent.find((s) => s.key === 'POST /memory')
check('回车写入,内容完整', add?.body?.content === '出勤率 = 实际 / 计划', JSON.stringify(add?.body ?? {}))
await page.getByLabel('回忆测试').fill('出勤')
await page.getByRole('button', { name: /回忆/ }).click()
await page.waitForTimeout(300)
const recall = sent.find((s) => s.key === 'GET /memory/search')
check('回忆测试带 peek=true,不改召回计数', recall?.url.includes('peek=true'), recall?.url ?? '')
if (withRun) {
const href = await page.locator(`[data-memory="${withRun.id}"] a[data-memory-source]`).getAttribute('href')
check('记忆来源可点,跳到那次运行', href === `/runs/${withRun.source.run_id}`, href ?? '')
}
if (target) {
const row = page.locator(`[data-memory="${target.id}"]`)
check('写着「记下于」', (await row.innerText()).includes('记下于'))
await row.getByRole('button', { name: '修改这条记忆' }).click()
await row.getByLabel('修改记忆内容').fill(`${target.content}(已核对)`)
await row.getByRole('button', { name: '保存' }).click()
await page.waitForTimeout(400)
const patch = sent.find((s) => s.key.startsWith('PATCH /memory/'))
check('原地修改走 PATCH', patch?.body?.content?.endsWith('(已核对)'), JSON.stringify(patch?.body ?? {}).slice(-40))
await page.locator(`[data-memory="${target.id}"]`).getByRole('button', { name: '删除这条记忆' }).click()
const d = dialog(page)
check('删除记忆先确认,并说后果', (await d.innerText()).includes('召回不到'))
await d.getByRole('button', { name: '删除记忆' }).click()
await page.waitForTimeout(300)
check('确认后先从列表拿掉', await page.locator(`[data-memory="${target.id}"]`).count() === 0)
await shot(page, 'memory-undo')
await page.getByRole('button', { name: '撤销' }).click()
await page.waitForTimeout(600)
check('撤销就回来了', await page.locator(`[data-memory="${target.id}"]`).count() === 1)
await page.waitForTimeout(5200)
check('撤销了就不会再发 DELETE', !sent.some((s) => s.key.startsWith('DELETE')))
}
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
}
console.log('\n=== Skill ===')
{
const { page, sent, natives, close } = await open('/knowledge/skills', {
handlers: [[/^POST \/skills$/, (route, { body }) => json({ id: 'fake-skill', ...body }, 201)(route)]],
})
await page.getByRole('button', { name: /新建 Skill/ }).first().click()
const dlg = dialog(page)
await dlg.locator('input').first().fill('检查用 Skill')
await dlg.locator('#skill-tags').click()
await page.keyboard.type('质量,工艺,出具')
const chips = await dlg.getByLabel(/去掉标签/).count()
check('逐字敲「质量,工艺,出具」:逗号(中英文)能分出标签', chips === 2, `${chips} 个标签 + 框里「${await dlg.locator('#skill-tags').inputValue()}」`)
await dlg.getByRole('button', { name: '保存' }).click()
await page.waitForTimeout(400)
const post = sent.find((s) => s.key === 'POST /skills')
check('保存时框里没收成标签的那半截也算上', JSON.stringify(post?.body?.tags) === JSON.stringify(['质量', '工艺', '出具']), JSON.stringify(post?.body?.tags))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
await close()
}
console.log('\n=== 工具 ===')
{
const danger = tools.find((t) => t.runtime_approval)
const safe = tools.find((t) => t.source === 'builtin' && !t.dangerous)
let calls = 0
const { page, sent, natives, errors, close } = await open(danger ? `/tools/library/${danger.id}` : '/tools', {
handlers: [
[/^POST \/tools\/[^/]+\/run$/, (route, { body }) => {
calls++
return body?.confirm
? json({ ok: true, result: 'done', duration_ms: 5 })(route)
: json({ detail: '会在 playground 工作目录里写入「x.txt」,同名文件会被覆盖。在工具库里执行不经过审批,确认后才会执行。' }, 409)(route)
}],
[/^POST \/mcp\/refresh$/, (route) => route.abort()],
],
})
if (danger) {
const listItem = page.locator('nav[aria-label="工具列表"] button', { hasText: danger.name }).first()
check('会停下来等审批的工具标「运行时需审批」', (await listItem.innerText()).includes('运行时需审批'))
if (safe) {
const s = page.locator('nav[aria-label="工具列表"] button', { hasText: safe.name }).first()
check('没副作用的内置工具不挂审批标签', !(await s.innerText()).includes('审批'))
}
check('详情区写明这里直接执行、不经审批', (await text(page)).includes('不经审批'))
await page.getByRole('button', { name: /^执行$/ }).click()
await page.waitForTimeout(300)
const d = dialog(page)
check('409 后弹确认,把后端说的「会做什么」给人看', (await d.innerText().catch(() => '')).includes('会被覆盖'))
await shot(page, 'tool-confirm')
await d.getByRole('button', { name: '确认执行' }).click()
await page.waitForTimeout(400)
const runs = sent.filter((s) => /^POST \/tools\/.+\/run$/.test(s.key))
check('确认后带 confirm:true 重发', runs.length === 2 && runs[1].body?.confirm === true, JSON.stringify(runs.map((r) => r.body?.confirm)))
check('结果显示成功', (await page.locator('[data-tool-result]').getAttribute('data-tool-result')) === 'ok')
}
await page.getByRole('tab', { name: '自定义工具' }).click()
await page.waitForTimeout(400)
await page.getByRole('button', { name: /新建工具/ }).first().click()
const params = await dialog(page).locator('textarea').nth(1).inputValue()
check('新建工具的参数给了能跑的示例,不是 {}', params.includes('"query"'), params.slice(0, 60))
check('编辑器里有试跑区(保存后可用)', (await dialog(page).locator('[data-tool-trial]').innerText()).includes('先保存'))
await dialog(page).getByRole('button', { name: '取消' }).click()
await page.getByRole('tab', { name: 'MCP 接入' }).click()
await page.waitForTimeout(400)
await page.getByRole('button', { name: /重新加载/ }).click()
await page.waitForTimeout(700)
check('写操作失败有 error toast(以前一声不响)', await page.locator('[role="alert"][aria-live="assertive"] > *').count() > 0)
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
}
console.log('\n=== 自定义工具 · 已有工具 ===')
{
// 参数是 {} 的已有工具:编辑器要显示存着的样子,只改描述不能把示例参数一起存回去
const tool = {
id: 'check-manage-tool', name: 'ping_mes', kind: 'http', description: '旧描述', parameters: {},
config: { method: 'GET', url: 'https://example.com/ping' }, enabled: true,
}
const doomed = { ...tool, id: 'check-manage-doomed', name: 'doomed_tool', description: '要删的' }
let list = [tool, doomed]
const { page, sent, natives, errors, close } = await open('/tools/custom', {
handlers: [
[/^GET \/custom-tools$/, (route) => json(list)(route)],
[/^PATCH \/custom-tools\/check-manage-tool$/, (route, { body }) => json({ ...tool, ...body })(route)],
[/^DELETE \/custom-tools\//, (route) => route.fulfill({ status: 204, body: '' })],
],
})
const row = page.locator('div.rounded-lg', { hasText: 'ping_mes' }).last()
await row.getByRole('button', { name: '编辑' }).click()
const dlg = dialog(page)
const params = await dlg.locator('textarea').nth(1).inputValue()
check('已有工具的参数照存着的显示({}),不塞示例', !params.includes('query'), params.slice(0, 60))
check('……参数为空时给「插入示例参数」,点了才填', await dlg.getByRole('button', { name: /插入示例参数/ }).count() === 1)
await shot(page, 'custom-tool-empty-params')
await dlg.locator('textarea').first().fill('新描述:探一下 MES 在不在')
await dlg.getByRole('button', { name: '保存' }).click()
await page.waitForTimeout(500)
const patch = sent.filter((s) => s.key === 'PATCH /custom-tools/check-manage-tool').at(-1)
check('只改描述:PATCH 里的参数还是 {},不带 query', !!patch && !JSON.stringify(patch.body?.parameters ?? {}).includes('query'),
JSON.stringify(patch?.body?.parameters ?? null))
await page.locator('div.rounded-lg', { hasText: 'ping_mes' }).last().getByRole('button', { name: '编辑' }).click()
const insert = dialog(page).getByRole('button', { name: /插入示例参数/ })
if (await insert.count()) await insert.click()
check('……点「插入示例参数」才填进 query', (await dialog(page).locator('textarea').nth(1).inputValue()).includes('"query"'))
await dialog(page).getByRole('button', { name: '取消' }).click()
// 撤销窗口里别的操作重拉列表:删掉的那行不能跟着回来
await page.getByRole('button', { name: '删除自定义工具 doomed_tool' }).click()
await page.waitForTimeout(200)
// 限定 exact:toast「已删除工具「doomed_tool」」里也有这个名字
const doomedRow = page.locator('main').getByText('doomed_tool', { exact: true })
check('删除后先从列表拿掉', await doomedRow.count() === 0)
await page.locator('div.rounded-lg', { hasText: 'ping_mes' }).last().getByRole('button', { name: '编辑' }).click()
await dialog(page).locator('textarea').first().fill('再改一次描述')
await dialog(page).getByRole('button', { name: '保存' }).click()
await page.waitForTimeout(500)
check('……撤销窗口里保存别的工具、列表重拉,删掉的那行不回来', await doomedRow.count() === 0
&& sent.filter((s) => s.key === 'GET /custom-tools').length >= 2)
await page.waitForTimeout(5000)
list = [tool]
check('……到点只发一次 DELETE', sent.filter((s) => s.key.startsWith('DELETE /custom-tools/')).length === 1)
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
}
console.log('\n=== 知识库 · 撤销窗口里的轮询 ===')
{
// 有文档在处理时列表 2 秒轮询一次。以前删掉的那行 2.6 秒后又被轮询救回来
const now = new Date().toISOString()
const busy = { id: 'check-manage-busy', collection: 'default', title: 'big.pdf', source: 'big.pdf', mime: 'application/pdf',
chunk_count: 0, status: 'processing', error: '', meta: { progress: { done: 3, total: 40 } }, created_at: now }
const victim = { ...busy, id: 'check-manage-victim', title: 'victim.docx', source: 'victim.docx', status: 'ready', chunk_count: 2, meta: {} }
const two = [
{ collection: 'default', documents: 2, chunks: 2 },
{ collection: 'check_second', documents: 0, chunks: 0 },
]
let docs = [busy, victim]
const { page, sent, natives, errors, close } = await open('/knowledge/kb', {
handlers: [
[/^GET \/kb\/collections$/, json(two)],
[/^GET \/kb\/documents$/, (route) => json(docs)(route)],
[/^DELETE \/kb\/documents\//, (route) => { docs = [busy]; return route.fulfill({ status: 204, body: '' }) }],
],
})
// 知识库单选组:只占一个 Tab 位,方向键切换
const radios = page.locator('[role="radiogroup"][aria-label="知识库"] [role="radio"]')
check('知识库单选组只占一个 Tab 位', (await radios.evaluateAll((els) => els.map((el) => el.tabIndex).join())) === '0,-1')
check('……「新知识库」不在单选组里', await page.locator('[role="radiogroup"][aria-label="知识库"]').getByText('新知识库').count() === 0)
await radios.first().focus()
await page.keyboard.press('ArrowRight')
await page.waitForTimeout(300)
check('……→ 切到下一个知识库,焦点跟过去', (await radios.nth(1).getAttribute('aria-checked')) === 'true'
&& await radios.nth(1).evaluate((el) => el === document.activeElement))
await page.keyboard.press('ArrowLeft')
await page.waitForTimeout(600)
await page.locator('[data-doc="victim.docx"]').getByRole('button', { name: /删除/ }).click()
await page.waitForTimeout(200)
check('删文档:先从列表拿掉', await page.locator('[data-doc="victim.docx"]').count() === 0)
await page.waitForTimeout(2600)
check('……撤销窗口里轮询刷新了列表,它也不回来', await page.locator('[data-doc="victim.docx"]').count() === 0
&& await page.locator('[data-doc="big.pdf"]').count() === 1)
await page.waitForTimeout(2800)
check('……到点只发一次 DELETE', sent.filter((s) => s.key.startsWith('DELETE /kb/documents/')).length === 1)
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
}
console.log('\n=== 知识库 · 切块地图 ===')
{
const doc = (await get('/kb/documents?collection=default'))[0]
if (doc) {
const { page, close } = await open(`/knowledge/kb/${doc.id}`)
const cells = page.locator('[data-chunk-map] button')
check('切块地图的每一格是按钮(不是 listitem)', await cells.count() > 0
&& (await cells.evaluateAll((els) => els.every((el) => !el.getAttribute('role')))))
check('……容器是带名字的 group,没有 list 语义', await page.locator('[data-chunk-map] [role="group"][aria-label="切块地图"]').count() === 1
&& await page.locator('[data-chunk-map] [role="list"], [data-chunk-map] [role="listitem"]').count() === 0)
await close()
} else {
check('(跳过:沙箱知识库里没有文档)', true)
}
}
if (SHOTS) {
console.log('\n=== 截图(亮 / 暗) ===')
for (const theme of ['dark', 'light']) {
for (const path of ['/data/databases', '/data/tables', '/settings/providers', '/settings/prefs', '/tools/library', '/tools/custom', '/knowledge/kb', '/knowledge/memory', '/knowledge/skills']) {
const { page, close } = await open(path, { theme })
await page.screenshot({ path: `${SHOTS}/${path.replace(/\//g, '_').slice(1)}-${theme}.png` })
await close()
}
}
console.log(` 已存到 ${SHOTS}`)
}
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 管理页全部通过')
process.exit(failed ? 1 : 0)