// 管理页(工具 / 知识 / 数据 / 设置)的交互回归。 // // 这几页的毛病都不在"渲染不出来",而在交互的细处:写操作失败一声不响、中文 // 输入法选词的回车把半句话写进长期记忆、原生 confirm 不说后果、测完连接只剩 // 4 秒 toast、Oracle 的 service_name 绑错了字段、传完表格回显一帧都看不到…… // 页面级的 check-ui 走的都是正常路径,这些一个字都不会说。 // // 不写库:GET 放行到后端(读真实数据),其余一律 page.route 拦下伪造——断言 // 请求体对不对,再回一个像样的响应。所以对哪个后端跑都不会改数据。 // 跑之前前后端都得起着(./scripts/dev.sh),默认连 5273 / 8000。对别的实例(比如一份 // 沙箱拷贝)跑时带上地址: // AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/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) } } /** * 上传表格时表单里的文本字段(mixed、raw_mode、header_row……)逐次记到 window.__uploadForms。 * page.route 拿到的 multipart 请求体按原文切太脆(文件那一段 Chrome 也不一定给),在页面里 * 直接读 FormData 最稳。和 fakeUploadProgress 谁先装都行:两边都只是包一层 send */ function recordUploadForms() { const open = XMLHttpRequest.prototype.open const send = XMLHttpRequest.prototype.send window.__uploadForms = [] XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__formUrl = String(url) return open.call(this, method, url, ...rest) } XMLHttpRequest.prototype.send = function (body) { if (/\/datasources\/upload(\?|$)/.test(this.__formUrl ?? '') && body instanceof FormData) { window.__uploadForms.push(Object.fromEntries([...body.entries()] .map(([k, v]) => [k, typeof v === 'string' ? v : ``]))) } return send.call(this, body) } } const uploadForms = (page) => page.evaluate(() => window.__uploadForms ?? []) /** * 开一页:GET 放行,写请求交给 handlers(按「METHOD 路径正则」匹配),没配的一律 * 拦成 503——既不写库,也顺带检验「写失败有反馈」。所有原生对话框都算失败。 */ async function open(path, { handlers = [], theme = THEME, viewport = { width: 1280, height: 860 }, uploadProgress = false, recordForms = false, } = {}) { const ctx = await browser.newContext({ viewport, colorScheme: theme, timezoneId: 'Asia/Shanghai' }) opened.add(ctx) // 找不到元素就早点失败:默认 30 秒一项,一处点不到能拖住整节半分钟 ctx.setDefaultTimeout(6000) ctx.setDefaultNavigationTimeout(30000) await ctx.addInitScript((t) => { try { localStorage.setItem('agentlab.theme', t); localStorage.removeItem('agentlab.health') } catch { /* noop */ } }, theme) if (uploadProgress) await ctx.addInitScript(fakeUploadProgress) if (recordForms) await ctx.addInitScript(recordUploadForms) 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: () => { opened.delete(ctx); return 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 inView = (locator) => locator.evaluate((el) => { const r = el.getBoundingClientRect() const hit = document.elementFromPoint(r.left + Math.min(24, r.width / 2), r.top + r.height / 2) return !!hit && el.contains(hit) }).catch(() => false) const composingEnter = (locator) => locator.evaluate((el) => { el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 229, isComposing: true, bubbles: true, cancelable: true })) }) /** * 一节一节地跑。某个元素找不到(点击超时)以前会抛出未捕获的异常,整个脚本就停在 * 那里,后面各节一项都不跑——变异测试也就证明不了后面那些检查抓得住回归。现在 * 这一节记一项失败、关掉它开的页面,接着跑下一节。 * 只跑其中几节:CHECK_ONLY=知识库,工具 node scripts/check-manage.mjs(按节名包含匹配) */ const ONLY = process.env.CHECK_ONLY?.split(',').map((x) => x.trim()).filter(Boolean) const opened = new Set() async function section(name, fn) { if (ONLY?.length && !ONLY.some((k) => name.includes(k))) return console.log(`\n=== ${name} ===`) try { await fn() } catch (e) { check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0]) } finally { for (const c of opened) await c.close().catch(() => {}) opened.clear() } } 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) } await section('页面骨架:页头、标签、地址', async () => { 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() } }) await section('设置 · 模型接入', async () => { 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):服务方拒绝了当前 API Key', } : { ...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('服务方拒绝了当前 API Key')) 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() }) await section('设置 · 模型接入:后端校验没过', async () => { // FastAPI 的 422 是 pydantic 的英文原文 + 内部键名。弹窗上说中文、按表单叫法点字段,原文收进「详情」(验收 NEW) const detail = [{ type: 'missing', loc: ['body', 'name'], msg: 'Field required', input: {} }] const { page, sent, errors, close } = await open('/settings/providers', { handlers: [[/^POST \/providers$/, json({ detail }, 422)]], }) await page.getByRole('button', { name: /添加接入/ }).first().click() const dlg = dialog(page) await dlg.getByRole('radio', { name: /演示模型/ }).click() await dlg.getByRole('button', { name: '保存' }).click() const alert = page.locator('[role="alert"][aria-live="assertive"]').first() const said = await until(async () => { const t = await alert.innerText().catch(() => ''); return t.includes('不符合要求') ? t : '' }, 4000) check('保存发出去了', sent.some((s) => s.key === 'POST /providers')) check('toast 说中文:哪个字段、怎么了', said.includes('提交的内容不符合要求') && said.includes('「名称」为必填项'), said.replace(/\s+/g, ' ')) const summary = said.split('详情')[0] check('……英文原文和内部键名不在正文里', !/Field required|\bname\b/.test(summary), summary.replace(/\s+/g, ' ')) await alert.getByText('详情').first().click() const raw = await alert.locator('details pre').first().innerText().catch(() => '') check('……英文原文收在「详情」里,给维护者复制', raw.includes('Field required'), raw.slice(0, 80)) check('弹窗还开着,填的东西没丢', await dlg.isVisible()) await shot(page, 'provider-422') check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close() }) await section('设置 · 偏好', async () => { // 设置读写都走假的一份: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) // 细节只报组名和主题:settings 里别的组存着沙箱的真实配置,日志常被贴进报告 check('主题选中即保存,只 PUT ui 一组', !!put && Object.keys(put.body?.values ?? {}).join() === 'ui' && put.body.values.ui.theme === value, `组:${Object.keys(put?.body?.values ?? {}).join(',') || '(没发)'} · theme=${put?.body?.values?.ui?.theme ?? '—'}`) 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()) // 切标签只问一次:以前 change() 先问一遍,守卫在地址变化时又问一遍 const asking = () => page.locator('[role="dialog"]').filter({ hasText: '还没保存' }).count() await page.getByRole('tab', { name: '模型接入' }).click() await page.waitForTimeout(400) check('有改动时切标签:只弹一个确认框', await asking() === 1, `${await asking()} 个`) await dialog(page).getByRole('button', { name: '返回保存' }).click() await page.waitForTimeout(400) check('……选「留下」:标签和地址都不变,也没有第二个框冒出来', page.url().endsWith('/settings/prefs') && (await page.getByRole('tab', { name: '偏好设置' }).getAttribute('aria-selected')) === 'true' && await asking() === 0, page.url()) // ⌘K 和 ⌥ 数字不走 :以前页面自己在捕获阶段拦 的点击,这两条路拦不住 const mod = process.platform === 'darwin' ? 'Meta' : 'Control' await page.keyboard.press(`${mod}+KeyK`) await page.getByRole('dialog', { name: '命令面板' }).getByRole('combobox').fill('工具') await page.keyboard.press('Enter') await page.waitForTimeout(400) check('⌘K 跳页也先问', await asking() === 1 && page.url().endsWith('/settings/prefs'), page.url()) await dialog(page).getByRole('button', { name: '返回保存' }).click() await page.waitForTimeout(300) await page.evaluate(() => document.activeElement?.blur()) await page.keyboard.press('Alt+Digit1') await page.waitForTimeout(400) check('⌥1 切页也先问', await asking() === 1 && page.url().endsWith('/settings/prefs'), page.url()) await dialog(page).getByRole('button', { name: '返回保存' }).click() await page.waitForTimeout(300) check('……都选「留下」:改动还在', (await page.locator('#pref-actor').inputValue()) === '检查脚本') 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) // run 组里是默认知识库、记忆作用域这些沙箱里的真实名字:只报组名 check('改了运行默认值:保存只 PUT run 一组', Object.keys(put2?.body?.values ?? {}).join() === 'run', `组:${Object.keys(put2?.body?.values ?? {}).join(',') || '(没发)'}`) // 有改动时切标签选「放弃修改」:一问就走,不再补问第二遍 await page.locator('#pref-actor').fill('再改一次') await page.getByRole('tab', { name: '运行环境' }).click() await page.waitForTimeout(400) await dialog(page).getByRole('button', { name: '放弃修改' }).click() await page.waitForTimeout(500) check('切标签选「放弃修改」:直接到运行环境,不再问第二遍', page.url().endsWith('/settings/system') && await asking() === 0, `${page.url()} · 还开着 ${await asking()} 个`) 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() }) await section('设置 · 门控模型', async () => { // 设置读写都走假的一份;接入列表也换成假的两家(日志里只出现这些通用名) let stored = await get('/settings') stored = { ...stored, run: { ...(stored.run ?? {}), tool_gate_provider: null, tool_gate_model: null } } const scope0 = stored.run.default_memory_scope ?? 'default' const PROVIDERS = [ { id: 'check-gate-p1', name: 'demo-main', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'big-model', models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true }, { id: 'check-gate-p2', name: 'demo-small', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'tiny-1', models: [{ id: 'tiny-1' }, { id: 'tiny-2' }], enabled: true, extra: {}, api_key_masked: '', has_key: true }, ] const { page, sent, errors, close } = await open('/settings/prefs', { handlers: [ [/^GET \/settings$/, (route) => json(stored)(route)], [/^PUT \/settings$/, (route, { body }) => { stored = { ...stored, ...body.values }; return json(stored)(route) }], [/^GET \/providers$/, json(PROVIDERS)], ], }) const box = page.locator('[data-gate-model]') const hint = await box.innerText().catch(() => '') check('运行默认值里有「门控模型」,提示留空用默认、建议选小模型', hint.includes('门控模型') && hint.includes('留空') && hint.includes('小模型'), hint.replace(/\s+/g, ' ').slice(0, 100)) const provider = page.getByLabel('门控模型 · 接入') const model = page.getByLabel('门控模型 · 模型') check('……留空时写明默认是哪家(第一个启用的接入)', (await provider.locator('option').first().innerText()).includes('demo-main')) await provider.selectOption('demo-small') await model.fill('tiny-2') const bar = await page.locator('[data-prefs-bar]').innerText().catch(() => '') check('……改了两项:保存条写「门控模型的接入、门控模型」', bar.includes('有 2 项未保存') && bar.includes('门控模型的接入') && bar.includes('门控模型'), bar.replace(/\s+/g, ' ')) await page.getByRole('button', { name: /保存设置/ }).click() await page.waitForTimeout(500) const put = sent.filter((s) => s.key === 'PUT /settings').at(-1) const run = put?.body?.values?.run ?? {} // run 组里别的键是沙箱的真实配置:只报门控这两项 check('保存只 PUT run 一组,写 tool_gate_provider / tool_gate_model', Object.keys(put?.body?.values ?? {}).join() === 'run' && run.tool_gate_provider === 'demo-small' && run.tool_gate_model === 'tiny-2', `provider=${run.tool_gate_provider ?? '—'} · model=${run.tool_gate_model ?? '—'}`) check('……其余运行默认值原样带着', run.default_memory_scope === scope0 && 'confirm_dangerous_tools' in run) await shot(page, 'prefs-gate-model') // 换接入:原来填的模型不在新接入里就清掉;都清空保存成 null(= 用默认接入的默认模型) await provider.selectOption('demo-main') check('换了接入,旧接入的模型清掉', await model.inputValue() === '', await model.inputValue()) await provider.selectOption('') await page.getByRole('button', { name: /保存设置/ }).click() await page.waitForTimeout(500) const run2 = sent.filter((s) => s.key === 'PUT /settings').at(-1)?.body?.values?.run ?? {} check('……两项都留空:存成 null', run2.tool_gate_provider === null && run2.tool_gate_model === null, `provider=${JSON.stringify(run2.tool_gate_provider)} · model=${JSON.stringify(run2.tool_gate_model)}`) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close() // 老后端:run 组里没有这两项,读进来就是空,不算改动 let legacy = await get('/settings') legacy = { ...legacy, run: Object.fromEntries(Object.entries(legacy.run ?? {}).filter(([k]) => !k.startsWith('tool_gate_'))) } const old = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(legacy)(route)], [/^GET \/providers$/, json(PROVIDERS)]] }) check('老后端没有这两项:显示空、没有保存条', await old.page.getByLabel('门控模型 · 模型').inputValue() === '' && await old.page.locator('[data-prefs-bar]').count() === 0) await old.close() }) await section('设置 · 智能体护栏', async () => { // 默认步数、token 预算、金额预算(后端 engine/guards.py)。预算留空 = 不限,存 null let stored = await get('/settings') stored = { ...stored, run: { ...(stored.run ?? {}), agent_max_steps: 100, agent_budget_tokens: 2000000, agent_budget_usd: null }, limits: { ...(stored.limits ?? {}), max_agent_steps: 100 } } const { page, sent, errors, 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 box = page.locator('[data-agent-guard]') const text = (await box.innerText().catch(() => '')).replace(/\s+/g, ' ') check('运行默认值里有「Agent 护栏」,说清不再被固定步数掐断', text.includes('Agent 护栏') && text.includes('基于已获取的信息收尾'), text.slice(0, 100)) const steps = page.getByLabel('默认最大步数') const tokens = page.getByLabel('token 预算(每个节点)') const usd = page.getByLabel('金额预算(美元)') check('……读回设置里的值:100 步、200 万 token、金额不限', await steps.inputValue() === '100' && await tokens.inputValue() === '2000000' && await usd.inputValue() === '') check('……金额留空写明「不限」', text.includes('不限:费用仅受最大步数和上下文长度约束')) await steps.fill('500') const save = page.getByRole('button', { name: /保存设置/ }) check('步数超过硬上限:标红、保存按钮不可用', await save.isDisabled() && (await box.innerText()).includes('请填写 1 到 100 之间的整数')) await steps.fill('60') await tokens.fill('') await usd.fill('1.5') const bar = (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).replace(/\s+/g, ' ') check('……改了三项:保存条写明是哪三项', bar.includes('有 3 项未保存') && bar.includes('默认最大步数') && bar.includes('token 预算') && bar.includes('金额预算'), bar) await save.click() await page.waitForTimeout(500) const run = sent.filter((s) => s.key === 'PUT /settings').at(-1)?.body?.values?.run ?? {} check('保存:步数写数字,token 留空存 null(不限),金额写数字', run.agent_max_steps === 60 && run.agent_budget_tokens === null && run.agent_budget_usd === 1.5, `steps=${JSON.stringify(run.agent_max_steps)} tokens=${JSON.stringify(run.agent_budget_tokens)} usd=${JSON.stringify(run.agent_budget_usd)}`) await tokens.fill('500') check('token 预算少于 1000:标红、不能保存', await save.isDisabled()) await shot(page, 'prefs-agent-guard') check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close() // 存着的步数比硬上限大(硬上限后来调小了):没动它就不挡别的设置保存,后端发起运行时自己会截 const tight = { ...stored, run: { ...stored.run, agent_max_steps: 100 }, limits: { ...stored.limits, max_agent_steps: 25 } } const t = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(tight)(route)], [/^PUT \/settings$/, (route) => json(tight)(route)]] }) await t.page.getByLabel('金额预算(美元)').fill('2') check('存着的步数超过硬上限、这次没改它:只改金额照样能保存', !(await t.page.getByRole('button', { name: /保存设置/ }).isDisabled())) await t.close() }) await section('设置 · 证据裁判', async () => { // 结论句裁判的模型和各项上限(后端 engine/judge.py):每一项都能设成不限(存 null),不限时写明还受什么约束。 // 读写照运行默认值:GET 读、PUT 整组写。写请求在浏览器层拦下,不落库 let stored = await get('/settings') stored = { ...stored, judge: { provider: null, model: null, report_max_claims: 40, report_max_cost_usd: null, report_timeout_s: 30, click_max_cost_usd: 0.01, daily_max_usd: 2 } } let spendGets = 0 const { page, sent, errors, close } = await open('/settings/prefs', { handlers: [ [/^GET \/settings$/, (route) => json(stored)(route)], [/^PUT \/settings$/, (route, { body }) => { stored = { ...stored, ...body.values }; return json(stored)(route) }], [/^GET \/settings\/judge\/spend$/, (route) => { spendGets += 1; return json({ date: '2026-09-29', usd: 0.0123, calls: 7, unpriced_calls: 2, daily_max_usd: 2 })(route) }], ], }) const box = page.locator('[data-judge-settings]') const all = (await box.innerText().catch(() => '')).replace(/\s+/g, ' ') check('偏好里有「证据裁判」一组,写明判断是模型给的、不是系统核对', all.includes('证据裁判') && all.includes('非确定'), all.slice(0, 80)) // 裁判拆档:接入和模型都没填时,平常那句建议换成醒目的「未单独配置」提示(建议写在提示里);填了再换回来(见下) check('裁判模型:接入和模型,提示建议和写作模型不同', await page.locator('#pref-judge-provider').count() === 1 && await page.locator('#pref-judge-model').count() === 1 && all.includes('建议配置一个与撰写报告的模型不同、能力更强的模型')) check('……接入留空时跟随助手的模型', (await page.locator('#pref-judge-provider option').first().innerText()).includes('跟随助手的模型')) const notSet = page.locator('[data-judge-model] [data-judge-not-set]') check('没配裁判模型(接入、模型都是 null):提示「未单独配置,将使用助手的模型」', (await notSet.locator('[data-judge-not-set-text]').innerText().catch(() => '')) === '未单独配置,将使用助手的模型', await notSet.innerText().catch(() => '(没有这条提示)')) const advice = await notSet.locator('[data-judge-not-set-advice]').innerText().catch(() => '') check('……并建议配一个和写作模型不同、能力更强的模型', advice.includes('与撰写报告的模型不同') && advice.includes('能力更强'), advice) const noteLook = await notSet.evaluate((el) => { const probe = document.createElement('span') probe.style.color = 'var(--st-waiting)' document.body.appendChild(probe) const waiting = getComputedStyle(probe).color probe.remove() const cs = getComputedStyle(el) return { border: cs.borderLeftColor, width: cs.borderLeftWidth, icon: getComputedStyle(el.querySelector('svg')).color, waiting, described: document.getElementById(el.closest('[data-judge-model]').getAttribute('aria-describedby'))?.hasAttribute('data-judge-not-set') } }).catch(() => null) check('……醒目:左侧提醒色竖线、提醒色图标', !!noteLook && noteLook.border === noteLook.waiting && noteLook.width === '2px' && noteLook.icon === noteLook.waiting, JSON.stringify(noteLook)) check('……读屏:模型那一组的说明就是这条提示(aria-describedby)', noteLook?.described === true) if (SHOTS) { await box.scrollIntoViewIfNeeded() await page.waitForTimeout(200) await box.screenshot({ path: `${SHOTS}/prefs-judge-not-set-${THEME}.png` }) } const val = (id) => page.locator(`#${id}`).inputValue() check('读回各项上限:40 句、30 秒、每次点击 $0.01、每日 $2', await val('pref-judge-claims') === '40' && await val('pref-judge-timeout') === '30' && await val('pref-judge-click') === '0.01' && await val('pref-judge-daily') === '2') const cost = page.locator('[data-judge-limit="report_max_cost_usd"]') check('存着 null 的每份报告金额:勾着「不限」、框变灰', await cost.locator('[data-judge-unlimited-toggle]').isChecked() && await page.locator('#pref-judge-cost').isDisabled()) const costNote = await cost.locator('[data-judge-unlimited]').innerText().catch(() => '') check('……写明「不设上限,费用仅受……约束」', costNote === '不设上限,费用仅受句数上限、时长上限、每日上限约束', costNote) const spend = await page.locator('[data-judge-spend]').innerText().catch(() => '') check('每日上限旁边写今天花了多少', spend.includes('今日已花费 $0.0123(7 次调用)'), spend) check('……估不出金额的调用另说', spend.includes('另有 2 次调用无法估算金额') && await page.locator('[data-judge-unpriced]').count() === 1, spend) const daily = page.locator('[data-judge-limit="daily_max_usd"]') await daily.locator('[data-judge-unlimited-toggle]').check() const dailyNote = await daily.locator('[data-judge-unlimited]').innerText().catch(() => '') check('每日上限设成不限:写明每日费用还受什么约束', dailyNote.startsWith('不设上限,每日费用仅受') && dailyNote.includes('每次点击的金额上限'), dailyNote) const costNote2 = await cost.locator('[data-judge-unlimited]').innerText().catch(() => '') check('……每日也不限了:每份报告金额那句不再说「每日上限」', !costNote2.includes('每日') && costNote2.startsWith('不设上限'), costNote2) await page.locator('#pref-judge-click').fill('0') const save = page.getByRole('button', { name: /保存设置/ }) check('每次点击填 0:标红、保存按钮不可用', await save.isDisabled() && (await page.locator('[data-judge-limit="click_max_cost_usd"]').innerText()).includes('请填写大于 0 的金额,或勾选「不限」')) await page.locator('#pref-judge-click').fill('0.02') check('……接入留空时模型框的占位也写跟随助手的模型', await page.locator('#pref-judge-model').getAttribute('placeholder') === '留空:跟随助手的模型') await page.locator('#pref-judge-model').fill('judge-model-b') check('填了裁判模型:「未单独配置」的提示收起,换回平常那句建议', await notSet.count() === 0 && (await box.innerText().catch(() => '')).includes('建议与撰写报告的模型不同')) // 只填了模型:设置这一级整组生效(judge_model_spec 不跨级拼),后端按模型名找接入——已经不跟随助手的模型 const provFirst = await page.locator('#pref-judge-provider option').first().innerText().catch(() => '') check('只填了模型:接入留空那一项不再叫「跟随助手的模型」,改叫「按模型名找接入」', provFirst === '按模型名找接入', provFirst) const byModel = page.locator('[data-judge-by-model]') const byText = await byModel.innerText().catch(() => '') // 说明里有沙箱的接入名:细节只报认没认出来,不打印原文 check('……写明按模型名找接入(认不出就去调默认接入),「跟随助手的模型」只在两项都留空时生效', await byModel.getAttribute('data-judge-by-model').catch(() => '') === 'default' && byText.includes('只填了模型') && byText.includes('默认接入') && byText.includes('「跟随助手的模型」仅在接入和模型都留空时生效'), `data-judge-by-model=${await byModel.getAttribute('data-judge-by-model').catch(() => '(没有)')}`) const bar = (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).replace(/\s+/g, ' ') check('保存条写明改了哪几项', bar.includes('有 3 项未保存') && bar.includes('每日金额上限') && bar.includes('每次点击的金额上限') && bar.includes('裁判模型'), bar) const gets = spendGets await save.click() await page.waitForTimeout(500) const put = sent.filter((s) => s.key === 'PUT /settings').at(-1)?.body?.values ?? {} const j = put.judge ?? {} check('保存:整组写 judge(七项都在),不限存 null', Object.keys(j).sort().join(',') === 'click_max_cost_usd,daily_max_usd,model,provider,report_max_claims,report_max_cost_usd,report_timeout_s' && j.daily_max_usd === null && j.report_max_cost_usd === null && j.click_max_cost_usd === 0.02 && j.report_max_claims === 40 && j.report_timeout_s === 30 && j.model === 'judge-model-b' && j.provider === null, JSON.stringify(j)) check('……只改了裁判:不碰运行默认值那一组', !('run' in put), Object.keys(put).join(',')) check('……存完再取一次今天的花费(每日上限变了)', spendGets > gets, `${gets} → ${spendGets}`) check('……存完显示「已保存」', (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).includes('已保存')) await daily.locator('[data-judge-unlimited-toggle]').uncheck() check('勾掉「不限」:填回原来的数', await val('pref-judge-daily') === '2') await daily.locator('[data-judge-unlimited-toggle]').check() check('没有运行时报错', errors.length === 0, errors[0] ?? '') if (SHOTS) { await box.scrollIntoViewIfNeeded() await page.waitForTimeout(200) await box.screenshot({ path: `${SHOTS}/prefs-judge-${THEME}.png` }) } await close() // 只填了接入(模型留空):也算单独配置了,不提示「未单独配置」 const byProvider = { ...stored, judge: { ...stored.judge, provider: 'judge-provider-x', model: null } } const pv = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(byProvider)(route)], [/^GET \/settings\/judge\/spend$/, (route) => json({ date: '2026-09-29', usd: 0, calls: 0, unpriced_calls: 0 })(route)]] }) await pv.page.waitForSelector('[data-judge-settings]', { timeout: 6000 }).catch(() => {}) check('只填了接入:不提示「未单独配置」', await pv.page.locator('[data-judge-settings]').count() === 1 && await pv.page.locator('[data-judge-not-set]').count() === 0) await pv.close() // 老后端没有 judge 这一组:不显示,也不写 const legacy = { ...stored } delete legacy.judge const old = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(legacy)(route)]] }) check('老后端没有裁判设置:这一组不出现', await old.page.locator('[data-judge-settings]').count() === 0) await old.close() }) await section('数据 · 数据库', async () => { // 上传的表格按来源标记认(origin),不按路径:上传库的路径随版本变 const dbs = sources.filter((s) => s.origin !== 'upload') 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: '连接失败:用户名或密码错误', origin: 'manual', current_snapshot: null, } // 假库二:配置改成了 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) })], // 取单张表时回老后端的样子:只有 detail 文本,没有 found、没有 columns [/^GET \/datasources\/check-manage-drift\/schema$/, (route, { url }) => json(url.searchParams.get('table') ? { table: url.searchParams.get('table'), detail: '表 sales.orders\n order_id INTEGER\n amount REAL' } : { tables: ['sales.orders', 'sales.sales_daily'], summary: '', synced_at: ago(0) })(route)], [/^POST \/datasources$/, json({ detail: '查询时限需要填写 1 到 600 之间的秒数,例如 60;当前为「0」' }, 422)], [/^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) }], // 回得慢一点:「测连接期间只说一句」要在结果回来之前读两次播报区。250ms 在机器 // 负载高时第一次读就已经是结果了,这项时过时不过 [/^POST \/datasources\/[^/]+\/test$/, delayed(1500, { 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() const tails = addrs.filter((a) => /:\s*$/.test(a)) check('地址不留尾巴冒号', !tails.length, tails.length ? `${tails.length} 条(内容不打出来)` : '') await card.getByRole('button', { name: /测试连接/ }).click() // 先等到真在测,再隔 1 秒读两次:计时每 100ms 一跳,播报区要是跟着念就对不上 await card.locator('[data-health="checking"]').first().waitFor({ timeout: 3000 }) const spoken0 = await card.locator('[role="status"]').first().innerText().catch(() => '') await page.waitForTimeout(700) 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 until(() => card.locator('[data-health]').first().getAttribute('data-health').then((v) => v !== 'checking'), 5000) 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}"]`) // 全局数据源目录(store/catalog)每重拉一次就是一个 GET /datasources;页面自己的列表只在进页时拉 const lists = () => sent.filter((s) => s.key === 'GET /datasources').length 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) check('失败框的候选里不含配置里现有的那个(ods),只列别的', await ec.getByRole('button', { name: '预览 ods' }).count() === 0 && await ec.getByRole('button', { name: '预览 mes' }).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) const listsBeforeSwitch = lists() await dialog(page).getByRole('button', { name: '改成 mes' }).click() await page.waitForTimeout(600) check('……卡片上换了 schema、重探了:全局的数据源目录也跟着重拉(检查器、问数据读它的 schema 和结构)', lists() > listsBeforeSwitch, `${listsBeforeSwitch} → ${lists()}`) 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() const tested = await until(async () => { const t = await ec.locator('[data-health]').innerText() return /连接成功.*42 ms/.test(t) ? t : '' }, 5000) check('本机刚测过:卡片换成本机的结果', !!tested) 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') const listsBefore = lists() if (await saveBtn.isEnabled()) await saveBtn.click() else await ed.getByRole('button', { name: '取消' }).click() await page.waitForTimeout(400) check('……保存后全局的数据源目录跟着重拉(检查器挑工具、问数据的范围读它)', lists() > listsBefore, `${listsBefore} → ${lists()}`) 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) // 老后端取单张表只给 detail 文本(没有 found):原样显示,不能误报成「缓存里没有这张表」 await dc.getByRole('button', { name: /个对象/ }).click() await page.waitForTimeout(500) await dc.locator('[data-schema-browser] button[aria-expanded]').first().click() await page.waitForTimeout(500) const oldCols = await dc.locator('[data-schema-browser]').innerText() check('老后端的列信息:照原文显示,不说「缓存中已没有这张表」', oldCols.includes('amount') && oldCols.includes('REAL') && !oldCols.includes('缓存中已没有这张表'), oldCols.replace(/\s+/g, ' ').slice(0, 160)) } { // SQLite 也有高级连接参数(查询时限);填错被 422 拒掉时写在那一项下面,不只弹 toast await page.getByRole('button', { name: /接入数据库/ }).first().click() const nd = dialog(page) await nd.getByLabel('类型').selectOption('sqlite') await nd.getByLabel(/^标识/).fill('zz_sqlite_demo') await nd.getByLabel(/数据库文件路径/).fill('/tmp/zz_sqlite_demo.db') const timeout = nd.locator('#ds-adv-query_timeout_s') check('SQLite 表单有「查询时限(秒)」', await timeout.count() === 1) if (await timeout.count()) { if (!(await timeout.isVisible())) await nd.getByText('高级连接参数').click() await timeout.fill('0') await nd.getByRole('button', { name: '保存' }).click() await page.waitForTimeout(500) const err = await nd.locator('#ds-adv-query_timeout_s-error').innerText().catch(() => '') check('……查询时限填错:后端的那句话写在这一项下面', err.includes('1 到 600'), err) check('……输入框标成出错,焦点落在它上面', (await timeout.getAttribute('aria-invalid')) === 'true' && await timeout.evaluate((el) => el === document.activeElement)) check('……不再只弹 toast', !(await page.locator('[aria-live="assertive"]').innerText().catch(() => '')).includes('1 到 600')) await shot(page, 'datasource-sqlite-timeout-error') // 照人的改法:退格删掉 0 再敲 30。删空的那一下高级参数一项都不剩,以前
跟着 // 收起,焦点掉到 body,后面敲的字落空(fill 一步到位,不经过空值,抓不到) await timeout.press('Backspace') await page.waitForTimeout(150) check('……改了这一项,出错提示就收起', await nd.locator('#ds-adv-query_timeout_s-error').count() === 0) const advOpen = () => timeout.evaluate((el) => !!el.closest('details')?.open) check('……删空那一下:高级连接参数不收起,焦点还在这一项上', await advOpen() && await timeout.evaluate((el) => el === document.activeElement), `${await advOpen() ? '开着' : '收起了'} · 焦点在 ${await page.evaluate(() => document.activeElement?.id || document.activeElement?.tagName)}`) await page.keyboard.type('30') check('……接着敲 30 就填进这一项', (await timeout.inputValue()) === '30', `框里是「${await timeout.inputValue()}」`) } await nd.getByRole('button', { name: '取消' }).click() await page.waitForTimeout(200) const discard = dialog(page).getByRole('button', { name: '放弃修改' }) if (await discard.count()) await discard.click() } 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') // 请求体里是沙箱库真实的主机、账号、schema、说明:只报键名和判断用到的几个真假 const reqOk = req?.body?.id === oracle.id && req?.body?.options?.sid === 'ORCL' && !('service_name' in (req?.body?.options ?? {})) && !('database' in (req?.body ?? {})) check('弹窗里先测连接:带 id、只写 options.sid、不写 database', reqOk, reqOk ? '' : !req ? '没发 POST /datasources/test' : `键:${Object.keys(req.body ?? {}).join(',')} · options 键:${Object.keys(req.body?.options ?? {}).join(',')}` + ` · id 对=${req.body?.id === oracle.id} · sid=ORCL:${req.body?.options?.sid === 'ORCL'}`) 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() }) await section('数据 · 遮罩列(证据面板的 mask_columns)', async () => { // 假库:两张表,phone 已经遮上了。挑列从已探查的结构里读;写请求全部在浏览器里接住 const base = { id: 'check-manage-mask', name: 'zz_mask', kind: 'postgres', host: '10.0.0.7', port: null, database: 'shop', username: 'reader', options: { schema: 'sales', mask_columns: ['phone'] }, readonly: true, description: '检查脚本的假库', enabled: true, password_masked: '', has_password: false, table_count: 2, schema_synced_at: ago(3600_000), cached_schema: 'sales', schema_error: '', available_schemas: [], tools: ['db_query__zz_mask'], last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null, origin: 'manual', current_snapshot: null, } const upload = { ...base, id: 'check-manage-upmask', name: 'zz_upload_mask', kind: 'sqlite', host: null, username: null, database: `/tmp/agentlab/uploads/tables/check-manage-upmask/builds/${'a'.repeat(64)}.db`, options: { query_timeout_s: '30' }, table_count: 1, tools: ['db_query__zz_upload_mask'], origin: 'upload', current_snapshot: { id: '1'.repeat(64), created_at: ago(3600_000), file_name: 'orders.xlsx', raw_state: 'kept' }, } let cur = base let up = upload const columns = { 'sales.orders': ['order_id', 'amount', 'phone'], 'sales.customers': ['id', 'customer_id', 'email', 'phone'], orders_upload: ['order_id', 'phone'], } const tableJson = (t) => ({ table: t, found: true, kind: 'table', comment: null, detail: '', columns: (columns[t] ?? []).map((name) => ({ name, type: 'TEXT' })) }) const { page, sent, errors, natives, close } = await open('/data/databases', { handlers: [ [/^GET \/datasources$/, (route) => json([...sources, cur, up])(route)], [/^GET \/datasources\/check-manage-mask\/schema$/, (route, { url }) => json(url.searchParams.get('table') ? tableJson(url.searchParams.get('table')) : { tables: ['sales.orders', 'sales.customers'], summary: '', synced_at: ago(0) })(route)], [/^GET \/datasources\/check-manage-upmask\/schema$/, (route, { url }) => json(url.searchParams.get('table') ? tableJson(url.searchParams.get('table')) : { tables: ['orders_upload'], summary: '', synced_at: ago(0) })(route)], [/^POST \/datasources\/check-manage-mask\/test$/, delayed(200, { ok: true, elapsed_ms: 42, url: 'x' })], [/^PATCH \/datasources\/check-manage-mask$/, (route, { body }) => { cur = { ...cur, ...body }; return json(cur)(route) }], [/^PATCH \/datasources\/check-manage-upmask$/, (route, { body }) => { up = { ...up, ...body }; return json(up)(route) }], ], }) const card = page.locator(`[data-source="${base.name}"]`) check('卡片上写着遮罩了几列', (await card.locator('[data-mask-columns]').innerText().catch(() => '')).includes('遮罩 1 列') && await card.locator('[data-mask-columns]').getAttribute('data-mask-columns') === 'phone') await card.getByRole('button', { name: /测试连接/ }).click() await until(() => card.locator('[data-health]').first().innerText().then((t) => /连接成功/.test(t)), 5000) await card.getByRole('button', { name: '编辑' }).click() const ed = dialog(page) const field = ed.locator('[data-field="mask_columns"]') check('编辑框里有「遮罩的列」,已遮的 phone 是一个标签', await field.count() === 1 && await field.locator('[data-mask-chip="phone"]').count() === 1) const boundary = await field.locator('[data-mask-boundary]').innerText().catch(() => '') check('写明遮罩只减少暴露、不是安全边界', boundary.includes('遮罩只减少暴露,不是安全边界'), boundary) check('高级连接参数里不再另摆一个遮罩的文本框(只有这一处)', await ed.locator('#ds-adv-mask_columns').count() === 0) const before = sent.filter((r) => r.key === 'GET /datasources/check-manage-mask/schema').length await ed.locator('#ds-mask-columns').focus() const listed = await until(async () => { const v = await ed.locator('#ds-mask-columns-list option').evaluateAll((els) => els.map((e) => e.value)) return v.length ? v : null }, 5000) const reads = sent.filter((r) => r.key === 'GET /datasources/check-manage-mask/schema').length - before check('聚焦输入框才去读已探查的结构,候选是各表的列(已遮的 phone 不再列)', !!listed && listed.includes('email') && listed.includes('amount') && !listed.includes('phone') && reads === 3, `${JSON.stringify(listed)} · ${reads} 个请求`) const hint = await field.innerText() check('……说清是从已探查的几张表里挑的', hint.includes('从已探查的 2 张表中选择'), hint.replace(/\s+/g, ' ').slice(0, 160)) await ed.locator('#ds-mask-columns').fill('email') await ed.locator('#ds-mask-columns').press('Enter') await ed.locator('#ds-mask-columns').pressSequentially('id_card') check('逐字敲 id_card 途中碰上列名 id 不会提前加成标签', await field.locator('[data-mask-chip="id"]').count() === 0 && (await ed.locator('#ds-mask-columns').inputValue()) === 'id_card') await ed.locator('#ds-mask-columns').press('Enter') await ed.locator('#ds-mask-columns').fill('EMAIL') await ed.locator('#ds-mask-columns').press('Enter') await field.getByRole('button', { name: '不再遮罩 phone' }).click() const chips = await field.locator('[data-mask-chip]').evaluateAll((els) => els.map((e) => e.getAttribute('data-mask-chip'))) check('回车加标签、× 去掉、大小写不同的同名列不重复加', chips.join(',') === 'email,id_card', chips.join(',')) if (SHOTS) await shot(page, 'datasource-mask-columns') await ed.getByRole('button', { name: '保存' }).click() await page.waitForTimeout(500) const saved = sent.filter((r) => r.key === 'PATCH /datasources/check-manage-mask').at(-1) check('保存:options.mask_columns 是列名列表,schema 原样带上', JSON.stringify(saved?.body?.options?.mask_columns) === '["email","id_card"]' && saved?.body?.options?.schema === 'sales', JSON.stringify(saved?.body?.options ?? {})) check('……改遮罩列不算改连接:刚测的结果还作数', /连接成功/.test(await card.locator('[data-health]').first().innerText())) check('……卡片跟着变成遮罩 2 列', (await card.locator('[data-mask-columns]').innerText().catch(() => '')).includes('遮罩 2 列')) await card.getByRole('button', { name: '编辑' }).click() await dialog(page).locator('[data-field="mask_columns"]').getByRole('button', { name: '不再遮罩 email' }).click() await dialog(page).locator('[data-field="mask_columns"]').getByRole('button', { name: '不再遮罩 id_card' }).click() await dialog(page).getByRole('button', { name: '保存' }).click() await page.waitForTimeout(400) const cleared = sent.filter((r) => r.key === 'PATCH /datasources/check-manage-mask').at(-1) check('全部去掉:options 里不留 mask_columns 这个键', !!cleared && !('mask_columns' in (cleared.body?.options ?? {})) && await card.locator('[data-mask-columns]').count() === 0, JSON.stringify(cleared?.body?.options ?? {})) await goto(page, '/data/tables') const uc = page.locator(`[data-source="${upload.name}"]`) await uc.locator('[data-edit-mask]').click() const ud = dialog(page) await ud.locator('#ds-mask-columns').fill('phone') await ud.locator('#ds-mask-columns').press('Enter') await ud.getByRole('button', { name: '保存' }).click() await page.waitForTimeout(400) const upSaved = sent.filter((r) => r.key === 'PATCH /datasources/check-manage-upmask').at(-1) check('传上来的表没有编辑框:卡片上「设遮罩列」单独改,其余 options 原样带上', JSON.stringify(upSaved?.body) === JSON.stringify({ options: { query_timeout_s: '30', mask_columns: ['phone'] } }), JSON.stringify(upSaved?.body ?? {})) check('……卡片写着遮罩 1 列', (await uc.locator('[data-mask-columns]').innerText().catch(() => '')).includes('遮罩 1 列')) check('没有原生对话框', natives.length === 0, natives.join(' | ')) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close() }) await section('数据 · 表格', async () => { // 新库路径按版本存放:uploads/tables/<源 id>/builds/<构建 id>.db。卡片认的是 origin,不看路径 const fake = { source: { id: 'check-manage-fake', name: 'sales_demo', kind: 'sqlite', host: null, port: null, database: `/tmp/x/uploads/tables/check-manage-fake/builds/${'b'.repeat(64)}.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'], origin: 'upload', current_snapshot: { id: 'c'.repeat(64), created_at: new Date().toISOString(), file_name: 'sales_demo.csv', raw_state: 'kept' }, }, replaced: false, import_id: 'check-manage-import', snapshot_id: 'c'.repeat(64), build_reused: false, skipped_sheets: [], conversions: [], warnings: [], // name 是 SQL 列名,header 是原表头:像不像一行数据按原表头判断(「2026-01」清洗成 c_2026_01 就看不出来了) tables: [{ name: 'sales_demo', sheet: 'sales_demo', rows: 12, region: 'A1:C13', unshaped: false, blank_rows_skipped: 0, columns_trimmed: [], columns: [ { name: 'c_2026_01', type: 'REAL', header: '2026-01' }, { name: 'region', type: 'TEXT', header: 'region' }, { name: 'col_3', type: 'TEXT', header: '' }, ] }], } const { page, sent, natives, close } = await open('/data/tables', { uploadProgress: true, recordForms: 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) const notice = await dlg.locator('[data-raw-notice]').innerText().catch(() => '') check('上传之前告知:原件(含隐藏工作表)保存在服务端,用于核对和追溯,之后可以清除', ['原始文件', '隐藏工作表', '服务端', '核对和追溯', '清除'].every((w) => notice.includes(w)), notice) 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 行')) const firstForm = (await uploadForms(page))[0] ?? {} check('第一次上传不替用户做决定:数字列混入非数字默认拒收,不按原样导入', firstForm.mixed === 'reject' && firstForm.raw_mode === 'false' && firstForm.header_row === '1', JSON.stringify(firstForm)) 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() }) await section('数据 · 表格:导入前的决定与回执', async () => { // 一张仿客流表的交叉表(标签都是假名):服务端先说结构不规整(shape),选了按原样导入之后又说 // 数字列混进了占位符(mixed),选了存为空值才导入成功。服务端的回答排在 replies 里,一次取一个 const src = { id: 'check-manage-decide', name: 'zz_flow', kind: 'sqlite', host: null, port: null, database: `/tmp/x/uploads/tables/check-manage-decide/builds/${'d'.repeat(64)}.db`, username: null, options: {}, readonly: true, description: '', enabled: true, password_masked: '', has_password: false, table_count: 1, schema_synced_at: ago(0), cached_schema: '', schema_error: '', available_schemas: [], tools: ['db_query__zz_flow'], last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null, origin: 'upload', current_snapshot: { id: 'e'.repeat(64), created_at: ago(0), file_name: 'zz_flow.csv', raw_state: 'kept' }, } // detail 是服务端那句原话:界面画的是选择页,不该把它当报错弹出来 const shape = { detail: '检查脚本的 422 原话:结构不规整', decision: { kind: 'shape', details: { reasons: [ { sheet: '时段客流', kind: 'date_header', cells: ['B1:H1'], message: '第 1 行(表头)有 7 个日期样式的单元格(B1:H1),疑似日期横排的交叉表' }, { sheet: '时段客流', kind: 'section_title', cells: ['A6', 'A11'], message: '第 6、11 行只有一个单元格有文字、其余列为空(A6「分区乙」、A11「分区丙」),疑似分段标题或备注' }, ] } }, } const mixed = { detail: '检查脚本的 422 原话:数字列混入非数字', decision: { kind: 'mixed', details: { columns: [ { sheet: '时段客流', table: '时段客流', column: 'c_8月1日', header: '8月1日', numeric: 14, nonnumeric: 4, values: [{ value: '·', count: 3 }, { value: 'N/A', count: 1 }] }, ] } }, } const receipt = { source: src, replaced: false, import_id: 'check-manage-imp', snapshot_id: 'e'.repeat(64), build_reused: false, skipped_sheets: [ { sheet: '备注', state: 'hidden', reason: 'hidden' }, { sheet: '口令', state: 'veryHidden', reason: 'hidden' }, { sheet: 'Sheet3', state: 'visible', reason: 'empty' }, ], conversions: [ { table: '时段客流', column: 'c_8月1日', kind: 'nonnumeric_to_null', count: 4, examples: ['·', 'N/A'] }, { table: '时段客流', column: '合计', kind: 'thousands_separator', count: 2, examples: ['1,234', '2,345'] }, ], warnings: ['表「时段客流」按原样导入、未经规整(表头是横排的日期、表内有分段标题行):同一列里混有不同口径的行,不能直接对列求和'], tables: [{ name: '时段客流', sheet: '时段客流', rows: 18, region: 'A1:I20', unshaped: true, blank_rows_skipped: 2, columns_trimmed: ['J'], columns: [ { name: '分区', type: 'TEXT', header: '分区' }, { name: 'c_8月1日', type: 'REAL', header: '8月1日' }, { name: '合计', type: 'INTEGER', header: '合计' }, ], }], } const replies = [] const { page, sent, natives, errors, close } = await open('/data/tables', { recordForms: true, handlers: [ [/^POST \/datasources\/upload$/, (route) => { const [body, status] = replies.shift() ?? [{ detail: '检查脚本没有准备这次的回答' }, 500] return json(body, status)(route) }], [/^GET \/datasources\/check-manage-decide\/schema$/, json({ tables: ['时段客流'], summary: '', synced_at: ago(0) })], ], }) const posts = () => sent.filter((s) => s.key === 'POST /datasources/upload').length const body = () => page.locator('body').innerText() await page.getByRole('button', { name: /传表格/ }).first().click() let dlg = dialog(page) await dlg.locator('input[type="file"]').setInputFiles({ name: 'zz_flow.csv', mimeType: 'text/csv', buffer: Buffer.from('分区,8月1日\n分区甲,12\n') }) // ---- 结构不规整:逐条原因带坐标,说明要按配方导入,「按原样导入」旁边写后果 replies.push([shape, 422]) await dlg.getByRole('button', { name: /导入/ }).click() const shapeView = page.locator('[data-upload-decision="shape"]') await shapeView.waitFor({ timeout: 5000 }).catch(() => {}) dlg = dialog(page) const shapeText = await shapeView.innerText().catch(() => '') check('结构不规整(422 shape):弹窗换成选择页,不弹报错', await shapeView.count() === 1 && !(await body()).includes('检查脚本的 422 原话'), shapeText.slice(0, 80)) check('……逐条列出原因,带坐标', await shapeView.locator('[data-shape-reason]').count() === 2 && shapeText.includes('表头是横排的日期') && shapeText.includes('表内有分段标题行') && (await shapeView.locator('[data-shape-cells]').first().innerText()).includes('B1:H1') && shapeText.includes('A6') && shapeText.includes('A11'), shapeText.replace(/\s+/g, ' ').slice(0, 200)) check('……说明这类表要按配方导入', (await shapeView.locator('[data-shape-recipe]').innerText().catch(() => '')).includes('按配方导入')) const rawBtn = dlg.getByRole('button', { name: '按原样导入(未规整)' }) const consequence = dlg.locator('[data-raw-consequence]') const consequenceText = await consequence.innerText().catch(() => '') const adjacent = await rawBtn.evaluate((el) => { const id = el.getAttribute('aria-describedby') const note = id && document.getElementById(id) return !!note && note.parentElement === el.parentElement && note.hasAttribute('data-raw-consequence') }).catch(() => false) check('……「按原样导入(未规整)」旁边写明后果:同一列混有不同口径的行,不能直接对列求和', await rawBtn.count() === 1 && adjacent && consequenceText.includes('不同口径') && consequenceText.includes('不能直接对列求和'), consequenceText) const firstForm = (await uploadForms(page))[0] ?? {} check('……第一次上传默认拒收:mixed=reject、raw_mode=false', firstForm.mixed === 'reject' && firstForm.raw_mode === 'false', JSON.stringify(firstForm)) await shot(page, 'table-decision-shape') // ---- 决定页上按 Esc、点 ×:文件还在,先问一句,不直接关掉丢了文件 const asking = () => dialog(page).locator('[role="alert"]', { hasText: '有未保存的修改' }) await page.keyboard.press('Escape') await page.waitForTimeout(150) check('决定页按 Esc:先问「放弃 / 继续编辑」,弹窗和选择页都还在', await asking().count() === 1 && await shapeView.count() === 1 && await dialog(page).getByRole('button', { name: '放弃修改' }).count() === 1) await dialog(page).getByRole('button', { name: '继续编辑' }).click() await page.waitForTimeout(150) check('……「继续编辑」:询问收起,仍在选择页', await asking().count() === 0 && await shapeView.count() === 1) await dialog(page).getByRole('button', { name: '关闭' }).click() await page.waitForTimeout(150) check('……点 × 同样先问', await asking().count() === 1 && await shapeView.count() === 1) await dialog(page).getByRole('button', { name: '继续编辑' }).click() await page.waitForTimeout(150) check('……这几下都没有发请求', posts() === 1 && await asking().count() === 0, String(posts())) dlg = dialog(page) // ---- 取消:什么都不发,回到表单,文件还在 await dlg.getByRole('button', { name: '取消', exact: true }).click() await page.waitForTimeout(200) dlg = dialog(page) check('「取消」回到表单:不再发请求,文件和名字都还在', posts() === 1 && await page.locator('[data-upload-decision]').count() === 0 && (await dlg.innerText()).includes('zz_flow.csv') && (await dlg.locator('input.mono').first().inputValue()) === 'zz_flow') check('……没有选过任何处理方式', await dlg.locator('[data-upload-choices]').count() === 0) // ---- 再导入:选按原样导入 → 服务端又问数字列 → 选存为空值 → 成功 replies.push([shape, 422], [mixed, 422], [receipt, 201]) await dlg.getByRole('button', { name: /导入/ }).click() await shapeView.waitFor({ timeout: 5000 }).catch(() => {}) await dialog(page).getByRole('button', { name: '按原样导入(未规整)' }).click() const mixedView = page.locator('[data-upload-decision="mixed"]') await mixedView.waitFor({ timeout: 5000 }).catch(() => {}) dlg = dialog(page) const forms = await uploadForms(page) check('选「按原样导入」:带 raw_mode=true 重新上传,数字列的处理仍是拒收', forms[2]?.raw_mode === 'true' && forms[2]?.mixed === 'reject', JSON.stringify(forms[2] ?? {})) const col = mixedView.locator('[data-mixed-column="c_8月1日"]') const colText = await col.innerText().catch(() => '') check('数字列混入非数字(422 mixed):列出每一列和其中的非数字取值及个数', await mixedView.count() === 1 && colText.includes('8月1日') && colText.includes('数字 14 个,非数字 4 个') && (await col.locator('[data-mixed-value="·"]').innerText().catch(() => '')).includes('3 个') && (await col.locator('[data-mixed-value="N/A"]').innerText().catch(() => '')).includes('1 个'), colText.replace(/\s+/g, ' ')) check('……写明已选的「按原样导入(未规整)」', (await mixedView.locator('[data-upload-choices]').innerText().catch(() => '')).includes('按原样导入')) check('……两个出口:存为空值按数字导入 / 取消', await dlg.getByRole('button', { name: '把这些值存为空值,按数字导入' }).count() === 1 && await dlg.getByRole('button', { name: '取消', exact: true }).count() === 1) check('……不弹报错', !(await body()).includes('检查脚本的 422 原话')) await shot(page, 'table-decision-mixed') await dlg.getByRole('button', { name: '把这些值存为空值,按数字导入' }).click() const result = page.locator('[data-upload-result]') await result.waitFor({ timeout: 5000 }).catch(() => {}) const third = (await uploadForms(page))[3] ?? {} check('选「存为空值」:带 mixed=null 重新上传,先前选的按原样导入一并带上', third.mixed === 'null' && third.raw_mode === 'true', JSON.stringify(third)) check('一共发了 4 次(取消那次没发)', posts() === 4, String(posts())) // ---- 回执 const table = result.locator('[data-upload-table="时段客流"]') const tableText = await table.innerText().catch(() => '') check('回执:未规整的表打上标记', (await table.locator('[data-unshaped]').innerText().catch(() => '')).includes('未规整')) const mappedChip = await table.locator('[data-header="8月1日"]').innerText().catch(() => '') check('……原表头和列名不同时并排写出(8月1日 → c_8月1日),相同的只写一次', mappedChip.includes('8月1日') && mappedChip.includes('c_8月1日') && await table.locator('[data-header]').count() === 1 && (await result.innerText()).includes('箭头左侧为原表头'), mappedChip.replace(/\s+/g, ' ')) const housekeeping = await table.locator('[data-upload-housekeeping]').innerText().catch(() => '') check('……去掉的空行和空列', housekeeping.includes('已跳过 2 个空行') && housekeeping.includes('J'), housekeeping) check('……导入区域', tableText.includes('A1:I20')) const hidden = await result.locator('[data-skipped-hidden]').innerText().catch(() => '') check('……跳过的隐藏工作表(深度隐藏的注明)', hidden.includes('2 个隐藏工作表') && hidden.includes('「备注」') && hidden.includes('「口令」(深度隐藏)'), hidden) check('……没有内容的工作表另写一行', (await result.locator('[data-skipped-empty]').innerText().catch(() => '')).includes('Sheet3')) const toNull = await result.locator('[data-conversion="nonnumeric_to_null"]').innerText().catch(() => '') const thousands = await result.locator('[data-conversion="thousands_separator"]').innerText().catch(() => '') check('……类型转换:哪一列、怎么转、多少个、举例', toNull.includes('c_8月1日') && toNull.includes('非数字的值已存为空值') && toNull.includes('共 4 个') && toNull.includes('「·」') && thousands.includes('千分位') && thousands.includes('「1,234」'), `${toNull} | ${thousands}`) const warnings = result.locator('[data-upload-warnings] li') check('……警告逐条列出', await warnings.count() === 1 && (await warnings.first().innerText()).includes('不能直接对列求和')) check('……列表里有了这张表(按来源分到「表格」)', await page.locator('[data-source="zz_flow"]').count() === 1) await shot(page, 'table-upload-receipt') // ---- 改表头行号:上次的回答作废,重新问 replies.push([mixed, 422]) await dialog(page).getByRole('button', { name: /表头有误/ }).click() await page.waitForTimeout(200) check('改表头行号重传:已选的处理方式清空', await dialog(page).locator('[data-upload-choices]').count() === 0) await dialog(page).getByRole('button', { name: /导入/ }).click() await mixedView.waitFor({ timeout: 5000 }).catch(() => {}) const fifth = (await uploadForms(page))[4] ?? {} check('……换了表头行,又按默认拒收发出(不沿用上次的回答)', fifth.header_row === '2' && fifth.mixed === 'reject' && fifth.raw_mode === 'false', JSON.stringify(fifth)) // ---- 认不出的决定:当普通报错弹出服务端那句话,不画空的选择页 await dialog(page).getByRole('button', { name: '取消', exact: true }).click() await page.waitForTimeout(200) replies.push([{ detail: '检查脚本:认不出的决定', decision: { kind: 'other', details: {} } }, 422]) await dialog(page).getByRole('button', { name: /导入/ }).click() await page.waitForTimeout(600) check('认不出的决定按普通报错显示服务端的原话,弹窗留在表单上', (await body()).includes('检查脚本:认不出的决定') && await page.locator('[data-upload-decision]').count() === 0 && await dialog(page).getByRole('button', { name: /导入/ }).isEnabled()) check('没有原生对话框', natives.length === 0, natives.join(' | ')) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close() }) await section('数据 · 表格:卡片按来源区分、显示当前版本', async () => { const blank = { kind: 'sqlite', host: null, port: null, username: null, options: {}, readonly: true, description: '', enabled: true, password_masked: '', has_password: false, cached_schema: '', schema_error: '', available_schemas: [], last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null, } const up = { ...blank, id: 'check-manage-ver', name: 'zz_versioned', table_count: 2, tools: ['db_query__zz_versioned'], database: `/tmp/x/uploads/tables/check-manage-ver/builds/${'f'.repeat(64)}.db`, schema_synced_at: ago(30 * 24 * 3600_000), origin: 'upload', current_snapshot: { id: '2'.repeat(64), created_at: ago(3 * 3600_000), file_name: '时段客流.xlsx', raw_state: 'kept' }, } // 迁移前上传的老版本:没有记下文件名,也没有原件。照真实后端的样子造:迁移补建的快照时间是迁移那一刻 // (升级后服务启动时,这里是两分钟前),原来的同步时间(schema_synced_at,40 天前)迁移时原样保留 const legacy = { ...up, id: 'check-manage-legacy', name: 'zz_legacy', table_count: 1, tools: ['db_query__zz_legacy'], database: '/tmp/x/uploads/tables/zz_legacy.db', schema_synced_at: ago(40 * 24 * 3600_000), current_snapshot: { id: '4'.repeat(64), created_at: ago(2 * 60_000), file_name: '', raw_state: 'absent' }, } // 没迁移成的早期上传(数据文件缺失、迁移失败):来源还是 manual,路径是老的 uploads/tables/<名>.db。 // 服务端允许同名重传把它变成上传的表格,前端不知道上传目录在哪,不能拦 let unmigrated = { ...blank, id: 'check-manage-unmigrated', name: 'zz_unmigrated', table_count: 0, tools: ['db_query__zz_unmigrated'], database: '/tmp/x/uploads/tables/zz_unmigrated.db', schema_synced_at: null, origin: 'manual', current_snapshot: null, } // 手工登记的其他库:名字照样在前端就拦住 const pg = { ...blank, id: 'check-manage-pg', name: 'zz_pgdb', kind: 'postgres', host: '10.0.0.8', port: 5432, database: 'mes', username: 'reader', table_count: 0, tools: ['db_query__zz_pgdb'], schema_synced_at: null, origin: 'manual', current_snapshot: null, } // 手工登记的 SQLite,路径恰好长得像老的上传路径:按来源算,它在「数据库」标签 const lookalike = { ...blank, id: 'check-manage-lookalike', name: 'zz_lookalike', table_count: 0, tools: ['db_query__zz_lookalike'], database: '/srv/old/uploads/tables/zz_lookalike.db', schema_synced_at: null, origin: 'manual', current_snapshot: null, } // 同名重传的回答按次序给(multipart 请求体按原文切太脆,发出的名字用 uploadForms 核对): // 第一次是手工库,回服务端那句 409 原话;第二次是没迁移成的早期上传,就地变成上传的表格 const replies = [ () => json({ detail: '已存在名为「zz_lookalike」的数据源(非上传表格),请换一个名称' }, 409), () => { unmigrated = { ...unmigrated, origin: 'upload', table_count: 1, schema_synced_at: ago(0), database: `/tmp/x/uploads/tables/check-manage-unmigrated/builds/${'5'.repeat(64)}.db`, current_snapshot: { id: '6'.repeat(64), created_at: ago(0), file_name: 'zz_unmigrated.csv', raw_state: 'kept' }, } return json({ source: unmigrated, replaced: true, import_id: 'check-manage-imp2', snapshot_id: '6'.repeat(64), build_reused: false, skipped_sheets: [], conversions: [], warnings: [], tables: [{ name: 'zz_unmigrated', sheet: 'zz_unmigrated', rows: 1, region: 'A1:B2', unshaped: false, blank_rows_skipped: 0, columns_trimmed: [], columns: [{ name: 'region', type: 'TEXT', header: 'region' }, { name: 'amount', type: 'INTEGER', header: 'amount' }] }], }, 201) }, ] const reupload = (route) => (replies.shift() ?? (() => json({ detail: '检查脚本没有准备这次的回答' }, 500)))()(route) const { page, sent, natives, errors, close } = await open('/data/tables', { recordForms: true, handlers: [ [/^GET \/datasources$/, (route) => json([...sources, up, legacy, lookalike, unmigrated, pg])(route)], [/^GET \/datasources\/check-manage-ver\/schema$/, json({ tables: ['时段客流', '分区明细'], summary: '', synced_at: ago(0) })], [/^GET \/datasources\/check-manage-legacy\/schema$/, json({ tables: ['sales'], summary: '', synced_at: ago(0) })], [/^GET \/datasources\/check-manage-unmigrated\/schema$/, json({ tables: ['zz_unmigrated'], summary: '', synced_at: ago(0) })], [/^POST \/datasources\/[^/]+\/introspect/, json({ detail: '上传的表格无需重新探查,重新上传即可更新' }, 409)], [/^POST \/datasources\/upload$/, reupload], ], }) const card = page.locator(`[data-source="${up.name}"]`) check('「表格」标签按来源认:上传的在,路径像上传库的手工 SQLite 不在', await card.count() === 1 && await page.locator('[data-source="zz_legacy"]').count() === 1 && await page.locator('[data-source="zz_lookalike"]').count() === 0) const version = card.locator('[data-current-version]') const versionText = await version.innerText().catch(() => '') check('卡片写当前版本的文件名和导入时间', versionText.includes('当前版本') && versionText.includes('时段客流.xlsx') && versionText.includes('3 小时前导入') && ((await version.getAttribute('title')) ?? '').includes('导入'), versionText) check('……原件已保存时不另标', !versionText.includes('原件')) const legacyVersion = page.locator('[data-source="zz_legacy"] [data-current-version]') const legacyText = await legacyVersion.innerText().catch(() => '') check('……迁移前上传的版本:没有文件名、注明未保存原件', legacyText.includes('早期上传的文件') && legacyText.includes('未保存原件'), legacyText) check('……不把迁移那一刻写成导入时间(不写「2 分钟前导入」),悬停说明未记录导入时间', !legacyText.includes('导入') && !legacyText.includes('分钟前') && !legacyText.includes('刚刚') && ((await legacyVersion.getAttribute('title')) ?? '').includes('未记录') && ((await legacyVersion.getAttribute('title')) ?? '').includes('导入时间'), `${legacyText} | ${await legacyVersion.getAttribute('title').catch(() => '')}`) const legacyCard = await page.locator('[data-source="zz_legacy"]').innerText().catch(() => '') check('……照写原来的同步时间(40 天前的「结构同步于」),不提示结构过期', legacyCard.includes('结构同步于') && !legacyCard.includes('分钟前') && !legacyCard.includes('库表可能已变更'), legacyCard.replace(/\s+/g, ' ')) const cardText = await card.innerText() check('上传的表格不给「探查结构」「按配置重新探查」(服务端会拒绝)', await card.getByRole('button', { name: /探查/ }).count() === 0 && !cardText.includes('探查结构')) check('……不写「结构同步于」,也不提示结构过期(版本冻结,导入时间写在版本那一行)', !cardText.includes('结构同步于') && !cardText.includes('库表可能已变更')) check('……路径按版本变,卡片上不写库文件路径', !cardText.includes('/builds/') && !cardText.includes('.db')) await card.getByRole('button', { name: '重新上传' }).click() const dlg = dialog(page) const nameBox = dlg.locator('input.mono').first() const importBtn = dlg.getByRole('button', { name: /导入/ }) check('同名重新上传:名字已填好,不算被占用', (await nameBox.inputValue()) === up.name && !(await dlg.innerText()).includes('已被其他数据库用作标识')) await nameBox.fill('zz_pgdb') check('……手工登记的其他库的名字不能拿来上传(按来源认,不按路径)', (await dlg.innerText()).includes('「zz_pgdb」已被其他数据库用作标识') && await importBtn.isDisabled()) await dlg.locator('input[type="file"]').setInputFiles({ name: 'zz_unmigrated.csv', mimeType: 'text/csv', buffer: Buffer.from('region,amount\neast,1\n') }) // 手工登记的 SQLite:可能是没迁移成的早期上传,前端不拦,提示一句,由服务端判断 await nameBox.fill('zz_lookalike') const sqliteHint = await dlg.locator('[data-name-sqlite]').innerText().catch(() => '') check('……和手工登记的 SQLite 重名:前端不拦(不知道上传目录在哪),提示早期上传的表格可同名替换、否则将被拒绝', !(await dlg.innerText()).includes('已被其他数据库用作标识') && await importBtn.isEnabled() && sqliteHint.includes('早期上传的表格可同名替换') && sqliteHint.includes('拒绝'), sqliteHint) await importBtn.click() await until(async () => (await dlg.innerText()).includes('非上传表格'), 5000) await page.waitForTimeout(100) const refused = await dlg.innerText() check('……照填的名字发出', (await uploadForms(page)).at(-1)?.name === 'zz_lookalike', JSON.stringify((await uploadForms(page)).at(-1) ?? {})) check('……服务端以重名拒收(409):原话写在名字下面,弹窗留在表单上,焦点回到名字', refused.includes('已存在名为「zz_lookalike」的数据源(非上传表格),请换一个名称') && await page.locator('[data-upload-result]').count() === 0 && await importBtn.isDisabled() && await nameBox.evaluate((el) => el === document.activeElement && el.getAttribute('aria-invalid') === 'true'), refused.replace(/\s+/g, ' ').slice(0, 160)) await nameBox.fill('zz_unmigrated') check('……改了名字,那句 409 就不再显示', !(await dlg.innerText()).includes('非上传表格') && await importBtn.isEnabled() && await dlg.locator('[data-name-sqlite]').count() === 1) await importBtn.click() const replacedResult = page.locator('[data-upload-result]') await replacedResult.waitFor({ timeout: 5000 }).catch(() => {}) const lastForm = (await uploadForms(page)).at(-1) ?? {} check('没迁移成的早期上传可以同名重传:照名字发出,服务端替换后写「已替换」', lastForm.name === 'zz_unmigrated' && await replacedResult.count() === 1 && (await dialog(page).innerText()).includes('已替换「zz_unmigrated」里的数据'), JSON.stringify(lastForm)) await dialog(page).getByRole('button', { name: '列名无误 · 完成' }).click() await page.waitForTimeout(300) const revived = page.locator('[data-source="zz_unmigrated"]') check('……替换后按来源分到「表格」,写当前版本的文件名和「重新上传」', await revived.count() === 1 && (await revived.locator('[data-current-version]').innerText().catch(() => '')).includes('zz_unmigrated.csv') && await revived.getByRole('button', { name: '重新上传' }).count() === 1) await goto(page, '/data/databases') const manual = page.locator('[data-source="zz_lookalike"]') check('「数据库」标签里是手工 SQLite(照常能探查结构),没有上传的表格', await manual.count() === 1 && await manual.getByRole('button', { name: /探查结构/ }).count() === 1 && await page.locator(`[data-source="${up.name}"]`).count() === 0 && await page.locator('[data-source="zz_unmigrated"]').count() === 0) check('没有对上传的表格发探查请求', !sent.some((s) => /introspect/.test(s.key))) check('上传一共发了 2 次(被拒收一次、替换一次)', sent.filter((s) => s.key === 'POST /datasources/upload').length === 2, String(sent.filter((s) => s.key === 'POST /datasources/upload').length)) check('没有原生对话框', natives.length === 0, natives.join(' | ')) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await close() }) await section('知识库', async () => { 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() }) await section('知识库 · 检索贡献条与重建进度', async () => { 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('正在重建索引(全部集合,已完成 3/15)')) 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() }) await section('长期记忆', async () => { 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/')) // content 是沙箱里一条真实的记忆:只报有没有发、键名 check('原地修改走 PATCH', patch?.body?.content?.endsWith('(已核对)'), patch?.body?.content?.endsWith('(已核对)') ? '' : patch ? `键:${Object.keys(patch.body ?? {}).join(',')}` : '没发 PATCH') 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() }) await section('目录没取回来:空态不冒充「还没有」', async () => { 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() } }) await section('Skill', async () => { 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() }) await section('工具', async () => { const danger = tools.find((t) => t.runtime_approval) const safe = tools.find((t) => t.source === 'builtin' && !t.dangerous) let calls = 0 // 库里早就存着的坏参数定义:后端在工具项上给 problem const brokenProblem = '参数定义格式有误:参数 store 应写成 {"type": "string"} 这样的对象,不能直接写 "string"' const brokenTool = { id: 'broken_demo', name: 'broken_demo', description: '检查脚本的坏工具', category: '自定义 · http', source: 'custom', dangerous: true, runtime_approval: false, schema: { properties: { store: 'string' } }, problem: brokenProblem } const brokenRow = { id: 'check-manage-broken', name: 'broken_demo', kind: 'http', description: '检查脚本的坏工具', parameters: { properties: { store: 'string' } }, config: { method: 'GET', url: 'https://example.com/x' }, enabled: true, problem: brokenProblem } const { page, sent, natives, errors, close } = await open(danger ? `/tools/library/${danger.id}` : '/tools', { handlers: [ [/^GET \/tools$/, async (route) => json([...await (await route.fetch()).json(), brokenTool])(route)], [/^GET \/custom-tools$/, async (route) => json([...await (await route.fetch()).json(), brokenRow])(route)], [/^POST \/tools\/[^/]+\/run$/, (route, { body }) => { calls++ return body?.confirm ? json({ ok: true, result: 'done', duration_ms: 5, note: '参数名 cuont 不存在,已按唯一候选 count 执行' })(route) : json({ detail: '将在工作目录中写入「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) check('……结果和提醒落在看得见的地方(不用自己滚)', await inView(page.locator('[data-tool-note]'))) } { const item = page.locator('nav[aria-label="工具列表"] button', { hasText: 'broken_demo' }).first() const chip = item.locator('[data-tool-problem]') check('工具库:参数定义写坏的自定义工具挂醒目的 chip,悬停看完整原因', await chip.count() === 1 && (await chip.getAttribute('title')) === brokenProblem, await chip.innerText().catch(() => '')) await item.click() await page.waitForTimeout(300) const alert = page.locator('[data-tool-problem-detail]') check('……详情区写明原因和「绑了它的节点一定失败」', (await alert.innerText().catch(() => '')).includes('一定失败')) await shot(page, 'tool-broken-detail') await alert.getByRole('button', { name: '去改参数定义' }).click() const ed0 = page.getByRole('dialog', { name: '编辑工具「broken_demo」' }) // 等编辑框真的出来再判:并行跑时固定等 700ms 不够,编辑框还没挂上就判成没打开(check-all 里偶发过) await ed0.waitFor({ timeout: 5000 }).catch(() => {}) await page.waitForTimeout(200) check('……「去改参数定义」直接打开它的编辑框(/tools/custom?edit=)', await ed0.count() === 1, page.url()) check('……读完 ?edit= 就从地址里摘掉', !page.url().includes('edit='), page.url()) const perr = await ed0.locator('[data-params-field]').innerText().catch(() => '') check('……编辑框一打开,参数定义下面就写着坏在哪', perr.includes('参数 store'), perr.replace(/\s+/g, ' ').slice(0, 120)) const row0 = page.locator('div.rounded-lg', { hasText: 'broken_demo' }).last() await ed0.getByRole('button', { name: '取消' }).click() await page.waitForTimeout(300) check('自定义工具列表:坏工具那一行也挂 chip,并写出原因', await row0.locator('[data-tool-problem]').count() === 1 && (await row0.innerText()).includes('参数 store')) } 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) check('……试运行结果和提醒自己滚进弹窗的可视范围(试运行区在编辑框最底下)', await inView(trialBox.locator('[data-tool-note]'))) 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() }) await section('自定义工具 · 已有工具', async () => { // 参数是 {} 的已有工具:编辑器要显示存着的样子,只改描述不能把示例参数一起存回去 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)], [/^POST \/custom-tools$/, json({ detail: '参数定义格式有误:无法识别参数 n 的类型「int」,是否应为 integer;只能是 string、integer、number、boolean、array、object 中的一个' }, 422)], [/^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() // 新建时参数定义写坏了:后端 422 的那句话写在「参数 JSON Schema」下面,不是盖在标题上的 toast await page.getByRole('button', { name: /新建工具/ }).first().click() const nt = dialog(page) await nt.getByLabel(/^名称/).fill('bad_schema_demo') await nt.getByLabel(/^URL/).fill('https://example.com/x') await nt.getByRole('button', { name: '保存' }).click() await page.waitForTimeout(500) const pe = await nt.locator('[data-params-field]').innerText().catch(() => '') check('新建时保存被 422 拒掉:原因写在「参数 JSON Schema」下面', pe.includes('int') && pe.includes('integer'), pe.replace(/\s+/g, ' ').slice(0, 120)) check('……不再弹 toast', !(await page.locator('[aria-live="assertive"]').innerText().catch(() => '')).includes('integer')) // toast 在 assertive 区里会被念出来;挪进字段之后,要靠焦点落到框上、框标成出错并指向那句话 const paramsBox = nt.locator('[data-params-field] textarea') const described = await paramsBox.getAttribute('aria-describedby').catch(() => null) const describedText = described ? await page.evaluate((ids) => ids.split(/\s+/).map((id) => document.getElementById(id)?.textContent ?? '').join(' '), described) : '' check('……参数框标成出错(aria-invalid),aria-describedby 指向那句原因', (await paramsBox.getAttribute('aria-invalid')) === 'true' && describedText.includes('integer'), `aria-invalid=${await paramsBox.getAttribute('aria-invalid')} · describedby=${described ?? '无'}`) check('……焦点落在参数框上(保存按钮忙时禁用,以前焦点掉到 body、弹窗外)', await paramsBox.evaluate((el) => el === document.activeElement), await page.evaluate(() => document.activeElement?.tagName ?? 'null')) check('……参数框描红', await paramsBox.evaluate((el) => getComputedStyle(el).borderTopColor) === await page.evaluate(() => { const d = document.createElement('div'); d.style.color = 'var(--err)'; document.body.append(d); const c = getComputedStyle(d).color; d.remove(); return c })) check('……弹窗还开着,填的东西都在', await nt.getByLabel(/^名称/).inputValue() === 'bad_schema_demo') await shot(page, 'custom-tool-schema-error') await nt.getByRole('button', { name: '取消' }).click() await page.waitForTimeout(200) const discard0 = dialog(page).getByRole('button', { name: '放弃修改' }) if (await discard0.count()) await discard0.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() }) await section('工具 · 信任三档', async () => { // 后端的 trust / trust_key 可能还没上线:GET /tools 在真列表后面补三件假的(一个自定义、两个 MCP), // PUT /tools/trust 一律拦下来。fail 打开时回 422,看界面退回原样、说出后端给的原因 const custom = { id: 'crm_lookup', name: 'crm_lookup', description: '按客户编号查客户档案', category: '自定义 · http', source: 'custom', dangerous: true, runtime_approval: true, schema: {}, problem: null, trust: 'ask', trust_key: 'crm_lookup' } const search = { id: 'mcp:demo/search', name: 'search', server: 'demo', description: '全文检索', category: 'MCP · demo', source: 'mcp', dangerous: true, runtime_approval: true, schema: {}, trust: 'gated', trust_key: 'mcp:demo/search' } const weather = { id: 'mcp:demo/weather', name: 'weather', server: 'demo', description: '查天气', category: 'MCP · demo', source: 'mcp', dangerous: true, runtime_approval: false, schema: {}, trust: 'always', trust_key: 'mcp:demo/weather' } const customRow = { id: 'check-trust-crm', name: 'crm_lookup', kind: 'http', description: '按客户编号查客户档案', parameters: {}, config: { method: 'GET', url: 'https://example.com/crm' }, enabled: true, problem: null } const server = { id: 'check-trust-demo', name: 'demo', transport: 'http', command: null, args: [], env: {}, url: 'https://example.com/mcp', enabled: true, status: 'ok', last_error: null, tools_cache: ['search', 'weather'], last_checked_at: null, last_check_ok: null, last_latency_ms: null } let fail = false const builtin = tools.find((t) => t.source === 'builtin') const { page, sent, natives, errors, close } = await open('/tools/library/crm_lookup', { handlers: [ [/^GET \/tools$/, async (route) => json([...await (await route.fetch()).json(), custom, search, weather])(route)], [/^GET \/custom-tools$/, json([customRow])], [/^GET \/mcp\/servers$/, json([server])], [/^PUT \/tools\/trust$/, (route, { body }) => (fail ? delayed(500, { detail: '信任档位只能是 ask、gated、always,当前为「bogus」' }, 422)(route) : delayed(150, { key: body?.key, trust: body?.trust })(route))], ], }) const puts = () => sent.filter((s) => s.key === 'PUT /tools/trust') const group = (name) => page.getByRole('radiogroup', { name: `${name} 的运行时审批` }) const checkedIn = (g) => g.locator('[aria-checked="true"]').innerText().catch(() => '') // 按名字整段认:search 这种短名会撞上内置工具名里的子串 const listItem = (name) => page.locator('nav[aria-label="工具列表"] button') .filter({ has: page.locator('span.mono', { hasText: new RegExp(`^${name}$`) }) }).first() const detail = group('crm_lookup') check('自定义工具:详情区有三选一,三档是需审批 / 始终允许 · 门控把关 / 始终允许', (await detail.getByRole('radio').allInnerTexts()).join('|') === '需审批|始终允许 · 门控把关|始终允许', (await detail.getByRole('radio').allInnerTexts().catch(() => [])).join('|')) check('……现在选中的是后端给的「需审批」', await checkedIn(detail) === '需审批', await checkedIn(detail)) check('……旁边说清三档的差别,写明正式运行不看这个设置', (await page.locator('[data-trust-block]').innerText()).includes('正式运行不受此设置影响')) check('徽标按信任档:需审批挂「运行时需审批」', (await listItem('crm_lookup').innerText()).includes('运行时需审批')) check('……门控把关挂「门控把关」', (await listItem('search').innerText()).includes('门控把关') && !(await listItem('search').innerText()).includes('运行时需审批')) const alwaysText = await listItem('weather').innerText() check('……始终允许什么都不挂', !alwaysText.includes('审批') && !alwaysText.includes('门控'), alwaysText.replace(/\s+/g, ' ')) await shot(page, 'tool-trust-library') // 改档:乐观更新,PUT 的载荷是 {key, trust} await detail.getByRole('radio', { name: '始终允许 · 门控把关' }).click() await page.waitForTimeout(40) check('改档先改界面(请求还没回来就已经选中、徽标跟着变)', await checkedIn(detail) === '始终允许 · 门控把关' && (await listItem('crm_lookup').innerText()).includes('门控把关'), await checkedIn(detail)) await page.waitForTimeout(300) const put = puts().at(-1) check('……PUT /tools/trust 的载荷是 {key: crm_lookup, trust: gated}', put?.body?.key === 'crm_lookup' && put?.body?.trust === 'gated' && Object.keys(put?.body ?? {}).length === 2, JSON.stringify(put?.body ?? null)) // 方向键:整组一个 Tab 位,→ 选下一档并保存 await detail.getByRole('radio', { name: '始终允许 · 门控把关' }).focus() await page.keyboard.press('ArrowRight') await page.waitForTimeout(300) check('……方向键也能改(→ 到「始终允许」,同样发 PUT)', puts().at(-1)?.body?.trust === 'always' && await checkedIn(detail) === '始终允许', JSON.stringify(puts().map((p) => p.body?.trust))) check('……始终允许之后徽标不挂了', !(await listItem('crm_lookup').innerText()).includes('审批')) // 失败:退回原来那一档,并说出后端给的原因 fail = true await detail.getByRole('radio', { name: '需审批' }).click() await page.waitForTimeout(120) const optimistic = await checkedIn(detail) await page.waitForTimeout(700) const reverted = await checkedIn(detail) const alert = await page.locator('[role="alert"][aria-live="assertive"]').innerText().catch(() => '') check('PUT 失败:先按新的显示,回来后退回「始终允许」', optimistic === '需审批' && reverted === '始终允许', `${optimistic} → ${reverted}`) check('……徽标也退回去(始终允许不挂)', !(await listItem('crm_lookup').innerText()).includes('审批')) check('……出错提示说哪个工具没改成、为什么', alert.includes('crm_lookup') && alert.includes('修改失败') && alert.includes('ask、gated、always'), alert.replace(/\s+/g, ' ').slice(0, 120)) fail = false await page.locator('[role="alert"][aria-live="assertive"] button[aria-label*="关闭"]').first().click().catch(() => {}) // MCP 工具的 id 带斜杠(mcp:demo/search):以前点它落到 404,详情区里的三选一根本到不了 await listItem('search').click() await page.waitForTimeout(300) check('MCP 工具在工具库里点得开,详情区有三选一(门控把关选中)', await checkedIn(group('mcp:demo/search')) === '始终允许 · 门控把关', page.url().replace(WEB, '')) // 内置工具没有这个控件 if (builtin) { await listItem(builtin.name).click() await page.waitForTimeout(300) check('内置工具的详情区没有三选一', await page.locator('[data-trust-control]').count() === 0 && await page.locator('[data-trust-block]').count() === 0) } await page.getByRole('tab', { name: '自定义工具' }).click() await page.waitForTimeout(400) const row = page.locator('div.rounded-lg', { hasText: 'crm_lookup' }).last() check('自定义工具列表:那一行有三选一,跟着刚才改的是「始终允许」', await checkedIn(row.getByRole('radiogroup')) === '始终允许') check('……列表上方说清三档的差别', (await page.locator('[data-trust-explain]').innerText().catch(() => '')).includes('门控把关指每次先由小模型检查参数')) await shot(page, 'tool-trust-custom') await page.getByRole('tab', { name: 'MCP 接入' }).click() await page.waitForTimeout(400) const card = page.locator('article', { hasText: 'demo' }) check('MCP 卡片列出这台服务的工具,每个一组三选一', await card.getByRole('radiogroup').count() === 2 && await checkedIn(group('mcp:demo/search')) === '始终允许 · 门控把关' && await checkedIn(group('mcp:demo/weather')) === '始终允许') await group('mcp:demo/weather').getByRole('radio', { name: '需审批' }).click() await page.waitForTimeout(300) check('……MCP 工具改档,键是 mcp:服务名/工具名', puts().at(-1)?.body?.key === 'mcp:demo/weather' && puts().at(-1)?.body?.trust === 'ask', JSON.stringify(puts().at(-1)?.body ?? null)) await shot(page, 'tool-trust-mcp') check('改档只发 PUT /tools/trust,没有别的写请求', sent.filter((s) => s.key !== 'PUT /tools/trust' && !s.key.startsWith('GET ')).length === 0, sent.filter((s) => s.key !== 'PUT /tools/trust' && !s.key.startsWith('GET ')).map((s) => s.key).join(', ')) check('没有原生对话框', natives.length === 0, natives.join(' | ')) // 渲染崩了由错误边界接住,不一定有 pageerror:边界的兜底页也算 check('没有运行时报错', errors.length === 0 && await page.locator('[data-error-scope]').count() === 0, errors[0] ?? '') await close() // 老后端:工具项上没有 trust / trust_key。界面照旧:没有三选一,自定义 / MCP 写「运行时不审批」 const strip = ({ trust, trust_key, ...rest }) => ({ ...rest, runtime_approval: false }) const old = await open('/tools/library/crm_lookup', { handlers: [ [/^GET \/tools$/, async (route) => json([...await (await route.fetch()).json(), strip(custom), strip(search)])(route)], [/^GET \/custom-tools$/, json([customRow])], [/^GET \/mcp\/servers$/, json([server])], ], }) check('老后端:详情区没有三选一', await old.page.locator('[data-trust-control]').count() === 0 && await old.page.locator('[data-trust-block]').count() === 0) check('……徽标照旧写「运行时不审批」', (await old.page.locator('nav[aria-label="工具列表"] button', { hasText: 'crm_lookup' }).first() .innerText({ timeout: 3000 }).catch(() => '')).includes('运行时不审批')) // 页面要是渲染崩了,后面几项照样跑完,最后那条「没有运行时报错」说出原因 await old.page.getByRole('tab', { name: '自定义工具' }).click({ timeout: 3000 }).catch(() => {}) await old.page.waitForTimeout(400) check('……自定义工具列表也没有,没有那段说明', await old.page.locator('[data-trust-control]').count() === 0 && await old.page.locator('[data-trust-explain]').count() === 0) await old.page.getByRole('tab', { name: 'MCP 接入' }).click({ timeout: 3000 }).catch(() => {}) await old.page.waitForTimeout(400) check('……MCP 卡片不列工具', await old.page.locator('[data-trust-control]').count() === 0 && await old.page.locator('article ul').count() === 0) check('……没有运行时报错', old.errors.length === 0 && await old.page.locator('[data-error-scope]').count() === 0, old.errors[0] ?? '') await old.close() }) await section('知识库 · 撤销窗口里的轮询', async () => { // 有文档在处理时列表 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() }) await section('知识库 · 切块地图', async () => { 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)