mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:41:58 +00:00
e10c4f23fc
来自 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 项旧断言(按旧日期格式找运行行),第三波更新。
814 lines
50 KiB
JavaScript
814 lines
50 KiB
JavaScript
// 管理页(工具 / 知识 / 数据 / 设置)的交互回归。
|
||
//
|
||
// 这几页的毛病都不在"渲染不出来",而在交互的细处:写操作失败一声不响、中文
|
||
// 输入法选词的回车把半句话写进长期记忆、原生 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)
|