mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +00:00
587bf7e27c
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」, 校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮, 轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。 平台可信度(先写失败测试) - Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则, 工具集合变小时自查报警、改图回执列出变化 - 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修 - 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量 - 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因 - 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员 - 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行 后端 - 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、 SQLite progress handler),可按数据源配置;超时报错与实际耗时一致 - 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存; 自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存; 检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors 前端 - 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时, 一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子 - 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚; 回放用量取那一刻的值 - 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口 - 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对 - 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条 - 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问 验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿; check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
1223 lines
79 KiB
JavaScript
1223 lines
79 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 })
|
||
|
||
/**
|
||
* 上传进度靠浏览器的 xhr.upload 事件,而 page.route 拦下的请求 Chrome 一个进度事件
|
||
* 都不报(真请求会报,已实测)。所以在页面里替 XHR 报:发 /upload 时先报 40%,
|
||
* 0.7 秒后报发完,再真正 send(被 page.route 接住)。发之前 abort 的,照浏览器的
|
||
* 样子报一次 abort。只测界面怎么接这些事件,不碰后端
|
||
*/
|
||
function fakeUploadProgress() {
|
||
const open = XMLHttpRequest.prototype.open
|
||
const send = XMLHttpRequest.prototype.send
|
||
const abort = XMLHttpRequest.prototype.abort
|
||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||
this.__url = String(url)
|
||
return open.call(this, method, url, ...rest)
|
||
}
|
||
XMLHttpRequest.prototype.abort = function () {
|
||
if (this.__pending) {
|
||
this.__pending = false
|
||
this.dispatchEvent(new ProgressEvent('abort'))
|
||
return
|
||
}
|
||
return abort.call(this)
|
||
}
|
||
XMLHttpRequest.prototype.send = function (body) {
|
||
if (!/\/upload(\?|$)/.test(this.__url ?? '')) return send.call(this, body)
|
||
const total = 2_000_000
|
||
const up = this.upload
|
||
this.__pending = true
|
||
const fire = (type, loaded) => up.dispatchEvent(new ProgressEvent(type, { lengthComputable: true, loaded, total }))
|
||
setTimeout(() => { if (this.__pending) fire('progress', 800_000) }, 50)
|
||
setTimeout(() => {
|
||
if (!this.__pending) return
|
||
this.__pending = false
|
||
fire('progress', total)
|
||
fire('load', total)
|
||
send.call(this, body)
|
||
}, 700)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 开一页:GET 放行,写请求交给 handlers(按「METHOD 路径正则」匹配),没配的一律
|
||
* 拦成 503——既不写库,也顺带检验「写失败有反馈」。所有原生对话框都算失败。
|
||
*/
|
||
async function open(path, { handlers = [], theme = THEME, viewport = { width: 1280, height: 860 }, uploadProgress = false } = {}) {
|
||
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)
|
||
if (uploadProgress) await ctx.addInitScript(fakeUploadProgress)
|
||
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) })
|
||
/** 多久之前的服务器时间(后端列表里的 last_checked_at 就是这种带时区的 ISO) */
|
||
const ago = (ms) => new Date(Date.now() - ms).toISOString()
|
||
/** 等到条件成立或超时,返回最后一次的值 */
|
||
const until = async (fn, ms = 8000, step = 200) => {
|
||
const end = Date.now() + ms
|
||
let v = await fn()
|
||
while (!v && Date.now() < end) { await new Promise((r) => setTimeout(r, step)); v = await fn() }
|
||
return v
|
||
}
|
||
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]
|
||
// 后端记着上次测连接的结果(last_check_*):第一张卡 3 小时前测过、没通过,其余没测过
|
||
const listed = providers.map((p, i) => (i === 0 ? {
|
||
...p, last_checked_at: ago(3 * 3600_000), last_check_ok: false, last_latency_ms: null,
|
||
last_error: '鉴权没通过(401):对方拒绝了这把密钥',
|
||
} : { ...p, last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null }))
|
||
const { page, sent, natives, errors, close } = await open('/settings/providers', {
|
||
handlers: [
|
||
[/^GET \/providers$/, json(listed)],
|
||
[/^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}"]`)
|
||
const first = await card.locator('[data-health]').innerText()
|
||
check('卡片初值是后端记着的上次结果:连不上 · 3 小时前测(换了浏览器也在)', /连不上.*3 小时前测/.test(first), first)
|
||
check('……原因也写在卡片上', (await card.innerText()).includes('对方拒绝了这把密钥'))
|
||
const p1 = providers[1]
|
||
if (p1) {
|
||
check('后端没测过的卡片写「未测试」',
|
||
(await page.locator(`[data-provider="${p1.name}"] [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=== 设置 · 偏好 ===')
|
||
{
|
||
// 设置读写都走假的一份:PUT 写进去,GET 读回来(主题的「已保存」是读回来核对过才写的)
|
||
let stored = await get('/settings')
|
||
const { page, sent, natives, close } = await open('/settings/prefs', {
|
||
handlers: [
|
||
[/^GET \/settings$/, (route) => json(stored)(route)],
|
||
[/^PUT \/settings$/, (route, { body }) => { stored = { ...stored, ...body.values }; return json(stored)(route) }],
|
||
],
|
||
})
|
||
const themeRadio = (label) => page.getByRole('radiogroup', { name: '主题' }).getByRole('radio', { name: label })
|
||
const checkedTheme = () => page.getByRole('radiogroup', { name: '主题' }).locator('[aria-checked="true"]').innerText()
|
||
// 选一个和这次要截图的主题一致的值(浅色跑选「跟随系统」,上下文的 colorScheme
|
||
// 是浅色);它已经是选中的就先绕一下别的,点选中项不会发请求
|
||
const [label, value] = THEME === 'dark' ? ['深色', 'dark'] : ['跟随系统', 'system']
|
||
if ((await themeRadio(label).getAttribute('aria-checked')) === 'true') {
|
||
await themeRadio('浅色').click()
|
||
await page.waitForTimeout(400)
|
||
}
|
||
await themeRadio(label).click()
|
||
await page.waitForTimeout(500)
|
||
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)
|
||
|
||
// REQ:从导航(或 ⌘K)换主题时偏好页开着,选项要跟着变,不能还显示旧值
|
||
const resolvedBefore = await page.evaluate(() => {
|
||
const a = document.documentElement.getAttribute('data-theme')
|
||
return a === 'light' || a === 'dark' ? a : (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
|
||
})
|
||
await page.getByRole('button', { name: /^切换到(浅色|深色)主题$/ }).click()
|
||
await page.waitForTimeout(500)
|
||
const synced = await checkedTheme()
|
||
check('从导航换主题:偏好页的主题选项跟着变', synced.includes(resolvedBefore === 'dark' ? '浅色' : '深色'), synced)
|
||
await themeRadio(label).click()
|
||
await page.waitForTimeout(500)
|
||
|
||
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())
|
||
|
||
const putsBefore = sent.filter((s) => s.key === 'PUT /settings').length
|
||
await page.getByRole('button', { name: /保存设置/ }).click()
|
||
await page.waitForTimeout(250)
|
||
check('只改署名:存在本机就行,不发 PUT', sent.filter((s) => s.key === 'PUT /settings').length === putsBefore)
|
||
check('保存后说「已保存」', (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).includes('已保存'))
|
||
check('署名写进本机', await page.evaluate(() => localStorage.getItem('agentlab_actor')) === '检查脚本')
|
||
const initial = await page.locator('nav a[href="/settings/prefs"]').first().innerText().catch(() => '')
|
||
check('……导航底部的署名首字立刻换成「检」(同一个标签页也收得到)', initial.trim() === '检', initial)
|
||
|
||
await page.locator('#pref-confirm-hint').locator('xpath=ancestor::label//input').click()
|
||
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('没有原生对话框', 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.locator('#pref-confirm-hint').locator('xpath=ancestor::label//input').click()
|
||
await failing.page.getByRole('button', { name: /保存设置/ }).click()
|
||
await failing.page.waitForTimeout(600)
|
||
const bar = await failing.page.locator('[data-prefs-bar]').innerText()
|
||
check('保存失败:条上写「没存上」', bar.includes('没存上'))
|
||
check('……署名只在本机,已经存上了;条上只剩危险工具审批一项', bar.includes('有 1 项未保存') && bar.includes('危险工具审批') && !bar.includes('署名'), bar.replace(/\s+/g, ' '))
|
||
check('保存失败不抛未捕获异常', failing.errors.length === 0, failing.errors[0] ?? '')
|
||
await failing.close()
|
||
|
||
// 断线时在偏好页换的主题:先在本机生效,恢复后自动补存,而不是被服务端的旧值翻回去。
|
||
// 断线要断得像真的:/health 也够不着,catalog 才会判断开、恢复时才算「重连」
|
||
let offline = false
|
||
let puts = 0
|
||
let stored2 = await get('/settings')
|
||
const cut = (route) => route.abort('internetdisconnected')
|
||
const off = await open('/settings/prefs', {
|
||
handlers: [
|
||
[/^GET \/settings$/, (route) => (offline ? cut(route) : json(stored2)(route))],
|
||
[/^PUT \/settings$/, (route, { body }) => {
|
||
puts++
|
||
if (offline) return cut(route)
|
||
stored2 = { ...stored2, ...body.values }
|
||
return json(stored2)(route)
|
||
}],
|
||
[/^GET \//, (route) => (offline ? cut(route) : route.continue())],
|
||
],
|
||
})
|
||
const offRadio = (l) => off.page.getByRole('radiogroup', { name: '主题' }).getByRole('radio', { name: l })
|
||
const target = (await offRadio('浅色').getAttribute('aria-checked')) === 'true' ? ['深色', 'dark'] : ['浅色', 'light']
|
||
offline = true
|
||
await offRadio(target[0]).click()
|
||
await off.page.waitForTimeout(800)
|
||
check('断线时换主题:说清先在本机生效、连上后自动存', (await off.page.locator('body').innerText()).includes('连上后端后自动存进设置'))
|
||
check('……选项和页面都已经是新主题', (await offRadio(target[0]).getAttribute('aria-checked')) === 'true'
|
||
&& await off.page.evaluate(() => document.documentElement.getAttribute('data-theme')) === target[1])
|
||
const down = await until(() => off.page.locator('[data-offline-banner]').count().then((n) => n > 0), 6000)
|
||
check('……整站也判了断开(横幅出来了)', !!down)
|
||
const putsOffline = puts
|
||
offline = false
|
||
await until(() => stored2?.ui?.theme === target[1], 20000)
|
||
await off.page.waitForTimeout(400)
|
||
check('……后端恢复后自动补存这个主题', puts > putsOffline && stored2?.ui?.theme === target[1], `PUT ${puts} 次,存的是 ${stored2?.ui?.theme}`)
|
||
check('……没有被服务端的旧值翻回去', await off.page.evaluate(() => document.documentElement.getAttribute('data-theme')) === target[1]
|
||
&& (await offRadio(target[0]).getAttribute('aria-checked')) === 'true')
|
||
await off.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')
|
||
const noCheck = { last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null }
|
||
// 假库一:配置里的 schema 探不出对象;后端记着 2 小时前测连接没通过。顺带是个免密库
|
||
// (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', sslmode: 'disable' }, readonly: true, description: '检查脚本的假库', enabled: true,
|
||
password_masked: '', has_password: false, table_count: 0, schema_synced_at: null, cached_schema: 'ods',
|
||
schema_error: 'NoSuchTableError: ods', available_schemas: ['mes', 'ods', 'public'], tools: ['db_query__zz_probe'],
|
||
last_checked_at: ago(2 * 3600_000), last_check_ok: false, last_latency_ms: null, last_error: '连不上:认证失败',
|
||
}
|
||
// 假库二:配置改成了 sales,缓存还是按 legacy 探的——助手看到的和配置对不上
|
||
const drifted = {
|
||
...empty, ...noCheck, id: 'check-manage-drift', name: 'zz_drift', options: { schema: 'sales' }, cached_schema: 'legacy',
|
||
table_count: 2, schema_error: '', available_schemas: [], schema_synced_at: ago(3600_000), tools: ['db_query__zz_drift'],
|
||
}
|
||
let cur = empty
|
||
let drift = drifted
|
||
const { page, sent, natives, errors, close } = await open('/data/databases', {
|
||
handlers: [
|
||
[/^GET \/datasources$/, (route) => json([...sources, cur, drift])(route)],
|
||
[/^GET \/datasources\/check-manage-empty\/schema$/, json({ tables: ['mes.work_order', 'mes.line', 'mes.shift'], summary: '', synced_at: ago(0) })],
|
||
[/^GET \/datasources\/check-manage-drift\/schema$/, json({ tables: ['sales.orders', 'sales.sales_daily'], summary: '', synced_at: ago(0) })],
|
||
[/^POST \/datasources\/check-manage-empty\/introspect$/, (route, { url }) => {
|
||
const schema = url.searchParams.get('schema')
|
||
if (url.searchParams.get('dry_run') === 'true') {
|
||
return json(schema === 'mes'
|
||
? { dry_run: true, schema: 'mes', table_count: 3, tables: ['mes.work_order', 'mes.line', 'mes.shift'], truncated: false, total: 3, schema_error: '', available_schemas: empty.available_schemas }
|
||
: { dry_run: true, schema, table_count: 0, tables: [], truncated: false, total: 0, schema_error: '', available_schemas: empty.available_schemas })(route)
|
||
}
|
||
const hit = cur.options.schema === 'mes'
|
||
cur = { ...cur, cached_schema: cur.options.schema ?? '', table_count: hit ? 3 : 0, schema_error: hit ? '' : empty.schema_error,
|
||
schema_synced_at: hit ? ago(0) : cur.schema_synced_at }
|
||
return json(cur)(route)
|
||
}],
|
||
[/^POST \/datasources\/check-manage-drift\/introspect$/, (route) => {
|
||
drift = { ...drift, cached_schema: drift.options.schema, schema_synced_at: ago(0) }
|
||
return json(drift)(route)
|
||
}],
|
||
// 后端的规矩:连接配置(地址、账号、库)改了,上次测连接的结果作废
|
||
[/^PATCH \/datasources\/check-manage-empty$/, (route, { body }) => {
|
||
const reconnect = ['host', 'port', 'database', 'username'].some((k) => k in (body ?? {}) && body[k] !== cur[k]) || 'password' in (body ?? {})
|
||
cur = { ...cur, ...body, options: body?.options ?? cur.options, ...(reconnect ? noCheck : {}) }
|
||
return json(cur)(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 看看」只看不存,好好的库也给;单文件的 SQLite 没有 schema 可换
|
||
check(first.kind === 'sqlite' ? 'SQLite 不给「换个 schema 看看」' : '结构正常的库也能「换个 schema 看看」(只看不存,没有风险)',
|
||
await card.locator('[data-probe-schema]').count() === (first.kind === 'sqlite' ? 0 : 1))
|
||
|
||
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')))
|
||
}
|
||
|
||
{
|
||
const ec = page.locator(`[data-source="${empty.name}"]`)
|
||
const pill0 = await ec.locator('[data-health]').innerText()
|
||
check('卡片初值是后端记着的上次测连接:连不上 · 2 小时前测', /连不上.*2 小时前测/.test(pill0), pill0)
|
||
check('……原因写在卡片上', (await ec.innerText()).includes('认证失败'))
|
||
|
||
// 换个 schema 看看:只看不存(dry_run),缓存和配置都不动
|
||
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('缓存和配置都不动'), 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 和 dry_run=true(不写缓存)', !!probeReq?.url.includes('schema=mes') && probeReq.url.includes('dry_run=true'), probeReq?.url ?? '')
|
||
const ask = dialog(page)
|
||
const askText = await ask.innerText().catch(() => '')
|
||
check('……探到了再问要不要写进配置,并举出探到的表名', askText.includes('mes') && askText.includes('work_order'), askText.slice(0, 160))
|
||
check('……「不改」说的是什么都不动,不再「换回去」', askText.includes('什么都不动') && !askText.includes('换回'), askText.slice(0, 200))
|
||
await shot(page, 'datasource-probe-schema')
|
||
const before = sent.length
|
||
await ask.getByRole('button', { name: '不改', exact: true }).click()
|
||
await page.waitForTimeout(500)
|
||
check('……不改:一个请求都不再发(以前要再探一次把缓存换回来)', sent.length === before, sent.slice(before).map((r) => r.url).join(' | '))
|
||
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(600)
|
||
const patch = sent.filter((s) => s.key === 'PATCH /datasources/check-manage-empty').at(-1)
|
||
check('……选「改成 mes」:PATCH 写进 options.schema,别的连接参数原样带上',
|
||
patch?.body?.options?.schema === 'mes' && patch?.body?.options?.sslmode === 'disable', JSON.stringify(patch?.body ?? {}))
|
||
const real = sent.filter((s) => s.key.endsWith('check-manage-empty/introspect')).at(-1)
|
||
check('……再按新配置真探一次(不带 dry_run)', !!real && !real.url.includes('dry_run') && !real.url.includes('schema='), real?.url ?? '')
|
||
const ecText = await ec.innerText()
|
||
check('……卡片换成新 schema 的结构', /3\s*个对象/.test(ecText) && !ecText.includes('上次探查失败'), ecText.replace(/\s+/g, ' ').slice(0, 160))
|
||
|
||
// 本机刚测的比后端记的新;只改说明不动它,改了连接配置就作废
|
||
await ec.getByRole('button', { name: /测连接/ }).click()
|
||
await page.waitForTimeout(500)
|
||
check('本机刚测过:卡片换成本机的结果', /已连通.*42 ms/.test(await ec.locator('[data-health]').innerText()))
|
||
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))
|
||
check('……只改说明:刚测的结果还作数', /已连通/.test(await ec.locator('[data-health]').innerText()))
|
||
await ec.getByRole('button', { name: '编辑' }).click()
|
||
await dialog(page).getByLabel(/主机/).fill('10.0.0.10')
|
||
await dialog(page).getByRole('button', { name: '保存' }).click()
|
||
await page.waitForTimeout(400)
|
||
const after = await ec.locator('[data-health]').innerText()
|
||
check('……换了主机:之前测的不再代表它,回到「未测试」', after.includes('未测试'), after)
|
||
|
||
// 缓存和配置对不上:常驻一行 warn,一键按配置重探
|
||
const dc = page.locator(`[data-source="${drifted.name}"]`)
|
||
const driftText = await dc.locator('[data-schema-drift]').innerText().catch(() => '')
|
||
check('缓存按 legacy 探、配置写着 sales:卡片上常驻一行说对不上', driftText.includes('legacy') && driftText.includes('sales'), driftText)
|
||
await shot(page, 'datasource-drift')
|
||
await dc.getByRole('button', { name: '按配置重新探查' }).click()
|
||
await page.waitForTimeout(500)
|
||
const re = sent.filter((s) => s.key === 'POST /datasources/check-manage-drift/introspect').at(-1)
|
||
check('……点「按配置重新探查」:按配置真探(不带 schema、不带 dry_run)', !!re && !re.url.includes('?'), re?.url ?? '')
|
||
check('……探完那行 warn 就消失', await dc.locator('[data-schema-drift]').count() === 0)
|
||
}
|
||
|
||
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', {
|
||
uploadProgress: true,
|
||
handlers: [
|
||
[/^POST \/datasources\/upload$/, delayed(400, fake, 201)],
|
||
// 传上来的表只有一两张,卡片会自动摊开结构:假的那张也得有结构可取
|
||
// 列注释里带两个空格:以前解析 detail 文本(按两个以上空格切列),「见附表」会被切丢
|
||
[/^GET \/datasources\/check-manage-fake\/schema$/, (route, { url }) => json(url.searchParams.get('table')
|
||
? {
|
||
table: 'sales_demo', found: true, qualified: 'sales_demo', kind: 'table', comment: '按月的销售明细',
|
||
detail: '表 sales_demo(按月的销售明细)\n 2026-01 REAL\n region TEXT 主键、非空、销售区域 见附表\n Unnamed: 2 TEXT',
|
||
columns: [
|
||
{ name: '2026-01', type: 'REAL', pk: false, not_null: false, comment: null },
|
||
{ name: 'region', type: 'TEXT', pk: true, not_null: true, comment: '销售区域 见附表' },
|
||
{ name: 'Unnamed: 2', type: 'TEXT', pk: false, not_null: false, comment: null },
|
||
],
|
||
}
|
||
: { 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(250)
|
||
check('导入中按钮写已用时间', /导入中/.test(await dlg.getByRole('button', { name: /导入中/ }).innerText().catch(() => '')))
|
||
const sending = await dlg.locator('[data-upload-progress]').innerText().catch(() => '')
|
||
check('上传中画真实的字节进度:已传多少 / 总共多少 · 百分比', sending.includes('已传') && sending.includes('40%')
|
||
&& (await dlg.locator('[data-upload-progress] [role="progressbar"]').getAttribute('aria-valuenow').catch(() => '')) === '40',
|
||
sending.replace(/\s+/g, ' '))
|
||
check('……字节没发完之前可以取消', await dlg.getByRole('button', { name: '取消上传' }).count() === 1)
|
||
await shot(page, 'table-uploading')
|
||
await page.waitForTimeout(650)
|
||
const processing = await dlg.locator('[data-upload-progress]').innerText().catch(() => '')
|
||
check('……发完写「正在读表」,不把条拉满冒充完成', processing.includes('正在读表')
|
||
&& await dlg.locator('[data-upload-progress] [role="progressbar"]').count() === 0, processing.replace(/\s+/g, ' '))
|
||
const cancel = dlg.getByRole('button', { name: '取消', exact: true })
|
||
check('……发完之后不能取消(后端已经在建表),悬停说明为什么',
|
||
await cancel.isDisabled() && ((await cancel.getAttribute('title')) ?? '').includes('传完'))
|
||
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()
|
||
await page.waitForTimeout(250)
|
||
await dialog(page).getByRole('button', { name: '取消上传' }).click()
|
||
await page.waitForTimeout(900)
|
||
check('发到一半取消:说清什么都没建,弹窗留在表单上', (await page.locator('body').innerText()).includes('没传完就取消了')
|
||
&& await dialog(page).getByRole('button', { name: /导入/ }).isEnabled())
|
||
await dialog(page).getByRole('button', { name: '取消' }).click()
|
||
await page.waitForTimeout(300)
|
||
// 结构浏览器的列清单用后端给的结构化列,不再解析 detail 文本
|
||
const tcard = page.locator('[data-source="sales_demo"]')
|
||
await tcard.locator('[data-schema-browser] button[aria-expanded]').first().click()
|
||
await page.waitForTimeout(500)
|
||
const region = tcard.locator('[data-schema-browser] tbody tr', { hasText: 'region' })
|
||
const cells = await region.locator('td').allInnerTexts()
|
||
check('列清单:类型、主键、非空、说明各归各的格子', cells.length === 3 && cells[1] === 'TEXT'
|
||
&& await region.locator('[aria-label="主键"]').count() === 1 && cells[2].includes('非空'), cells.join(' | '))
|
||
check('……列注释里的两个空格不再把说明切丢(「见附表」还在)', cells[2]?.includes('销售区域') && cells[2]?.includes('见附表'), cells[2] ?? '')
|
||
check('……表注释写在列表上方', (await tcard.locator('[data-schema-browser]').innerText()).includes('按月的销售明细'))
|
||
await shot(page, 'table-columns')
|
||
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', {
|
||
uploadProgress: true,
|
||
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') },
|
||
{ name: 'more.md', mimeType: 'text/markdown', buffer: Buffer.from('# more') },
|
||
])
|
||
await page.waitForTimeout(300)
|
||
check('上传中列表顶上有占位行,一份一行', await page.locator('[data-uploading]').count() === 2)
|
||
check('上传按钮写着上传中', (await page.getByRole('button', { name: /上传中/ }).count()) === 1)
|
||
const first = page.locator('[data-uploading]', { hasText: 'note.md' })
|
||
const sendingRow = await first.innerText()
|
||
check('……在传的那份画真实的字节进度', sendingRow.includes('已传') && sendingRow.includes('40%')
|
||
&& await first.locator('[role="progressbar"]').count() === 1, sendingRow.replace(/\s+/g, ' '))
|
||
const second = page.locator('[data-uploading]', { hasText: 'more.md' })
|
||
check('……第二份写「排队中」,还能取消', (await second.innerText()).includes('排队中')
|
||
&& await second.getByRole('button', { name: '取消上传 more.md' }).count() === 1)
|
||
await shot(page, 'kb-uploading')
|
||
await second.getByRole('button', { name: '取消上传 more.md' }).click()
|
||
await page.waitForTimeout(200)
|
||
check('……排队的取消了立刻从列表拿掉', await page.locator('[data-uploading]', { hasText: 'more.md' }).count() === 0)
|
||
await page.waitForTimeout(500)
|
||
const processingRow = await first.innerText()
|
||
check('……字节发完写「处理中」(后端在解析、切块),不把条拉满冒充完成',
|
||
processingRow.includes('后端在解析、切块') && await first.locator('[role="progressbar"]').count() === 0,
|
||
processingRow.replace(/\s+/g, ' '))
|
||
check('……发完的那份不给取消(后端已经在切块)', await page.getByRole('button', { name: '取消上传 note.md' }).count() === 0)
|
||
await page.waitForTimeout(1500)
|
||
check('传完占位行消失', await page.locator('[data-uploading]').count() === 0)
|
||
check('……取消的那份没有发出去', sent.filter((s) => s.key === 'POST /kb/upload').length === 1)
|
||
|
||
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 hits = {
|
||
query: '出勤', collection: 'default', degraded: [], alpha: embedding.default_alpha ?? 0.5,
|
||
results: [
|
||
{ chunk_id: 'c1', document_id: 'd1', title: 'hr_attendance.md', ordinal: 3, content: '出勤率 = 实际出勤 / 计划出勤',
|
||
score: 0.42, signals: { vector: 0.6, keyword: 3.21 }, contrib: { vector: 0.3, keyword: 0.12 } },
|
||
{ chunk_id: 'c2', document_id: 'd1', title: 'hr_attendance.md', ordinal: 4, content: '计划出勤按排班表算',
|
||
score: 0.25, signals: { vector: 0.5 }, contrib: { vector: 0.25, keyword: 0 } },
|
||
],
|
||
}
|
||
const job = (phase, chunks, memories, state = 'running') => ({
|
||
id: 'check-job', state, phase, collection: 'default', total: 15, done: chunks + memories,
|
||
chunks: { total: 12, done: chunks }, memories: { total: 3, done: memories },
|
||
started_at: ago(4000), finished_at: state === 'running' ? null : ago(0), error: null, hint: null,
|
||
result: state === 'done' ? { reindexed: 12, memories_reindexed: 3, embedder: 'openai:demo-embed' } : null,
|
||
})
|
||
// GET /kb/reindex:点重建之前没在跑(开发模式下进页那一眼会问两次);之后依次是
|
||
// 片段 → 倒排 → 记忆 → 做完
|
||
const steps = [job('chunks', 6, 0), job('index', 12, 0), job('memories', 12, 2), job('done', 12, 3, 'done')]
|
||
let started = false
|
||
let polled = 0
|
||
let rebuilt = false
|
||
const { page, sent, natives, errors, close } = await open('/knowledge/kb', {
|
||
handlers: [
|
||
[/^GET \/kb\/search$/, json(hits)],
|
||
[/^GET \/kb\/embedding$/, (route) => json({ ...embedding, fallback: false, has_semantics: true, unindexed_chunks: 0,
|
||
stale_chunks: rebuilt ? 0 : 12, stale_memories: rebuilt ? 0 : 3 })(route)],
|
||
[/^POST \/kb\/reindex$/, (route) => { started = true; return json(job('chunks', 0, 0), 202)(route) }],
|
||
[/^GET \/kb\/reindex$/, (route) => {
|
||
if (!started) return json({ state: 'idle' })(route)
|
||
const out = steps[Math.min(polled++, steps.length - 1)]
|
||
if (out.state === 'done') rebuilt = true
|
||
return json(out)(route)
|
||
}],
|
||
],
|
||
})
|
||
await page.getByLabel('检索内容').fill('出勤')
|
||
await page.getByRole('button', { name: '检索', exact: true }).click()
|
||
await page.waitForTimeout(400)
|
||
const hit = page.locator('[data-hit]').first()
|
||
const contrib = await hit.locator('[data-contrib]').innerText().catch(() => '')
|
||
check('命中拆成两路贡献:语义 +0.300、关键词 +0.120(两段之和就是总分)',
|
||
/语义\s*\+0\.300/.test(contrib) && /关键词\s*\+0\.120/.test(contrib), contrib.replace(/\s+/g, ' '))
|
||
check('……原始分另写成数:余弦、BM25', contrib.includes('余弦') && contrib.includes('0.600') && contrib.includes('3.21'))
|
||
const segs = await hit.locator('span[title^="语义贡献"] > span').count()
|
||
const segs2 = await page.locator('[data-hit]').nth(1).locator('span[title^="语义贡献"] > span').count()
|
||
check('……贡献条两段;只靠语义命中的那条只有一段', segs === 2 && segs2 === 1, `${segs} / ${segs2}`)
|
||
check('……结果上方有图例:总分 = 语义贡献 + 关键词贡献', (await page.locator('[data-contrib-legend]').innerText().catch(() => '')).includes('关键词贡献'))
|
||
await shot(page, 'kb-search-contrib')
|
||
|
||
const rebuild = page.getByRole('button', { name: /重建索引(15 段)/ })
|
||
check('向量对不上时给「重建索引(15 段)」', await rebuild.count() === 1,
|
||
(await page.locator('[data-embedder]').innerText().catch(() => '')).replace(/\s+/g, ' ').slice(0, 160))
|
||
await rebuild.click()
|
||
const post = sent.find((s) => s.key === 'POST /kb/reindex')
|
||
check('重建走后台:POST 带 background=true', !!post?.url.includes('background=true'), post?.url ?? '')
|
||
const chunksText = await until(async () => {
|
||
const t = await page.locator('[data-reindex="chunks"]').innerText().catch(() => '')
|
||
return t.includes('6 / 12') ? t : ''
|
||
}, 4000)
|
||
check('进度是后端按批记的真实数:正在重算知识片段的向量 · 6 / 12 段', !!chunksText, chunksText)
|
||
check('……进度条的值就是 done / total', (await page.locator('[data-reindex] [role="progressbar"]').getAttribute('aria-valuenow').catch(() => '')) === '6')
|
||
check('……跑着的时候不能换向量模型', await page.getByRole('button', { name: '换一个' }).isDisabled())
|
||
await shot(page, 'kb-reindex-progress')
|
||
const indexText = await until(() => page.locator('[data-reindex="index"]').innerText().catch(() => ''), 4000)
|
||
check('倒排那一段没有分批进度:写明,不去假装它在走', indexText.includes('没有分批进度'), indexText.replace(/\s+/g, ' '))
|
||
const memText = await until(() => page.locator('[data-reindex="memories"]').innerText().catch(() => ''), 4000)
|
||
check('……接着重算记忆:2 / 3 条', memText.includes('2 / 3 条'), memText.replace(/\s+/g, ' '))
|
||
const doneToast = await until(async () => {
|
||
const t = await page.locator('body').innerText()
|
||
return t.includes('重建 12 段知识') ? t : ''
|
||
}, 4000)
|
||
check('做完了报一句:用哪个模型重建了多少', !!doneToast && doneToast.includes('openai:demo-embed') && doneToast.includes('3 条记忆'))
|
||
check('……进度收起,重建按钮也跟着消失(不再有对不上的)',
|
||
await page.locator('[data-reindex]').count() === 0 && await page.getByRole('button', { name: /重建索引/ }).count() === 0)
|
||
check('没有原生对话框', natives.length === 0, natives.join(' | '))
|
||
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
|
||
await close()
|
||
|
||
// 已经有一次在跑:回 409 就说明,并接着看它的进度,不起第二次
|
||
let other = false
|
||
const busy = await open('/knowledge/kb', {
|
||
handlers: [
|
||
[/^GET \/kb\/embedding$/, json({ ...embedding, fallback: false, has_semantics: true, unindexed_chunks: 0, stale_chunks: 12, stale_memories: 3 })],
|
||
[/^POST \/kb\/reindex$/, (route) => { other = true; return json({ detail: '已经在重建索引了(全部集合,已完成 3/15),等它做完再点' }, 409)(route) }],
|
||
// 进页那一眼还没在跑;点了之后别处起的那一次才露面
|
||
[/^GET \/kb\/reindex$/, (route) => json(other ? { ...job('chunks', 3, 0), collection: null } : { state: 'idle' })(route)],
|
||
],
|
||
})
|
||
await busy.page.getByRole('button', { name: /重建索引(15 段)/ }).click()
|
||
await busy.page.waitForTimeout(600)
|
||
check('已经在重建:回 409 时说明已经在跑', (await busy.page.locator('body').innerText()).includes('已经在重建索引了'))
|
||
check('……接着显示那一次的进度', ((await busy.page.locator('[data-reindex]').innerText().catch(() => '')).includes('3 / 12')))
|
||
check('……只发了一次 POST,不起第二次', busy.sent.filter((s) => s.key === 'POST /kb/reindex').length === 1)
|
||
await busy.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=== 目录没取回来:空态不冒充「还没有」 ===')
|
||
for (const [path, pattern, label] of [
|
||
['/settings/providers', /^GET \/providers$/, '模型接入'],
|
||
['/knowledge/skills', /^GET \/skills$/, 'Skill'],
|
||
]) {
|
||
const { page, close } = await open(path, { handlers: [[pattern, json({ detail: '后端出错了' }, 500)]] })
|
||
await page.waitForTimeout(600)
|
||
const el = page.locator('main [data-empty-unknown]')
|
||
check(`${label}那张表没取回来:不说「还没有」,说没取回来并给「重新读取」`,
|
||
(await el.getAttribute('data-empty-unknown').catch(() => null)) === 'error' && (await el.innerText().catch(() => '')).includes('重新读取'))
|
||
check('……收起「添加 / 新建」这类动作,免得照着建出重复的', await el.getByRole('button', { name: /添加接入|新建 Skill/ }).count() === 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, note: '参数名 cuont 不存在,已按唯一候选 count 执行' })(route)
|
||
: json({ detail: '会在 playground 工作目录里写入「x.txt」,同名文件会被覆盖。在工具库里执行不经过审批,确认后才会执行。' }, 409)(route)
|
||
}],
|
||
[/^POST \/mcp\/refresh$/, (route) => route.abort()],
|
||
[/^POST \/custom-tools\/test$/, (route, { body }) => json(body?.config?.url?.includes('fail')
|
||
? { ok: false, error: '工具执行失败:HTTP 404', hint: '核对接口地址', detail: '', duration_ms: 12 }
|
||
: { ok: true, result: { echo: body?.args ?? {} }, duration_ms: 34, note: '参数名 qurey 不存在,已按唯一候选 query 执行' })(route)],
|
||
// 沙箱里没有 MCP 服务:两台假的,一台后端记着 5 分钟前连上过,一台老数据只有 status、没有测连接记录
|
||
[/^GET \/mcp\/servers$/, json([
|
||
{ id: 'check-mcp-ok', name: 'files_demo', transport: 'stdio', command: 'npx', args: ['-y', 'demo-mcp'], env: {}, url: null,
|
||
enabled: true, status: 'ok', last_error: null, tools_cache: ['read', 'list'],
|
||
last_checked_at: ago(5 * 60_000), last_check_ok: true, last_latency_ms: 88 },
|
||
{ id: 'check-mcp-old', name: 'legacy_demo', transport: 'http', command: null, args: [], env: {}, url: 'https://example.com/mcp',
|
||
enabled: true, status: 'error', last_error: '连不上', tools_cache: [],
|
||
last_checked_at: null, last_check_ok: null, last_latency_ms: null },
|
||
])],
|
||
[/^POST \/mcp\/servers\/check-mcp-ok\/probe$/, delayed(200, { ok: true, tools: [{ name: 'read' }, { name: 'list' }, { name: 'write' }], elapsed_ms: 120 })],
|
||
],
|
||
})
|
||
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')
|
||
const note = await page.locator('[data-tool-note]').innerText().catch(() => '')
|
||
check('参数名被纠正过:成功结果下面用 warn 写出来,提醒先改对再抄进工作流', note.includes('cuont') && note.includes('改对'), note)
|
||
}
|
||
await page.getByRole('tab', { name: '自定义工具' }).click()
|
||
await page.waitForTimeout(400)
|
||
await page.getByRole('button', { name: /新建工具/ }).first().click()
|
||
const ed = dialog(page)
|
||
const params = await ed.locator('textarea').nth(1).inputValue()
|
||
check('新建工具的参数给了能跑的示例,不是 {}', params.includes('"query"'), params.slice(0, 60))
|
||
const trialBox = ed.locator('[data-tool-trial]')
|
||
check('新建时不用先保存就能试跑', (await trialBox.innerText()).includes('不用先保存'))
|
||
const tryBtn = trialBox.getByRole('button', { name: /试跑/ }).last()
|
||
check('……URL 还没填时试跑不可点,悬停说缺什么', await tryBtn.isDisabled() && ((await tryBtn.getAttribute('title')) ?? '').includes('URL'))
|
||
await ed.getByLabel(/^URL/).fill('https://api.example.com/search?q={{ query }}')
|
||
await tryBtn.click()
|
||
await page.waitForTimeout(400)
|
||
const draft = sent.filter((s) => s.key === 'POST /custom-tools/test').at(-1)
|
||
check('……试跑的是眼前这份没保存的配置(POST /custom-tools/test)',
|
||
draft?.body?.kind === 'http' && draft?.body?.config?.url?.includes('api.example.com') && 'query' in (draft?.body?.parameters?.properties ?? {})
|
||
&& 'args' in (draft?.body ?? {}), JSON.stringify(draft?.body ?? {}).slice(0, 160))
|
||
check('……没有保存(没发 POST /custom-tools)', !sent.some((s) => s.key === 'POST /custom-tools'))
|
||
const trialNote = await trialBox.locator('[data-tool-note]').innerText().catch(() => '')
|
||
check('……试跑结果里参数名被纠正过也写出来', trialNote.includes('qurey'), trialNote)
|
||
await shot(page, 'custom-tool-trial')
|
||
await ed.getByLabel(/^URL/).fill('https://api.example.com/fail')
|
||
check('……改了配置,刚才的结果标成旧的', (await trialBox.innerText()).includes('这是改之前的结果'))
|
||
await tryBtn.click()
|
||
await page.waitForTimeout(400)
|
||
check('……失败:原因和怎么办写在试跑区', (await trialBox.innerText()).includes('HTTP 404') && (await trialBox.innerText()).includes('核对接口地址'))
|
||
await ed.getByRole('button', { name: '取消' }).click()
|
||
await page.waitForTimeout(200)
|
||
const discard = dialog(page).getByRole('button', { name: '放弃修改' })
|
||
if (await discard.count()) await discard.click()
|
||
|
||
await page.getByRole('tab', { name: 'MCP 接入' }).click()
|
||
await page.waitForTimeout(400)
|
||
const okCard = page.locator('article', { hasText: 'files_demo' })
|
||
const okPill = await okCard.locator('[data-health]').innerText().catch(() => '')
|
||
check('MCP 卡片初值是后端记着的上次探测:已连通 · 88 ms · 5 分钟前测', /已连通.*88 ms.*5 分钟前测/.test(okPill), okPill)
|
||
check('……只有 status、没有测连接记录的老数据写「未测试」,不再拿 status 猜',
|
||
(await page.locator('article', { hasText: 'legacy_demo' }).locator('[data-health]').getAttribute('data-health')) === 'idle')
|
||
await okCard.getByRole('button', { name: /测试/ }).click()
|
||
await page.waitForTimeout(500)
|
||
const probed = await okCard.locator('[data-health]').innerText()
|
||
check('……测一次:用后端量的耗时(120 ms),不是往返时间', /已连通.*120 ms.*刚测过/.test(probed), probed)
|
||
await shot(page, 'mcp-health')
|
||
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)
|