Files
AgentLab/scripts/check-manage.mjs

4817 lines
362 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 管理页(工具 / 知识 / 数据 / 设置)的交互回归。
//
// 这几页的毛病都不在"渲染不出来",而在交互的细处:写操作失败一声不响、中文
// 输入法选词的回车把半句话写进长期记忆、原生 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 { readFileSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
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)
}
window.__importForms = []
XMLHttpRequest.prototype.send = function (body) {
const entries = () => Object.fromEntries([...body.entries()]
.map(([k, v]) => [k, typeof v === 'string' ? v : `<file ${v.name}>`]))
if (/\/datasources\/upload(\?|$)/.test(this.__formUrl ?? '') && body instanceof FormData) {
window.__uploadForms.push(entries())
}
// 按配方导入的两个上传口(暂存、上传新一期)另记一份:不挤占上面那份的下标
if (/\/datasources\/(imports\/stage|[^/]+\/reupload)(\?|$)/.test(this.__formUrl ?? '') && body instanceof FormData) {
window.__importForms.push({ url: this.__formUrl, ...entries() })
}
return send.call(this, body)
}
}
const uploadForms = (page) => page.evaluate(() => window.__uploadForms ?? [])
const importForms = (page) => page.evaluate(() => window.__importForms ?? [])
/**
* 开一页: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) }
/**
* 后台探查(next-round 03 A1)的假任务:POST /introspect 回 202 和排队中的任务,GET 任务回做完了的样子。界面开始探查后
* 每秒取一次进度,结束了换上任务里带回来的数据源
*/
const introspectJob = (sourceId, total = 3, extra = {}) => ({
id: `job-${sourceId}`, source_id: sourceId, status: 'queued', trigger: 'manual', schema: '', created_by: null,
created_at: ago(0), started_at: null, finished_at: null, total, done: 0,
counts: { pending: total, running: 0, ok: 0, failed: 0, cancelled: 0 }, current: null, failures: [], error: null,
scope: {}, waiting_for: null, ...extra,
})
const introspectDone = (job) => ({ ...job, status: 'succeeded', done: job.total, finished_at: ago(0),
counts: { ...job.counts, pending: 0, running: 0, ok: job.total } })
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 和 ⌥ 数字不走 <a>:以前页面自己在捕获阶段拦 <a> 的点击,这两条路拦不住
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 () => {
// 数据目录「用模型起草」和按配方导入表格时的 AI 起草用的模型(catalog_draft 一组):两项都留空跟随助手的模型;只填模型时
// 按模型名找接入;存着的接入停用了在那一格下面说;服务端按项拒收(422 带 field)时整句落到那一格下面。设置读写、接入表都是假的
const PROVIDERS = [
{ id: 'check-draft-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-draft-p2', name: 'demo-wide', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'wide-1',
models: [{ id: 'wide-1' }, { id: 'wide-2' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
{ id: 'check-draft-p3', name: 'demo-off', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'off-model',
models: [{ id: 'off-model' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
]
let stored = await get('/settings')
stored = { ...stored, catalog_draft: { provider: null, model: null } }
// 服务端的说法(api/settings._provider_problem):主语是这一项在设置页上的叫法
const REFUSED = '「起草模型 · 接入」指定的模型接入「demo-wide」已停用。请在「设置 → 模型接入」中启用它,或换一个接入'
let refuse = false
const { page, sent, errors, close } = await open('/settings/prefs', {
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => {
if (refuse) {
refuse = false
return route.fulfill({ status: 422, contentType: 'application/json',
body: JSON.stringify({ detail: REFUSED, code: 'settings_provider_invalid', field: 'catalog_draft.provider' }) })
}
stored = { ...stored, ...body.values }
return json(stored)(route)
}],
[/^GET \/providers$/, json(PROVIDERS)],
],
})
const box = page.locator('[data-draft-model-settings]')
await box.waitFor()
const text = (await box.innerText()).replace(/\s+/g, ' ')
check('偏好设置里有「起草模型」一节:说明数据目录和导入表格的起草都用它,留空跟随助手的模型',
text.includes('起草模型') && text.includes('数据目录') && text.includes('导入表格') && text.includes('跟随助手的模型'), text.slice(0, 160))
const provider = page.getByLabel('起草模型 · 接入')
const model = page.getByLabel('起草模型 · 模型')
check('……接入留空写「跟随助手的模型」,模型的占位也写明', (await provider.locator('option').first().innerText()) === '跟随助手的模型'
&& (await model.getAttribute('placeholder')) === '留空:跟随助手的模型', await provider.locator('option').first().innerText())
check('……停用的接入列着但选不了', await provider.locator('option[value="demo-off"]').evaluate((o) => o.disabled))
// 只填模型:按模型名找接入,写明用的是哪家
await model.fill('wide-2')
check('只填模型:写明按模型名使用哪个接入', (await box.innerText()).includes('将按模型名使用接入「demo-wide」'), (await box.innerText()).replace(/\s+/g, ' ').slice(0, 200))
check('……接入那一格的空选项改叫「按模型名找接入」', (await provider.locator('option').first().innerText()) === '按模型名找接入')
await provider.selectOption('demo-wide')
const bar = (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……改了两项:保存条写「起草模型 · 接入、起草模型 · 模型」', bar.includes('有 2 项未保存') && bar.includes('起草模型 · 接入')
&& bar.includes('起草模型 · 模型'), bar)
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(500)
const put = sent.filter((s) => s.key === 'PUT /settings').at(-1)
check('保存只 PUT catalog_draft 一组:接入、模型', Object.keys(put?.body?.values ?? {}).join() === 'catalog_draft'
&& JSON.stringify(put.body.values.catalog_draft) === JSON.stringify({ provider: 'demo-wide', model: 'wide-2' }), JSON.stringify(put?.body?.values))
await shot(page, 'prefs-draft-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 put2 = sent.filter((s) => s.key === 'PUT /settings').at(-1)?.body?.values?.catalog_draft
check('……两项都留空:存成 null', JSON.stringify(put2) === JSON.stringify({ provider: null, model: null }), JSON.stringify(put2))
// 服务端按项拒收:整句落到接入那一格下面,焦点移过去
refuse = true
await provider.selectOption('demo-wide')
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(500)
const said = await page.locator('#pref-draft-provider-error').innerText().catch(() => '')
check('服务端按项拒收(422 带 field):整句落到「起草模型 · 接入」下面', said === REFUSED, said)
check('……焦点移到那一格', await page.locator('#pref-draft-provider').evaluate((el) => el === document.activeElement))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
// 存着的接入后来停用了:那一格下面说清起草会怎样
const disabled = { ...stored, catalog_draft: { provider: 'demo-off', model: null } }
const off = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(disabled)(route)], [/^GET \/providers$/, json(PROVIDERS)]] })
await off.page.locator('[data-draft-model-settings]').waitFor().catch(() => {})
const offSaid = await off.page.locator('#pref-draft-provider-error').innerText().catch(() => '')
check('存着的接入已停用:那一格下面说清用模型起草会失败', offSaid.includes('模型接入「demo-off」已停用') && offSaid.includes('起草'), offSaid)
check('没有运行时报错', off.errors.length === 0, off.errors[0] ?? '')
await off.close()
// 老服务端:设置里没有这一组,不显示这一节
const legacy = Object.fromEntries(Object.entries(stored).filter(([k]) => k !== 'catalog_draft'))
const old = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(legacy)(route)], [/^GET \/providers$/, json(PROVIDERS)]] })
check('老服务端没有这一组:不显示「起草模型」,也没有保存条', await old.page.locator('[data-draft-model-settings]').count() === 0
&& await old.page.locator('[data-prefs-bar]').count() === 0)
await old.close()
})
await section('设置 · 应用:使用者可以建个人应用(只有管理员能改)', async () => {
// 管理员开关 apps.user_personal_apps(docs/design/normal-user-apps.md N7):在「应用」一组,和按需视图每日上限一起整组写;
// 只有管理员能改,搭建者看得到、改不了。使用者那边的入口随认人接口的 user_personal_apps 变(check-auth 查)
let stored = await get('/settings')
stored = { ...stored, apps: { ...(stored.apps ?? {}), on_demand_daily_limit: 200, user_personal_apps: false } }
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-apps-settings]')
await box.waitFor()
const toggle = box.locator('[data-apps-personal]')
check('「应用」一组里有「使用者可以建个人应用」,存着的是关', await toggle.count() === 1 && !(await toggle.isChecked())
&& (await box.innerText()).includes('使用者可以建个人应用'))
const hint = await page.locator('#pref-apps-personal-hint').innerText().catch(() => '')
check('……写明开了之后使用者能做什么:只有自己看得见、只读遮罩、给别人用要审核', hint.includes('只有自己看得见')
&& hint.includes('遮罩') && hint.includes('审核'), hint)
check('管理员(关登录)可以改', await toggle.isEnabled())
await toggle.check()
const bar = (await page.locator('[data-prefs-bar]').innerText().catch(() => '')).replace(/\s+/g, ' ')
check('……保存条写「使用者可以建个人应用」', bar.includes('有 1 项未保存') && bar.includes('使用者可以建个人应用'), bar)
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(500)
const put = sent.filter((s) => s.key === 'PUT /settings').at(-1)?.body?.values
check('保存只 PUT apps 一组:开关写成 true,每日上限原样带回', Object.keys(put ?? {}).join() === 'apps'
&& put.apps.user_personal_apps === true && put.apps.on_demand_daily_limit === 200, JSON.stringify(put))
check('……读回来还是开着', await toggle.isChecked())
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
// 搭建者(开了登录):开关照服务端显示,改不了,写明只有管理员可以修改
const builder = await open('/settings/prefs', {
handlers: [
[/^GET \/auth\/me$/, json({ mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true,
user_personal_apps: true })],
[/^GET \/settings$/, (route) => json({ ...stored, apps: { ...stored.apps, user_personal_apps: true } })(route)],
],
})
const t2 = builder.page.locator('[data-apps-personal]')
await t2.waitFor()
check('搭建者:开关照服务端显示(开着),但改不了', await t2.isChecked() && await t2.isDisabled())
check('……写明只有管理员可以修改', (await builder.page.locator('[data-prefs-readonly]').innerText().catch(() => '')).includes('只有管理员'))
await t2.click({ force: true, timeout: 2000 }).catch(() => {})
await builder.page.waitForTimeout(300)
check('……点了也不变、不出保存条、不发 PUT', await t2.isChecked() && await builder.page.locator('[data-prefs-bar]').count() === 0
&& !builder.sent.some((s) => s.key === 'PUT /settings'))
check('没有运行时报错', builder.errors.length === 0, builder.errors[0] ?? '')
await builder.close()
})
await section('设置 · 通用:对外访问地址', async () => {
// 全站只认一个对外访问地址(服务端 core/public_url.py):环境变量优先,其次这里填的(存在 notify 一组)。通知和 Webhook 的
// 链接、单点登录的回调、来源核对都按它。管理员能改;环境变量指定了的只显示、写明来源;搭建者只看
let stored = await get('/settings')
stored = { ...stored, notify: { ...(stored.notify ?? {}), public_base_url: null }, public_url: { value: null, env_locked: false } }
const { page, sent, errors, natives, close } = await open('/settings/general', {
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => {
stored = { ...stored, ...body.values, public_url: { value: body.values.notify?.public_base_url ?? null, env_locked: false } }
return json(stored)(route)
}],
],
})
const box = page.locator('[data-general-public-url]')
await box.waitFor()
check('设置页有「通用」标签,里面是对外访问地址', (await page.getByRole('tab', { name: '通用' }).getAttribute('aria-selected')) === 'true'
&& (await box.innerText()).includes('对外访问地址'))
const said = await box.innerText()
check('……写明它管哪几件事:通知和 Webhook 的链接、单点登录的回调、来源核对', said.includes('Webhook') && said.includes('单点登录')
&& said.includes('来源'), said.replace(/\s+/g, ' '))
const input = box.locator('[data-public-url-input]')
const save = box.locator('[data-public-url-save]')
check('没改动时保存不可点', await save.isDisabled())
await input.fill('https://agentlab.example.com/?from=x')
check('带 ? 的地址:当场标错、不让存', await save.isDisabled() && (await box.innerText()).includes('不带 ? 或 #'))
await input.fill('agentlab.example.com')
check('没写协议:同样不让存', await save.isDisabled())
await input.fill('https://agentlab.example.com/')
await page.getByRole('tab', { name: '运行环境' }).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/general')
&& (await input.inputValue()) === 'https://agentlab.example.com/')
await save.click()
await until(() => sent.some((x) => x.key === 'PUT /settings'))
const put = sent.filter((x) => x.key === 'PUT /settings').at(-1)?.body?.values
check('保存只 PUT notify 一组、去掉末尾的斜杠,同组别的项原样带回', Object.keys(put ?? {}).join() === 'notify'
&& put.notify.public_base_url === 'https://agentlab.example.com', JSON.stringify(put))
check('……存完按钮回到不可点', await until(() => save.isDisabled()))
check('没有运行时报错、原生对话框', errors.length === 0 && natives.length === 0, [...errors, ...natives][0] ?? '')
await close()
// 环境变量指定了:只显示它、写明来源,不给输入框
const env = await open('/settings/general', {
handlers: [[/^GET \/settings$/, json({ ...stored, public_url: { value: 'https://agentlab.example.internal', env_locked: true } })]],
})
const ro = env.page.locator('[data-public-url-readonly="env"]')
await ro.waitFor()
const roText = await ro.innerText()
check('环境变量指定的:显示它、写明由 AGENTLAB_PUBLIC_URL 指定,不给输入框', roText.includes('https://agentlab.example.internal')
&& roText.includes('AGENTLAB_PUBLIC_URL') && await env.page.locator('[data-public-url-input]').count() === 0, roText)
await env.close()
// 搭建者(开了登录):只看,写明只有管理员可以修改
const builder = await open('/settings/general', {
handlers: [
[/^GET \/auth\/me$/, json({ mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true })],
[/^GET \/settings$/, json(stored)],
],
})
const ro2 = builder.page.locator('[data-public-url-readonly="role"]')
await ro2.waitFor()
const ro2Text = await ro2.innerText()
check('搭建者:只显示当前值、写明只有管理员可以修改', ro2Text.includes('https://agentlab.example.com') && ro2Text.includes('只有管理员')
&& await builder.page.locator('[data-public-url-input]').count() === 0, ro2Text)
await builder.close()
})
await section('设置 · 点名的接入已停用或不存在', async () => {
// 点名停用的接入以前照常调用,现在运行时照实报错(K1):存着的值后来被停用了,那一格下面要说;别的停用接入列着但选不了;
// 服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的
const PROVIDERS = [
{ id: 'check-ref-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-ref-p2', name: 'demo-off', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'off-model',
models: [{ id: 'off-model' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
{ id: 'check-ref-p3', name: 'demo-spare', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'spare-1',
models: [{ id: 'spare-1' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
]
let stored = await get('/settings')
stored = {
...stored,
run: { ...(stored.run ?? {}), tool_gate_provider: 'demo-off', tool_gate_model: null },
judge: { ...(stored.judge ?? {}), provider: 'demo-off', model: null },
verify: { ...(stored.verify ?? {}), provider: 'demo-off', model: null },
}
// 服务端的说法(api/settings._provider_problem):下拉框里选的写「指定的」,不另讲留空是什么意思(空选项写着)
const REFUSED = '「门控模型 · 接入」指定的模型接入「demo-main」已停用。请在「设置 → 模型接入」中启用它,或换一个接入'
let refuse = true
const { page, sent, errors, close } = await open('/settings/prefs', {
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => {
if (refuse) {
refuse = false
return route.fulfill({ status: 422, contentType: 'application/json',
body: JSON.stringify({ detail: REFUSED, code: 'settings_provider_invalid', field: 'run.tool_gate_provider' }) })
}
stored = { ...stored, ...body.values }
return json(stored)(route)
}],
[/^GET \/providers$/, json(PROVIDERS)],
],
})
const said = async (id) => page.locator(`#${id}-error`).innerText().catch(() => '')
check('门控模型:存着的接入已停用,那一格下面说清', (await said('pref-gate-provider')).includes('模型接入「demo-off」已停用:门控模型无法调用,所有工具调用将转为人工审批'),
await said('pref-gate-provider'))
check('证据裁判:同样说清', (await said('pref-judge-provider')).includes('模型接入「demo-off」已停用:裁判调用将失败'), await said('pref-judge-provider'))
check('核查模型:同样说清', (await said('pref-verify-provider')).includes('模型接入「demo-off」已停用:核查调用将失败'), await said('pref-verify-provider'))
check('……模型那一格不再写「留空则使用 off-model」', !(await page.locator('[data-gate-model]').innerText()).includes('off-model'))
const gate = page.locator('#pref-gate-provider')
const off = (name) => gate.locator(`option[value="${name}"]`).evaluate((o) => o.disabled)
check('下拉:选着的停用接入照常显示,别的停用接入列着但选不了', !(await off('demo-off')) && await off('demo-spare') && !(await off('demo-main')))
await shot(page, 'prefs-provider-disabled')
await gate.selectOption('demo-main')
check('换成启用的接入:那一格的说明消失,刚才那个停用接入也选不回去了', !(await said('pref-gate-provider')) && await off('demo-off'))
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(500)
check('服务端按项拒收:整句落到那一格下面', (await said('pref-gate-provider')) === REFUSED, await said('pref-gate-provider'))
check('……焦点移到那一格', await gate.evaluate((el) => el === document.activeElement))
const bar = await page.locator('[data-prefs-save-error]').innerText().catch(() => '')
check('……保存条写到原因,不截在第一句', bar.startsWith('保存失败:') && bar.includes('请在「设置 → 模型接入」中启用它'), bar)
await gate.selectOption('')
check('再改这一项:服务端那句说明收起', !(await said('pref-gate-provider')))
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(500)
const put = sent.filter((x) => x.key === 'PUT /settings').at(-1)?.body?.values?.run ?? {}
check('……再保存:照常写进去', put.tool_gate_provider === null && (await page.locator('[data-prefs-save-error]').count()) === 0,
`provider=${JSON.stringify(put.tool_gate_provider)}`)
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('设置 · 停用接入的后果写全;接入名恰好是模型名时说清怎么用这个模型', async () => {
// 停用一个接入以后,点名它(或只写了它的模型)的节点和设置都调不了它(K1):卡片上「已停用」的悬停说明、编辑框里
// 「启用」下面的说明都要写到这一层,不只是「下拉里看不到」;调不了以后怎样(报错、转人工审批、记未裁判、记缺口)各组
// 自己说,这里不笼统写「运行时报错」。停用的接入恰好叫 big-model,而 big-model 是启用的 demo-main 提供的模型:设置页
// 三格下面补一句这是哪个接入的模型、在哪一栏用它,和服务端保存时 422 的那句一致。接入表、设置都是假的
const PROVIDERS = [
{ id: 'check-trap-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-trap-p2', name: 'big-model', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'old-model',
models: [{ id: 'old-model' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
]
const EFFECT = '该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也无法调用'
// 编辑框里「启用」的版式:「启用」两个字只占一行(以前长说明和它并排放在 label 里,1440 宽时把它挤成上下两个字);
// 说明不在 label 里,复选框的读屏名只是「启用」,说明由 aria-describedby 接上
const enabledLayout = (page) => page.evaluate(() => {
const hint = document.querySelector('[data-provider-enabled-hint]')
const box = [...document.querySelectorAll('[role="dialog"] input[type="checkbox"]')]
.find((el) => el.closest('label')?.textContent?.trim().startsWith('启用'))
const label = box?.closest('label')
const word = label && [...label.childNodes].find((n) => n.nodeType === 3 && n.textContent.includes('启用'))
const range = document.createRange()
if (word) range.selectNodeContents(word)
const lines = word ? new Set([...range.getClientRects()].filter((r) => r.width > 0).map((r) => Math.round(r.top))).size : 0
const d = document.querySelector('[role="dialog"]')
// 弹窗自己不出横向滚动条,「启用」一行和说明都在弹窗里
const fits = !!d && d.scrollWidth - d.clientWidth <= 0
&& [label, hint].every((el) => el && el.getBoundingClientRect().right <= d.getBoundingClientRect().right + 0.5)
return { lines, inLabel: !!label && !!hint && label.contains(hint), described: !!hint?.id && box?.getAttribute('aria-describedby') === hint.id, fits }
})
const cards = await open('/settings/providers', { handlers: [[/^GET \/providers$/, json(PROVIDERS)]], viewport: { width: 1440, height: 900 } })
const chip = cards.page.locator('[data-provider="big-model"] .chip', { hasText: '已停用' })
check('卡片上「已停用」:悬停写明点名它的节点和设置也无法调用', await chip.getAttribute('title').catch(() => '') === `已停用:${EFFECT}`,
await chip.getAttribute('title').catch(() => ''))
await cards.page.locator('[data-provider="big-model"]').getByRole('button', { name: '编辑' }).click()
const enabledHint = await cards.page.locator('[data-provider-enabled-hint]').innerText().catch(() => '')
check('编辑框「启用」下面:不只说下拉里看不到,也说点名它的节点和设置无法调用', enabledHint === `停用后,${EFFECT}`, enabledHint)
const wide = await enabledLayout(cards.page)
check('1440 宽:「启用」只占一行,说明另起一行、不在 label 里,由复选框的 aria-describedby 接上',
wide.lines === 1 && !wide.inLabel && wide.described && wide.fits, JSON.stringify(wide))
check('……复选框的读屏名只是「启用」', await cards.page.getByRole('checkbox', { name: '启用', exact: true }).count() === 1)
await shot(cards.page, 'provider-enabled-hint')
check('没有运行时报错', cards.errors.length === 0, cards.errors[0] ?? '')
await cards.close()
const phone = await open('/settings/providers', { handlers: [[/^GET \/providers$/, json(PROVIDERS)]], viewport: { width: 390, height: 844 } })
await phone.page.locator('[data-provider="big-model"]').getByRole('button', { name: '编辑' }).click()
await phone.page.locator('[data-provider-enabled-hint]').waitFor()
const narrow = await enabledLayout(phone.page)
check('390 宽:「启用」同样只占一行,说明在弹窗里折行,弹窗不出横向滚动条', narrow.lines === 1 && narrow.fits, JSON.stringify(narrow))
await shot(phone.page, 'provider-enabled-hint-390')
check('没有运行时报错', phone.errors.length === 0, phone.errors[0] ?? '')
await phone.close()
let stored = await get('/settings')
stored = {
...stored,
run: { ...(stored.run ?? {}), tool_gate_provider: 'big-model', tool_gate_model: null },
judge: { ...(stored.judge ?? {}), provider: 'big-model', model: null },
verify: { ...(stored.verify ?? {}), provider: 'big-model', model: null },
}
const { page, errors, close } = await open('/settings/prefs', {
handlers: [[/^GET \/settings$/, (route) => json(stored)(route)], [/^GET \/providers$/, json(PROVIDERS)]],
})
const said = async (id) => page.locator(`#${id}-error`).innerText().catch(() => '')
const note = (model, provider) => `「big-model」是接入「demo-main」的模型:如需使用该模型,请在「${model}」中填写,「${provider}」留空或选择「demo-main」`
check('门控模型:先说停用了、会怎样,再补一句这是 demo-main 的模型、在哪一栏用它',
await said('pref-gate-provider') === `模型接入「big-model」已停用:门控模型无法调用,所有工具调用将转为人工审批。${note('门控模型 · 模型', '门控模型 · 接入')}`,
await said('pref-gate-provider'))
check('证据裁判:同样补这一句,写裁判那两栏的叫法', (await said('pref-judge-provider')).endsWith(`。${note('裁判模型 · 模型', '裁判模型 · 接入')}`),
await said('pref-judge-provider'))
check('核查模型:同样补这一句,写核查那两栏的叫法', (await said('pref-verify-provider')).endsWith(`。${note('核查模型 · 模型', '核查模型 · 接入')}`),
await said('pref-verify-provider'))
await shot(page, 'prefs-provider-named-like-model')
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await 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('设置 · 分析(判定用的阈值、次数上限:叫法同契约,整组写 analysis,值不合规不让存,服务端的报错照原话显示)', async () => {
// 读的是真后端补齐缺省之后的一组(有就用它),写走假的一份
let stored = await get('/settings')
if (!stored.analysis) {
check('服务端的设置里有 analysis 一组(分析能力一轮)', false, Object.keys(stored).join(','))
return
}
const saved = { ...stored.analysis }
let reject = null
const { page, sent, errors, close } = await open('/settings/prefs', {
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => {
if (reject) return json({ detail: reject }, 422)(route)
stored = { ...stored, ...body.values }
return json(stored)(route)
}],
],
})
const sec = page.locator('[data-analysis-settings]')
await sec.waitFor({ timeout: 8000 }).catch(() => {})
const py = readFileSync(new URL('../backend/app/analysis/contract.py', import.meta.url), 'utf8')
const labels = [...(py.match(/^LABELS: dict\[str, str\] = \{([\s\S]*?)^\}/m)?.[1] ?? '').matchAll(/"([^"]+)":\s*"([^"]+)"/g)]
const text = await sec.innerText().catch(() => '')
check('这一节在偏好设置里,十七项的叫法都是契约 LABELS 的', labels.length === 17 && labels.every(([, , label]) => text.includes(label)),
labels.filter(([, , label]) => !text.includes(label)).map(([, k]) => k).join('、'))
check('……分成四组:变化拆解、波动基线与趋势判断、差异检验、次数与层数',
(await sec.locator('[data-analysis-settings-group]').evaluateAll((els) => els.map((e) => e.dataset.analysisSettingsGroup))).join('、')
=== '变化拆解、波动基线与趋势判断、差异检验、次数与层数')
const val = (k) => page.locator(`[data-analysis-setting="${k}"]`).inputValue()
check('显示的是服务端生效的值', await val('main_share') === String(saved.main_share) && await val('min_history') === String(saved.min_history)
&& await val('max_calls') === String(saved.max_calls), `${await val('main_share')} / ${await val('min_history')} / ${await val('max_calls')}`)
check('……每项下面写着范围(同契约 RANGES)', text.includes('0.3 到 0.9 之间') && text.includes('4 到 104 之间的整数'))
await page.locator('[data-analysis-setting="main_share"]').fill('0.95')
const bar = page.locator('[data-prefs-bar]')
await bar.waitFor({ timeout: 3000 }).catch(() => {})
check('超出范围:当场说(说法同服务端),保存键不能点', (await sec.innerText()).includes('「主要来源」的占比要在 0.3 到 0.9 之间')
&& await bar.getByRole('button', { name: '保存设置' }).isDisabled())
await page.locator('[data-analysis-setting="min_history"]').fill('9.5')
check('整数项填了小数:说要填整数', (await sec.innerText()).includes('至少几期历史要填整数'))
await page.locator('[data-analysis-setting="main_share"]').fill('0.6')
await page.locator('[data-analysis-setting="min_history"]').fill('10')
const barText = await bar.innerText().catch(() => '')
check('保存条写改了哪几项(契约的叫法)', barText.includes('有 2 项未保存') && barText.includes('「主要来源」的占比')
&& barText.includes('至少几期历史'), barText.replace(/\s+/g, ' '))
await bar.getByRole('button', { name: '保存设置' }).click()
await page.waitForTimeout(500)
const put = sent.filter((x) => x.key === 'PUT /settings').at(-1)?.body?.values ?? {}
const a = put.analysis ?? {}
check('整组写 analysis(十七项都在,没改的照原值、写成数)', Object.keys(a).length === 17 && a.main_share === 0.6 && a.min_history === 10
&& a.k_warn === saved.k_warn && a.table_top_k === saved.table_top_k, JSON.stringify(a).slice(0, 160))
check('……只改了分析:不碰别的组', Object.keys(put).join(',') === 'analysis', Object.keys(put).join(','))
check('……存完显示「已保存」', (await bar.innerText().catch(() => '')).includes('已保存'))
// 两两之间的大小交给服务端核:「明显超出」不大于「超出常规波动」时 422,原话写在保存条上,草稿留着
reject = '「明显超出」的倍数要大于「超出常规波动」的倍数'
await page.locator('[data-analysis-setting="k_alert"]').fill('1.5')
await bar.getByRole('button', { name: '保存设置' }).click()
await page.waitForTimeout(500)
const failed = await bar.innerText().catch(() => '')
check('服务端拒收:保存条照原话写为什么,草稿还在', failed.includes(reject) && await val('k_alert') === '1.5', failed.replace(/\s+/g, ' '))
reject = null
await page.locator('[data-analysis-setting="k_alert"]').fill(String(stored.analysis.k_alert))
check('改回原值:不再算改动', !(await bar.innerText().catch(() => '')).includes('未保存'))
if (SHOTS) {
await sec.scrollIntoViewIfNeeded()
await page.waitForTimeout(200)
await sec.screenshot({ path: `${SHOTS}/prefs-analysis-${THEME}.png` })
}
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
// 老后端没有 analysis 这一组:不显示,也不写
const legacy = { ...stored }
delete legacy.analysis
const old = await open('/settings/prefs', { handlers: [[/^GET \/settings$/, (route) => json(legacy)(route)]] })
check('老后端没有分析设置:这一组不出现', await old.page.locator('[data-analysis-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({ job: introspectJob(cur.id), source: cur }, 202)(route)
}],
[/^POST \/datasources\/check-manage-drift\/introspect$/, (route) => {
drift = { ...drift, cached_schema: drift.options.schema, schema_synced_at: ago(0) }
return json({ job: introspectJob(drift.id), source: drift }, 202)(route)
}],
[/^GET \/datasources\/check-manage-(empty|drift)\/introspect-jobs\/[^/]+$/, (route, { key }) => {
const row = key.includes('check-manage-empty') ? cur : drift
return json({ job: introspectDone(introspectJob(row.id)), source: row })(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({ job: introspectJob(id, row?.table_count ?? 1), source: { ...row, schema_synced_at: new Date().toISOString() } }, 202)(route)
}],
[/^GET \/datasources\/[^/]+\/introspect-jobs\/[^/]+$/, (route, { url }) => {
const id = url.pathname.split('/')[3]
const row = sources.find((s) => s.id === id)
return json({ job: introspectDone(introspectJob(id, row?.table_count ?? 1)), source: { ...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。删空的那一下高级参数一项都不剩,以前 <details> 跟着
// 收起,焦点掉到 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('数据 · 后台探查与探查范围', async () => {
// 探查是后台任务(next-round 03 A1):卡片上看得到进度、能停止、停了写明结果;几千张表的库按「探查范围」读取,规则命中
// 超过上限时服务端拒绝并说出命中数,界面给「调整探查范围」;范围面板边改边数命中(服务端算,不连库),说明一律在范围内的表。
// 全部是假的:一个 3,000 张表的假库,任务怎么往前走由检查推
const base = {
id: 'check-manage-big', name: 'zz_bigdb', kind: 'mysql', host: '10.0.0.8', port: 3306, database: 'erp', username: 'reader',
options: {}, readonly: true, description: '检查脚本的大库', enabled: true, password_masked: '', has_password: false,
table_count: 400, schema_synced_at: ago(86400_000), cached_schema: '', schema_error: '', available_schemas: [],
tools: ['db_query__zz_bigdb'], last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null,
origin: 'manual', current_snapshot: null, introspect_job: null,
schema_scope: { in_scope: 400, reflected: 400, failed: 0, out_of_scope: 2600, cap: 500, index_total: 3000, pinned: 3, explicit: 0,
rule: 397, on_demand: 0, index_method: 'dictionary', index_capped: false },
}
let big = base
let rules = { mode: 'all', include: [], exclude: [], tables: [], removed: [], views: true, max_tables: 500 }
let indexed = false
let job = null
const hitsOf = (r) => (r.mode === 'rules' && r.include.includes('ord_*') ? 400 : 3000)
const previewOf = (r) => {
const hits = hitsOf(r)
return { in_scope: hits, hits, cap: r.max_tables, out_of_scope: 3000 - hits, index_total: 3000, pinned: 3, explicit: 0,
rule: hits - 3, on_demand: 0, over: hits > r.max_tables }
}
const scopeOut = (r = rules) => ({
scope: rules, defaults: { max_tables: 500, max_limit: 2000, index_limit: 50000, rules_max: 100 },
preview: indexed ? previewOf(r) : null,
index: { ready: indexed, schema: '', method: indexed ? 'dictionary' : null, capped: false, at: indexed ? ago(0) : null, total: indexed ? 3000 : null },
pinned_sample: ['ord_dim_store', 'fin_ledger', 'mbr_card'], on_demand: [],
})
const { page, sent, natives, errors, close } = await open('/data/databases', {
handlers: [
[/^GET \/datasources$/, (route) => json([...sources, big])(route)],
[/^GET \/datasources\/check-manage-big\/introspect-scope$/, (route) => json(scopeOut())(route)],
[/^POST \/datasources\/check-manage-big\/introspect-scope\/preview$/, (route, { body }) => json(scopeOut(body.scope))(route)],
[/^PUT \/datasources\/check-manage-big\/introspect-scope$/, (route, { body }) => { rules = body.scope; return json(scopeOut())(route) }],
[/^POST \/datasources\/check-manage-big\/introspect$/, (route, { url }) => {
if (url.searchParams.get('dry_run') === 'true') {
indexed = true
return json({ dry_run: true, schema: '', table_count: 3000, tables: [], truncated: true, total: 3000, schema_error: '',
available_schemas: [], stored: true, scope: previewOf(rules), index_method: 'dictionary', index_capped: false })(route)
}
const hits = hitsOf(rules)
if (hits > rules.max_tables) {
return json({ detail: `按探查范围的规则命中 ${hits.toLocaleString('en-US')} 张表(其中有数据目录或被已发布模板用到、一律在范围内的 3 张),超过上限 500 张,没有开始探查。请在「探查范围」里收窄规则(加包含、排除的通配),或者把上限调高(最多 2,000 张)`,
code: 'scope_too_large', hits, cap: rules.max_tables, pinned: 3 }, 409)(route)
}
job = introspectJob(big.id, hits, { status: 'running', started_at: ago(0), counts: { pending: hits, running: 0, ok: 0, failed: 0, cancelled: 0 } })
return json({ job, source: big }, 202)(route)
}],
[/^GET \/datasources\/check-manage-big\/introspect-jobs\/[^/]+$/, (route) => {
if (job && job.status === 'running') {
const done = Math.min(job.done + 50, job.total - 25)
job = { ...job, done, current: `ord_line_${done}`, counts: { ...job.counts, ok: done, pending: job.total - done } }
}
return json({ job, source: big })(route)
}],
[/^POST \/datasources\/check-manage-big\/introspect-jobs\/[^/]+\/cancel$/, (route) => {
job = { ...job, status: 'cancelled', finished_at: ago(0), done: job.total,
counts: { ...job.counts, pending: 0, cancelled: job.total - job.counts.ok } }
big = { ...big, introspect_job: { id: job.id, status: 'cancelled', total: job.total, done: job.total, failed: 0,
cancelled: job.counts.cancelled, waiting_for: null, finished_at: ago(0), error: null } }
return json({ job, source: big })(route)
}],
],
})
const card = page.locator(`[data-source="${base.name}"]`)
await card.waitFor()
check('卡片上写探查范围:范围内几张 / 对象一共几张', (await card.locator('[data-introspect-scope]').getAttribute('data-introspect-scope')) === '400/3000'
&& (await card.locator('[data-introspect-scope]').innerText()).includes('探查范围 400 / 3,000'))
// 缺省范围(全部对象,上限 500)碰到 3,000 张:拒绝并说出命中数,给「调整探查范围」
await card.locator('[data-introspect-start]').click()
const toastBox = page.locator('[role="status"], [role="alert"]').filter({ hasText: '命中 3,000 张' }).first()
check('规则命中超过上限:照实说命中了多少张、上限多少', await toastBox.waitFor({ timeout: 4000 }).then(() => true, () => false))
const adjust = page.getByRole('button', { name: '调整探查范围' })
check('……给「调整探查范围」', await adjust.count() >= 1)
check('……没有开始任务:卡片上没有进度', await card.locator('[data-introspect-job]').count() === 0)
await adjust.first().click()
const dlg = page.locator('[data-scope-dialog]')
check('点了打开探查范围面板', await dlg.waitFor({ timeout: 4000 }).then(() => true, () => false))
// 还没有对象索引:先刷新(一条字典查询,只看不存)
check('……还没有对象索引时说清、给「刷新对象索引」', (await dlg.innerText()).includes('还没有对象索引'))
await dlg.locator('[data-scope-refresh]').first().click()
await until(() => dlg.locator('[data-scope-hits]').getAttribute('data-scope-hits').then((v) => v === '3000/500'))
const dry = sent.filter((x) => x.key === 'POST /datasources/check-manage-big/introspect').at(-1)
check('……刷新对象索引发的是 dry_run(不反射、不改表结构)', !!dry?.url.includes('dry_run=true'), dry?.url ?? '')
const hitsText = await dlg.locator('[data-scope-hits]').innerText()
check('……刷新后数出命中:超过上限的写明,会被拒绝', hitsText.includes('超过上限') && hitsText.includes('3,000'), hitsText.replace(/\s+/g, ' '))
check('……说明一律在范围内的表(有目录、被已发布模板用到)', (await dlg.locator('[data-scope-pinned]').innerText()).includes('规则排不掉'))
check('……超上限时「保存并探查」点不了', await page.locator('[data-scope-save-run]').isDisabled())
// 边改边数:改成按规则 + 包含 ord_*,停手后问服务端
await dlg.locator('[data-scope-mode="rules"]').check()
await dlg.locator('[data-scope-input="include"]').fill('ord_*\nord_*')
await until(() => dlg.locator('[data-scope-hits]').getAttribute('data-scope-hits').then((v) => v === '400/500'))
const pv = sent.filter((x) => x.key === 'POST /datasources/check-manage-big/introspect-scope/preview').at(-1)
check('改规则时边改边问命中数(服务端算):按规则、包含去重后的 ord_*', pv?.body?.scope?.mode === 'rules'
&& JSON.stringify(pv?.body?.scope?.include) === '["ord_*"]', JSON.stringify(pv?.body ?? {}))
check('……命中回到上限以内', (await dlg.locator('[data-scope-hits]').innerText()).includes('命中 400 张,上限 500 张'))
await shot(page, 'introspect-scope')
await page.locator('[data-scope-save-run]').click()
await until(() => card.locator('[data-introspect-job]').count().then((n) => n === 1))
const put = sent.filter((x) => x.key === 'PUT /datasources/check-manage-big/introspect-scope').at(-1)
check('「保存并探查」:先保存规则', put?.body?.scope?.mode === 'rules' && put?.body?.scope?.max_tables === 500, JSON.stringify(put?.body ?? {}))
check('……再开始探查:卡片上有进度条', await card.locator('[data-introspect-job] [role="progressbar"]').count() === 1)
await until(async () => ((await card.locator('[data-introspect-job]').getAttribute('data-introspect-progress')) ?? '').startsWith('100/'), 6000)
const progress = await card.locator('[data-introspect-job]').getAttribute('data-introspect-progress')
check('……每秒取一次进度,往前走', /^(1|2|3)\d\d\/400$/.test(progress ?? ''), progress ?? '')
check('……写正在读哪张', (await card.locator('[data-introspect-job]').innerText()).includes('正在读取'))
check('……进行中「探查结构」点不了,旁边有「停止」', await card.locator('[data-introspect-start]').isDisabled()
&& await card.locator('[data-introspect-stop]').count() === 1)
await shot(page, 'introspect-running')
// 停止:读完的照常更新,卡片常驻一行结果
await card.locator('[data-introspect-stop]').click()
await until(() => card.locator('[data-introspect-result="cancelled"]').count().then((n) => n === 1))
const result = await card.locator('[data-introspect-result]').innerText().catch(() => '')
check('停止后卡片上写明:已停止、读完了几张、其余保留上一次的结构', result.includes('上次探查已停止') && result.includes('其余保留上一次的结构'), result)
check('……发了停止请求', sent.some((x) => x.key.endsWith('/cancel')))
check('……进度条不见了,「停止」也不在了', await card.locator('[data-introspect-job]').count() === 0
&& await card.locator('[data-introspect-stop]').count() === 0)
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)
// 口径同 docs/zh-CN/identity-and-security.md「哪些角色看得到原值」:未开启登录时对谁都只减少暴露,开了登录对使用者是边界——两头都说清
check('……并写明开启登录后对使用者是边界', await field.locator('[data-mask-boundary]').getAttribute('data-mask-boundary') === 'off'
&& 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()
})
// ---------------------------------------------------------------------------
// 按配方导入(期 2):接口全部伪造。夹具是契约里的参考配方(「结构仿照客流表」,假名)和照它造的
// 假暂存区:网格按 P2-SPEC 9.1 的坐标摆(B2:AG30,非空格 771),另加一行没有去处的 B32:C32
// ---------------------------------------------------------------------------
const FLOW_RECIPE = JSON.parse(readFileSync(new URL('../backend/tests/fixtures/recipes/flow_recipe.json', import.meta.url), 'utf8'))
const RECIPE_UNITS = ['人次', '人', '元', '万元', '千元', '亿元', '千克', '吨', '个', '件', '户', '%']
const RECIPE_CANDIDATES = { '日间时段客流(人次)': ['日间', '日间时段客流'], '夜间时段客流(人次)': ['夜间', '夜间时段客流'] }
// 参考配方没有列表块:另加一张「分区明细」工作表,给配方面板的「数据中间的空行」那一项用
const recipeWithList = () => {
const r = JSON.parse(JSON.stringify(FLOW_RECIPE))
r.sheets.push({ id: 's2', match: { name: '分区明细' }, blocks: [{ id: '列表1', layout: 'list', table: '分区明细', columns: [
{ header: '分区', name: '分区', type: 'TEXT' }, { header: '客流(人次)', name: '客流', type: 'INTEGER' },
] }] })
r.tables.push({ name: '分区明细', grain: ['分区'], units: { 客流: '人次' } })
return r
}
function flowGrid() {
const col = (n) => { let s = ''; let x = n; while (x > 0) { s = String.fromCharCode(65 + ((x - 1) % 26)) + s; x = Math.floor((x - 1) / 26) } return s }
const days = 31
const cells = []
const formulas = {}
cells.push([2, 2, '统计时间范围:2026年8月1日至2026年8月31日', 'text'], [3, 2, '客流汇总表', 'text'])
for (let d = 1; d <= days; d++) cells.push([4, 2 + d, `8月${d}日`, 'text'])
;['全日客流(人次)', '分区甲(人次)', '分区乙(人次)'].forEach((l, i) => cells.push([5 + i, 2, l, 'text']))
for (let d = 1; d <= days; d++) {
const a = 3000 + ((d * 137) % 6000)
const b = 2000 + ((d * 91) % 6000)
cells.push([5, 2 + d, String(a + b), 'number'], [6, 2 + d, String(a), 'number'], [7, 2 + d, String(b), 'number'])
}
cells.push([9, 2, '日间时段客流(人次)', 'text'])
for (let h = 7; h < 18; h++) {
const r = 10 + h - 7
cells.push([r, 2, `${h}-${h + 1}`, 'text'])
for (let d = 1; d <= days; d++) cells.push([r, 2 + d, h === 7 ? '·' : String(50 + ((d * h * 13) % 850)), h === 7 ? 'text' : 'number'])
}
cells.push([21, 2, '夜间时段客流(人次)', 'text'])
for (let h = 18; h < 24; h++) {
const r = 22 + h - 18
cells.push([r, 2, `${h}-${h + 1}`, 'text'])
for (let d = 1; d <= days; d++) cells.push([r, 2 + d, String(50 + ((d * h * 7) % 850)), 'number'])
}
;[['18-22 时合计', 22, 25], ['22-24 时合计', 26, 27], ['18-24 时合计', 22, 27]].forEach(([label, r1, r2], i) => {
cells.push([28 + i, 2, label, 'text'])
for (let d = 1; d <= days; d++) {
cells.push([28 + i, 2 + d, String(1000 + d), 'formula'])
formulas[`${col(2 + d)}${28 + i}`] = `=SUM(${col(2 + d)}${r1}:${col(2 + d)}${r2})`
}
})
// 表下补录的一行:没有分段认领它(P2-SPEC 9.2 的 D21)
cells.push([32, 2, '补录(人次)', 'text'], [32, 3, '1,234', 'text'])
const rows = []; for (let r = 2; r <= 32; r++) rows.push(r)
const cols = []; for (let c = 2; c <= 33; c++) cols.push(c)
return {
sheet: '客流汇总', bounds: 'B2:AG32', total_rows: 31, total_cols: 32, truncated: false, rows, cols, cells, formulas,
merges: ['B2:AG2', 'B3:AG3', 'B8:AG8', 'B9:AG9', 'B21:AG21'], hidden_rows: [], hidden_cols: [],
}
}
const FLOW_MARKS = [
['context', 'B2'], ['outside_text', 'B3'], ['col_header', 'C4:AG4'], ['row_label', 'B5:B7'], ['value', 'C5:AG7'],
['section_title', 'B9'], ['row_label', 'B10:B20'], ['value', 'C10:AG20'], ['section_title', 'B21'], ['row_label', 'B22:B27'],
['value', 'C22:AG27'], ['derived_label', 'B28:B30'], ['derived_value', 'C28:AG30'],
].map(([role, ref]) => ({ sheet: '客流汇总', role, ref }))
const FLOW_CARDS = [
{ id: 'axis', title: '第 4 行是日期表头:按交叉表导入', reason: 'C4:AG4 共 31 格都能解析为日期', cells: ['客流汇总!C4:AG4'] },
{ id: 'period', title: '统计期取自 B2', reason: 'B2 能解析出 2026-08-01 至 2026-08-31,没有别处冲突', cells: ['客流汇总!B2'] },
{ id: 'titles', title: '分段标题:B9、B21', reason: '标签列有字、日期列全空的行按分段标题处理', cells: ['客流汇总!B9', '客流汇总!B21'] },
{ id: 'f1', title: '第 5 行 = 第 6 行 + 第 7 行', reason: '31 列中 31 列成立,建议登记为每期核对', cells: ['客流汇总!B5:B7'], question: 'q_relation:F1' },
{ id: 'merge', title: '日间、夜间合进一张表', reason: '两段标签都是时段、互不相交;类别从标题的候选词里选', cells: ['客流汇总!B9', '客流汇总!B21'] },
{ id: 'placeholder', title: '「·」表示无数据吗', reason: '第 10 行整行都是「·」', cells: ['客流汇总!C10:AG10'], question: 'q_placeholder:·' },
{ id: 'derived', title: '第 28–30 行是合计:改作核对并另存', reason: '标签能解析出时段区间,值是汇总上方几行的公式', cells: ['客流汇总!B28:B30'] },
{ id: 'f2', title: '各时段之和与全日客流 31 天中 0 天相等', reason: '口径不同,建议登记为「口径不同」', cells: ['客流汇总!B5'], question: 'q_relation:F2' },
{ id: 'outside', title: 'B3 是区域外文字', reason: '不含数字,只记进回执', cells: ['客流汇总!B3'] },
{ id: 'mode', title: '导入模式:每期替换', reason: '按期累积将在后续版本提供', cells: [] },
]
const relationQuestion = (id, extra = {}) => ({
id: `q_relation:${id}`, text: `系统发现的关系 ${id} 要登记吗`,
options: [{ value: 'register', label: '登记', needs_reason: false }, { value: 'dismiss', label: '不登记', needs_reason: true }],
...extra,
})
// 前两题带起草器的建议(契约 Question.default):界面只写「建议:…」,不替人选
const FLOW_QUESTIONS = [
{ id: 'q_placeholder:·', text: '「·」是否表示无数据', default: 'null', options: [
{ value: 'null', label: '是,存为空值', needs_reason: false }, { value: 'reject', label: '不是,不导入', needs_reason: false },
] },
relationQuestion('F1', { default: 'register' }), relationQuestion('F2'),
]
const FLOW_CONFIRMS = [
['placeholder:·', '「·」存为空值(31 格)'], ['derived:夜间合计', '第 28–30 行改作核对,原值另存表「时段客流_表内合计」'],
['year_from:交叉表', '表头只写月日,年份取自统计期'], ['relation:R1', '每期核对「全日客流 = 分区甲 + 分区乙」'],
['relation:R2', '「时段客流」与「日客流」口径不同'], ['unit:日客流.全日客流', '「全日客流(人次)」→ 列「全日客流」,单位 人次'],
['unit:日客流.分区甲', '「分区甲(人次)」→ 列「分区甲」,单位 人次'], ['unit:日客流.分区乙', '「分区乙(人次)」→ 列「分区乙」,单位 人次'],
['unit:时段客流.客流', '列「客流」,单位 人次'], ['unit:时段客流_表内合计.客流', '列「客流」,单位 人次'], ['mode', '导入模式:每期替换'],
].map(([id, label]) => ({ id, label, detail: '', required: true, source: 'recipe' }))
// 外加一条不必勾的(ConfirmItem.required=false):检查脚本不勾它,提交的 confirmations 里就不能有它
const OPTIONAL_CONFIRM = { id: 'sheet_extra:说明', label: '另有可见工作表「说明」,不导入', detail: '', required: false, source: 'sheet' }
FLOW_CONFIRMS.push(OPTIONAL_CONFIRM)
const flowCheck = (id, kind, title, status, extra = {}) => ({
id, kind, title, status, category: status === 'info' ? 'info' : 'structure', checked: 31, failed: 0, unverifiable: 0,
details: [], cells: [], acceptable: false, reasons: {}, ...extra,
})
function flowTrial(status, extra = {}) {
const checks = status === 'needs_decision' ? [
flowCheck('C1', 'context_agree', '统计期多处一致', 'passed', { checked: 1 }),
flowCheck('K1', 'derived_sum', '夜间合计按时段区间重算', 'unverifiable', { category: 'structure', acceptable: true, unverifiable: 31,
details: ['明细含无数据占位符:31 格'], reasons: { null_detail: 31 }, cells: ['客流汇总!C28'] }),
flowCheck('R1', 'relation_sum_eq', '全日客流 = 分区甲 + 分区乙', 'mismatch', { category: 'data_quality', acceptable: true, failed: 2,
details: ['8 月 3 日:全日客流与分区之和不等'], cells: ['客流汇总!E5'] }),
flowCheck('R2', 'relation_not_comparable', '「时段客流」与「日客流」口径不同', 'info', { failed: 31, details: ['31 天中 0 天相等'] }),
] : [
flowCheck('C1', 'context_agree', '统计期多处一致', 'passed', { checked: 1 }),
flowCheck('K1', 'derived_sum', '夜间合计按时段区间重算', 'passed', { checked: 93 }),
flowCheck('R1', 'relation_sum_eq', '全日客流 = 分区甲 + 分区乙', 'passed'),
flowCheck('R2', 'relation_not_comparable', '「时段客流」与「日客流」口径不同', 'info', { failed: 31, details: ['31 天中 0 天相等'] }),
]
return {
trial_id: `trial-${status}-${Math.random().toString(36).slice(2, 8)}`, status,
receipt: {
ledger: [{ sheet: '客流汇总', nonempty_scan: 771, nonempty_read: 771, unclaimed: 0, roles: {
value: 620, derived_value: 93, derived_label: 3, col_header: 31, row_label: 20, section_title: 2, context: 1, outside_text: 1,
} }],
tables: [
{ name: '日客流', sheet: '客流汇总', kind: 'data', rows: 31, grain: ['日期'], columns: [
{ name: '日期', type: 'TEXT' }, { name: '全日客流', type: 'INTEGER', unit: '人次' },
{ name: '分区甲', type: 'INTEGER', unit: '人次' }, { name: '分区乙', type: 'INTEGER', unit: '人次' }] },
{ name: '时段客流', sheet: '客流汇总', kind: 'data', rows: 527, grain: ['日期', '时段'], columns: [
{ name: '日期', type: 'TEXT' }, { name: '时段', type: 'TEXT' }, { name: '客流', type: 'INTEGER', unit: '人次' }] },
{ name: '时段客流_表内合计', sheet: '客流汇总', kind: 'reported_total', rows: 93, grain: ['日期', '合计项'], columns: [
{ name: '日期', type: 'TEXT' }, { name: '合计项', type: 'TEXT' }, { name: '客流', type: 'INTEGER', unit: '人次' }] },
],
period: status === 'needs_input' ? null : { start: '2026-08-01', end: '2026-08-31', source: 'cells', cells: ['客流汇总!B2'] },
placeholders: { '·': 31 }, outside_text: [{ sheet: '客流汇总', cell: '客流汇总!B3', text: '客流汇总表', kind: 'text' }],
db_sha256: 'a'.repeat(64),
},
problems: status === 'needs_input'
? [{ code: 'period_missing', category: 'input', message: '未能从表格中解析出统计期,请为本期录入统计期', cells: [] }] : [],
checks: status === 'needs_input' ? [] : checks,
confirm_items: FLOW_CONFIRMS,
acceptable: status === 'needs_decision' ? ['K1', 'R1'] : [],
notes: { 日客流: { comment: '按日的客流,已逐日核对全日客流等于两个分区之和。', columns: { 全日客流: '单位:人次' } } },
diff: null, same_as_import: null, base_snapshot_id: null,
...extra,
}
}
function flowStaging(over = {}) {
return {
id: 'stg-flow', kind: 'first', status: 'drafting',
source: { id: 'src-new-flow', name: 'zz_flow_recipe', exists: false, import_mode: null },
file: { name: '月报导出_2026-08-01_2026-08-31.xlsx', size: 12345, sha256_prefix: 'a1b2c3d4' },
sheets: [{ name: '客流汇总', state: 'visible', bounds: 'B2:AG32', nonempty: 773, merged: 5, formulas: 93, formulas_uncached: 0, hidden_rows: 0, hidden_cols: 0 }],
skipped_sheets: [], full_calc_on_load: false, grids: [flowGrid()],
draft: { recipe: recipeWithList(), complete: true, origin: 'rules', cards: FLOW_CARDS, questions: FLOW_QUESTIONS, failures: [] },
ai_draft: null, ai: { offered: false, available: true, reason: '', model: '检查脚本模型', provider: '检查脚本接入' }, ai_consents: [],
recipe: recipeWithList(), recipe_origin: 'rules', recipe_problems: [], answers: {},
cards: FLOW_CARDS, questions: FLOW_QUESTIONS,
draft_problems: [{ code: 'row_unclaimed', category: 'structure', message: '第 32 行:标签列有字、日期列有值,没有分段认领这一行', cells: ['客流汇总!B32:C32'] }],
draft_partial: false, candidates: RECIPE_CANDIDATES, units: RECIPE_UNITS, marks: FLOW_MARKS, trial: null, context_inputs: {},
created_at: ago(60_000), updated_at: ago(0), expires_at: ago(-7 * 24 * 3600_000),
...over,
}
}
const RECIPE_SRC_BASE = {
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, table_count: 3,
schema_synced_at: ago(0), origin: 'upload', import_mode: 'recipe',
current_snapshot: { id: '7'.repeat(64), created_at: ago(3600_000), file_name: '月报导出_2026-07-01_2026-07-31.xlsx', raw_state: 'kept' },
current_recipe: { id: 'rcp-1', seq: 1, origin: 'rules', activated_at: ago(3600_000), signed_by: '检查脚本' },
open_staging: null,
}
const recipeSource = (id, name, over = {}) => ({
...RECIPE_SRC_BASE, id, name, tools: [`db_query__${name}`], database: `/tmp/x/uploads/tables/${id}/builds/${'8'.repeat(64)}.db`, ...over,
})
const coded = (status, code, detail) => json({ detail, code }, status)
/** 确认清单:逐条勾上全部必勾的确认项(不点任何「全选」;标了「可选」的不勾) */
const tickRequired = async (list) => {
const boxes = list.locator('[data-confirm-item][data-required="true"] input[type="checkbox"]')
for (let i = 0; i < await boxes.count(); i++) await boxes.nth(i).check()
}
await section('数据 · 表格:按配方导入', async () => {
// 服务端的回答排队:每个接口按到达顺序取一个;没备好的回 500,免得静默通过
const replies = { stage: [], answers: [], recipe: [], trial: [], commit: [], get: [], preview: [], draftAi: [] }
const take = (k) => (route, ctx) => {
const r = replies[k].shift()
return r ? r(route, ctx) : json({ detail: `检查脚本没有准备这次的回答(${k})` }, 500)(route)
}
let current = flowStaging()
const echo = (patch = {}) => (route, { body }) => {
current = { ...current, ...patch, ...(body?.recipe ? { recipe: body.recipe, answers: {} } : {}) }
return json(current)(route)
}
const shapeReply = {
detail: '检查脚本的 422 原话:结构不规整',
decision: { kind: 'shape', details: { reasons: [{ sheet: '客流汇总', kind: 'date_header', cells: ['C4:AG4'], message: '第 4 行有 31 个日期样式的单元格(C4:AG4),疑似日期横排的交叉表' }] } },
}
const recipeSrc = recipeSource('check-manage-recipe', 'zz_recipe_card')
const aiSrc = recipeSource('check-manage-ai', 'zz_ai_offer', { import_mode: 'simple', open_staging: { id: 'stg-ai', kind: 'switch', status: 'drafting', created_at: ago(60_000) } })
const aiOffSrc = recipeSource('check-manage-ai-off', 'zz_ai_unavailable', { import_mode: 'simple', open_staging: { id: 'stg-ai-off', kind: 'switch', status: 'drafting', created_at: ago(60_000) } })
const PREVIEW = { text: '## 工作表「客流汇总」 已用区域 B2:AG30,非空格 771\n合并:B2:AG2、B3:AG3\n数字(只有类型):\n C5:AG7 整数', chars: 66, sha256: 'f'.repeat(64), model: '检查脚本模型', provider: '检查脚本接入' }
const { page, sent, natives, errors, close } = await open('/data/tables', {
recordForms: true,
handlers: [
[/^GET \/datasources$/, (route) => json([...sources, recipeSrc, aiSrc, aiOffSrc])(route)],
[/^GET \/datasources\/check-manage-[^/]+\/schema$/, json({ tables: ['日客流'], summary: '', synced_at: ago(0) })],
[/^POST \/datasources\/upload$/, json(shapeReply, 422)],
[/^POST \/datasources\/imports\/stage$/, take('stage')],
[/^GET \/datasources\/imports\/[^/]+\/draft-ai\/preview$/, take('preview')],
[/^POST \/datasources\/imports\/[^/]+\/draft-ai$/, take('draftAi')],
[/^GET \/datasources\/imports\/[^/]+$/, take('get')],
[/^POST \/datasources\/imports\/[^/]+\/answers$/, take('answers')],
[/^PUT \/datasources\/imports\/[^/]+\/recipe$/, take('recipe')],
[/^POST \/datasources\/imports\/[^/]+\/trial$/, take('trial')],
[/^POST \/datasources\/imports\/[^/]+\/commit$/, take('commit')],
],
})
const count = (key) => sent.filter((s) => s.key === key || new RegExp(key).test(s.key)).length
const last = (re) => [...sent].reverse().find((s) => re.test(s.key))
const wizard = () => dialog(page)
// ---- 卡片:按配方导入的源换成「上传新一期」「修改配方」,没有「重新上传」
const rcard = page.locator('[data-source="zz_recipe_card"]')
check('按配方导入的源:卡片上有「上传新一期」「修改配方」,没有「重新上传」',
await rcard.getByRole('button', { name: '上传新一期' }).count() === 1
&& await rcard.getByRole('button', { name: '修改配方' }).count() === 1
&& await rcard.getByRole('button', { name: '重新上传' }).count() === 0)
const recipeLine = await rcard.locator('[data-current-recipe]').innerText().catch(() => '')
check('……卡片上写当前配方:第几版、起草方式、启用时间、署名(未认证)',
recipeLine.includes('配方第 1 版') && recipeLine.includes('规则起草') && recipeLine.includes('启用')
&& recipeLine.includes('署名「检查脚本」(未认证)'), recipeLine)
check('……简单导入的源不写配方信息', await page.locator('[data-source="zz_ai_offer"] [data-current-recipe]').count() === 0)
check('表格标签页头部有「按配方导入」入口', await page.locator('[data-recipe-entry]').count() === 1)
// ---- 头部入口:选文件区接得住拖进来的文件;提示的格式与选择框收的一致
await page.locator('[data-recipe-entry]').click()
const dropZone = dialog(page).locator('[data-wizard-drop]')
await dropZone.waitFor({ timeout: 4000 }).catch(() => {})
const pickHint = await dropZone.innerText().catch(() => '')
const accept = await dropZone.locator('input[type="file"]').getAttribute('accept').catch(() => '')
check('选文件区写「拖入文件」,提示的格式与选择框收的一致(.xlsx、.xlsm、CSV / TSV)', pickHint.includes('拖入文件')
&& accept === '.xlsx,.xlsm,.csv,.tsv' && pickHint.includes('.xlsx') && pickHint.includes('.xlsm')
&& pickHint.includes('CSV'), `${pickHint} | ${accept}`)
const dropFile = async (name) => {
const dt = await page.evaluateHandle((n) => {
const d = new DataTransfer()
d.items.add(new File(['PK'], n, { type: 'application/octet-stream' }))
return d
}, name)
await dropZone.dispatchEvent('dragover', { dataTransfer: dt })
await dropZone.dispatchEvent('drop', { dataTransfer: dt })
await page.waitForTimeout(200)
}
await dropFile('drop_flow.pdf')
check('……拖进来的不是 Excel 或 CSV:不选中,说明支持哪几种', await dropZone.locator('[data-wizard-file]').count() === 0
&& (await page.locator('body').innerText()).includes('不是 Excel 或 CSV 文件'))
await dropFile('drop_flow.xlsx')
check('……拖进来的文件被选中,名字取自文件名', (await dropZone.locator('[data-wizard-file]').innerText().catch(() => '')).includes('drop_flow.xlsx')
&& (await dialog(page).locator('[data-wizard-name]').inputValue().catch(() => '')) === 'drop_flow')
await dialog(page).getByRole('button', { name: '取消', exact: true }).click()
await page.waitForTimeout(200)
// ---- 交叉表决定页 → 按配方导入:带着同一个文件和名字发 stage
await page.getByRole('button', { name: /传表格/ }).first().click()
let dlg = dialog(page)
await dlg.locator('input[type="file"]').setInputFiles({ name: '月报导出_2026-08-01_2026-08-31.xlsx', mimeType: 'application/octet-stream', buffer: Buffer.from('PK\x03\x04') })
await dlg.locator('input.mono').first().fill('zz_flow_recipe')
await dlg.getByRole('button', { name: /导入/ }).click()
const recipeBtn = page.locator('[data-shape-recipe] button', { hasText: '按配方导入' })
await recipeBtn.waitFor({ timeout: 5000 }).catch(() => {})
check('交叉表决定页出现「按配方导入」按钮', await recipeBtn.count() === 1)
replies.stage.push(json(current, 201))
await recipeBtn.click()
const grid = page.locator('[data-sheet-grid]')
await grid.waitFor({ timeout: 5000 }).catch(() => {})
const stageForm = (await importForms(page)).at(-1) ?? {}
check('……点了发 POST /datasources/imports/stage,表单里带同一个文件和名字',
count('^POST /datasources/imports/stage$') === 1 && /imports\/stage/.test(stageForm.url ?? '')
&& stageForm.name === 'zz_flow_recipe' && stageForm.file === '<file 月报导出_2026-08-01_2026-08-31.xlsx>', JSON.stringify(stageForm))
check('……上传弹窗让位给向导(只剩一个弹窗)', await page.locator('[role="dialog"]').count() === 1 && await page.locator('[data-upload-decision]').count() === 0)
// ---- 网格
check('网格:行号、列字母', await grid.locator('[data-row-header="5"]').count() === 1 && await grid.locator('[data-col-header="C"]').count() === 1)
check('……B5 按去向着色为行标签', (await grid.locator('[data-cell="B5"]').getAttribute('data-role').catch(() => '')) === 'row_label')
check('……C28 是合计格,悬停显示公式', (await grid.locator('[data-cell="C28"]').getAttribute('data-role').catch(() => '')) === 'derived_value'
&& ((await grid.locator('[data-cell="C28"]').getAttribute('title').catch(() => '')) ?? '').includes('=SUM(C22:C25)'))
check('……没有去处的格(B32)标成 unclaimed,图例里有「没有去处」',
(await grid.locator('[data-cell="B32"]').getAttribute('data-role').catch(() => '')) === 'unclaimed'
&& (await grid.locator('[data-grid-legend]').innerText().catch(() => '')).includes('没有去处'))
check('……合并区 B2:AG2 画成一格', (await grid.locator('[data-cell="B2"]').getAttribute('colspan').catch(() => '')) === '32'
&& await grid.locator('[data-cell="C2"]').count() === 0)
// ---- 规则草稿完整:不提供 AI 入口
check('规则草稿完整(offered=false):没有 AI 入口', await page.locator('[data-ai-offer]').count() === 0)
// ---- 建议卡片与问题
const cards = page.locator('[data-suggestion-card]')
const reasons = await page.locator('[data-suggestion-card] [data-card-reason]').allInnerTexts()
check('建议卡片 10 张,每张都有理由', await cards.count() === 10 && reasons.length === 10 && reasons.every((r) => r.trim().length > 0), String(await cards.count()))
const qPlace = page.locator('[data-question="q_placeholder:·"]')
const radios = page.locator('[data-question] input[type="radio"]')
check('问题是单选,没有默认选中', await radios.count() === 6 && await page.locator('[data-question] input[type="radio"]:checked').count() === 0
&& (await qPlace.locator('input[type="radio"]').first().getAttribute('type')) === 'radio')
check('……带起草建议(default)的问题也不预选,只写「建议:…」',
(await qPlace.locator('[data-question-suggested]').innerText().catch(() => '')).includes('建议:是,存为空值')
&& (await page.locator('[data-question="q_relation:F1"] [data-question-suggested]').innerText().catch(() => '')).includes('建议:登记')
&& await page.locator('[data-question="q_relation:F2"] [data-question-suggested]').count() === 0)
replies.answers.push((route, { body }) => { current = { ...current, answers: Object.fromEntries(Object.entries(body.answers).map(([k, v]) => [k, { reason: null, ...v }])) }; return json(current)(route) })
await qPlace.locator('[data-option="null"] input').check()
await until(async () => count('^POST /datasources/imports/[^/]+/answers$') === 1, 4000)
const a1 = last(/answers$/)
check('选了以后发 POST …/answers,body 的 answers 是 {问题: {value}}',
a1?.body?.answers?.['q_placeholder:·']?.value === 'null' && !('reason' in (a1?.body?.answers?.['q_placeholder:·'] ?? {})), JSON.stringify(a1?.body ?? {}))
await page.waitForTimeout(200)
check('……服务端回的 answers 回显为选中', await qPlace.locator('[data-option="null"] input').isChecked())
const qF2 = page.locator('[data-question="q_relation:F2"]')
await qF2.locator('[data-option="dismiss"] input').check()
await page.waitForTimeout(250)
const reasonBox = qF2.locator('[data-question-reason] textarea')
const submitReason = qF2.locator('[data-question-reason] button')
check('选需要理由的「不登记」:出现理由框,没填理由不发请求',
await reasonBox.count() === 1 && await submitReason.isDisabled() && count('^POST /datasources/imports/[^/]+/answers$') === 1)
replies.answers.push((route, { body }) => { current = { ...current, answers: Object.fromEntries(Object.entries(body.answers).map(([k, v]) => [k, { reason: null, ...v }])) }; return json(current)(route) })
await reasonBox.fill('两段口径本来就不同,检查脚本')
await submitReason.click()
await until(async () => count('^POST /datasources/imports/[^/]+/answers$') === 2, 4000)
const a2 = last(/answers$/)
check('……写了理由再提交:带理由发出,之前的回答一并带上',
a2?.body?.answers?.['q_relation:F2']?.value === 'dismiss' && a2?.body?.answers?.['q_relation:F2']?.reason === '两段口径本来就不同,检查脚本'
&& a2?.body?.answers?.['q_placeholder:·']?.value === 'null', JSON.stringify(a2?.body ?? {}))
await shot(page, 'recipe-drafting')
// ---- 配方面板
await page.locator('[data-recipe-drawer-toggle]').click()
const panel = page.locator('[data-recipe-panel]')
await panel.waitFor({ timeout: 4000 }).catch(() => {})
const panelText = await panel.innerText().catch(() => '')
check('配方面板整段文本里没有「正则」「regex」「pattern」', panelText.length > 100 && !/正则|regex|pattern/i.test(panelText))
const unitOptions = await panel.locator('[data-field="/tables/0/units/全日客流"] option').evaluateAll((os) => os.map((o) => o.value))
check('……单位是下拉框,选项恰好是单位词表(外加一个「无单位」)',
JSON.stringify(unitOptions.filter((v) => v)) === JSON.stringify(RECIPE_UNITS) && unitOptions.filter((v) => !v).length === 1, unitOptions.join(','))
const constOptions = await panel.locator('[data-field="/sheets/0/blocks/0/segments/1/const/时段类别/pick"] option').evaluateAll((os) => os.map((o) => o.value))
check('……常量是下拉框,选项恰好是该分段标题的候选词', JSON.stringify(constOptions) === JSON.stringify(RECIPE_CANDIDATES['日间时段客流(人次)']), constOptions.join(','))
const puts = () => sent.filter((s) => /^PUT \/datasources\/imports\/[^/]+\/recipe$/.test(s.key))
replies.recipe.push(echo())
await panel.locator('[data-field="/tables/0/name"]').fill('日客流量')
await panel.locator('[data-field="/tables/0/name"]').press('Enter')
await until(async () => puts().length === 1, 4000)
const p1 = puts()[0]?.body?.recipe
check('改表名:发一次 PUT …/recipe,表名变了,引用它的分段一并改',
puts().length === 1 && p1?.tables?.[0]?.name === '日客流量' && p1?.sheets?.[0]?.blocks?.[0]?.segments?.[0]?.table === '日客流量'
&& p1?.relations?.[0]?.table === '日客流量', JSON.stringify(p1?.tables?.[0] ?? {}))
await page.waitForTimeout(300)
replies.recipe.push(echo())
const dayLabels = panel.locator('[data-field-wrap="/sheets/0/blocks/0/segments/1/labels/expect"]')
await dayLabels.locator('[data-label-input]').fill('6-7')
await dayLabels.locator('[data-label-add]').click()
await until(async () => puts().length === 2, 4000)
const p2 = puts()[1]?.body?.recipe?.sheets?.[0]?.blocks?.[0]?.segments?.[1]?.labels?.expect ?? []
check('增一个标签:发 PUT,标签集合多了「6-7」', puts().length === 2 && p2.includes('6-7') && p2.length === 12, p2.join(','))
await page.waitForTimeout(300)
replies.recipe.push(echo())
await dayLabels.getByRole('button', { name: '移除标签「7-8」' }).click()
await until(async () => puts().length === 3, 4000)
const p3 = puts()[2]?.body?.recipe?.sheets?.[0]?.blocks?.[0]?.segments?.[1]?.labels?.expect ?? []
check('删一个标签:发 PUT,标签集合少了「7-8」', puts().length === 3 && !p3.includes('7-8') && p3.includes('6-7'), p3.join(','))
await page.waitForTimeout(300)
replies.recipe.push(echo())
const titleBox = panel.locator('[data-field="/sheets/0/blocks/0/segments/2/locate/title"]')
await titleBox.fill('夜间客流(人次)')
await titleBox.blur()
await until(async () => puts().length === 4, 4000)
check('改分段标题:发 PUT,标题变了', puts()[3]?.body?.recipe?.sheets?.[0]?.blocks?.[0]?.segments?.[2]?.locate?.title === '夜间客流(人次)')
await page.waitForTimeout(300)
replies.recipe.push(echo())
await panel.locator('[data-field="/sheets/1/blocks/0/rows/blank_rows"] input[value="skip"]').check()
await until(async () => puts().length === 5, 4000)
check('切换「数据中间的空行」:发 PUT,blank_rows 变成跳过', puts()[4]?.body?.recipe?.sheets?.[1]?.blocks?.[0]?.rows?.blank_rows === 'skip')
await page.waitForTimeout(300)
// 期 5:没配小计时可以声明「没有小计行」(以合计字样开头的行照常是数据,不再每期报「像小计的行」要人确认)
replies.recipe.push(echo())
await panel.locator('[data-field="/sheets/1/blocks/0/rows/no_subtotals"]').check()
await until(async () => puts().length === 6, 4000)
const noSub = puts()[5]?.body?.recipe?.sheets?.[1]?.blocks?.[0]?.rows
check('勾选「没有小计行」:发 PUT,no_subtotals 为 true、没有配小计', noSub?.no_subtotals === true && !noSub?.subtotals,
JSON.stringify(noSub ?? {}))
await page.waitForTimeout(300)
// 指标类型:整行是文字的指标(如「高峰时段」写着「18-19」)改按时段 / 文字导入,落成文字列、去掉单位;改回数字时按标签补回单位
const kindSel = panel.locator('[data-field="/sheets/0/blocks/0/segments/0/text_measures/分区乙(人次)"]')
const kindOptions = await kindSel.locator('option').evaluateAll((os) => os.map((o) => `${o.value}:${o.textContent}`))
check('指标类型是下拉框:数字、时段、文字', JSON.stringify(kindOptions) === JSON.stringify([':数字', 'hour_range:时段', 'text:文字']),
kindOptions.join(','))
replies.recipe.push(echo())
await kindSel.selectOption('hour_range')
await until(async () => puts().length === 7, 4000)
const k1 = puts()[6]?.body?.recipe
check('改成「时段」:发 PUT,text_measures 写上 hour_range,这一列的单位去掉',
k1?.sheets?.[0]?.blocks?.[0]?.segments?.[0]?.text_measures?.['分区乙(人次)']?.parser === 'hour_range'
&& !('分区乙' in (k1?.tables?.[0]?.units ?? {})), JSON.stringify(k1?.tables?.[0]?.units ?? {}))
await page.waitForTimeout(300)
replies.recipe.push(echo())
await kindSel.selectOption('')
await until(async () => puts().length === 8, 4000)
const k2 = puts()[7]?.body?.recipe
check('改回「数字」:发 PUT,text_measures 去掉这一项,单位按标签补回「人次」',
!('分区乙(人次)' in (k2?.sheets?.[0]?.blocks?.[0]?.segments?.[0]?.text_measures ?? {}))
&& k2?.tables?.[0]?.units?.['分区乙'] === '人次', JSON.stringify(k2?.tables?.[0]?.units ?? {}))
await page.waitForTimeout(300)
// 有问题的配方:一条对得上字段(单位),一条对不上(挂到面板顶部)
replies.recipe.push(echo({ recipe_problems: [
{ path: '/tables/0/units/分区甲', code: 'unit_label_conflict', message: '表「日客流量」:列「分区甲」的单位与标签「分区甲(人次)」不一致' },
{ path: '/sheets/0/blocks/0/label_offset', code: 'schema', message: '第 1 个工作表的交叉表:标签列偏移只能是紧挨日期的左侧一列' },
] }))
await panel.locator('[data-field="/tables/0/units/分区甲"]').selectOption('人')
await until(async () => puts().length === 9, 4000)
await page.waitForTimeout(300)
const trialBtn = wizard().locator('[data-trial-run]')
check('配方有问题时「试运行」禁用', await trialBtn.isDisabled())
check('……带 path 的问题显示在对应字段旁', (await panel.locator('[data-field-wrap="/tables/0/units/分区甲"] [data-recipe-problem]').innerText().catch(() => '')).includes('不一致'))
check('……对不到字段的显示在面板顶部', (await panel.locator('[data-recipe-problems-top]').innerText().catch(() => '')).includes('标签列偏移')
&& await panel.locator('[data-recipe-problems-top] [data-recipe-problem]').count() === 1)
await panel.locator('[data-recipe-json-toggle]').click()
const jsonText = await panel.locator('[data-recipe-json]').innerText().catch(() => '')
check('「查看配方 JSON」可见,补全了默认值', jsonText.includes('"recipe_format"') && jsonText.includes('"fallback"') && jsonText.includes('"blank_rows"'))
replies.recipe.push(echo({ recipe_problems: [] }))
await panel.locator('[data-recipe-paste-toggle]').click()
await panel.locator('[data-recipe-paste] textarea').fill(JSON.stringify(FLOW_RECIPE))
await panel.locator('[data-recipe-paste] button', { hasText: '保存配方' }).click()
await until(async () => puts().length === 10, 4000)
check('「粘贴配方」发 PUT,body 就是粘贴的配方', JSON.stringify(puts()[9]?.body?.recipe) === JSON.stringify(FLOW_RECIPE))
await page.waitForTimeout(300)
check('……问题清掉以后「试运行」可用', await trialBtn.isEnabled())
// 连续修改:上一次 PUT 还没回来就接着改。上一次回来时,排在后面的修改不能被服务端那份盖掉,
// 之后的修改也要以含它的配方为底(服务端每次回复都慢 1.2 秒)
const slowEcho = (route, ctx) => new Promise((r) => setTimeout(r, 1200)).then(() => echo()(route, ctx))
replies.recipe.push(slowEcho, slowEcho, slowEcho)
const unitSel = (t, c) => panel.locator(`[data-field="/tables/${t}/units/${c}"]`)
const putsBefore = puts().length
await unitSel(1, '客流').selectOption('人')
await unitSel(2, '客流').selectOption('人')
await until(async () => puts().length === putsBefore + 2, 6000)
await page.waitForTimeout(150)
const midUi = await unitSel(2, '客流').inputValue().catch(() => '')
await unitSel(0, '全日客流').selectOption('元')
await until(async () => puts().length === putsBefore + 3, 6000)
await until(async () => !(await panel.locator('[data-recipe-saving]').count()), 6000)
await page.waitForTimeout(200)
const lastUnits = (puts().at(-1)?.body?.recipe?.tables ?? []).map((t) => t?.units ?? {})
const finalUi = [await unitSel(0, '全日客流').inputValue(), await unitSel(1, '客流').inputValue(), await unitSel(2, '客流').inputValue()]
check('连续改三处(回复慢):上一次 PUT 回来时,排着的那处修改在界面上不退回', midUi === '人', midUi)
check('……最后一次 PUT 三处修改都在,界面停在三处都改过的样子',
puts().length === putsBefore + 3 && lastUnits[0]?.['全日客流'] === '元' && lastUnits[1]?.['客流'] === '人' && lastUnits[2]?.['客流'] === '人'
&& JSON.stringify(finalUi) === JSON.stringify(['元', '人', '人']), `${puts().length - putsBefore} 次:${JSON.stringify(lastUnits)} 界面 ${finalUi.join(',')}`)
await shot(page, 'recipe-panel')
// ---- 试运行:先要录入统计期,再出回执
replies.trial.push((route) => { current = { ...current, status: 'trialed', trial: flowTrial('needs_input') }; return json(current)(route) })
await trialBtn.click()
const periodForm = page.locator('[data-period-form]')
await periodForm.waitFor({ timeout: 4000 }).catch(() => {})
check('needs_input:出现两个日期框,文件名里的区间作建议', await periodForm.locator('input[type="date"]').count() === 2
&& (await periodForm.innerText()).includes('2026-08-01'))
check('……needs_input 没有写库:「表与行数」只列表和列,写「未写入」,不显示行数',
await page.locator('[data-trial-receipt] [data-receipt-tables="unwritten"]').count() === 1
&& await page.locator('[data-trial-receipt] [data-receipt-table][data-rows]').count() === 0
&& !(await page.locator('[data-trial-receipt] [data-receipt-tables]').innerText().catch(() => '')).includes(' 行'))
replies.trial.push((route) => { current = { ...current, status: 'trialed', trial: flowTrial('needs_decision') }; return json(current)(route) })
await periodForm.locator('[data-period-start]').fill('2026-08-01')
await periodForm.locator('[data-period-end]').fill('2026-08-31')
await periodForm.getByRole('button', { name: '按此统计期试运行' }).click()
const receipt = page.locator('[data-trial-receipt]')
await until(async () => (await receipt.locator('[data-check]').count()) > 0, 4000)
const t2 = sent.filter((s) => /\/trial$/.test(s.key))[1]
check('……提交 trial 时 body 带 context_inputs', t2?.body?.context_inputs?.['统计期']?.start === '2026-08-01'
&& t2?.body?.context_inputs?.['统计期']?.end === '2026-08-31', JSON.stringify(t2?.body ?? {}))
check('回执:格子账含 771', (await receipt.locator('[data-ledger]').innerText().catch(() => '')).includes('771'))
const rows = async (n) => (await receipt.locator(`[data-receipt-table="${n}"]`).getAttribute('data-rows').catch(() => ''))
check('……日客流 31 行、时段客流 527 行、时段客流_表内合计 93 行',
await rows('日客流') === '31' && await rows('时段客流') === '527' && await rows('时段客流_表内合计') === '93'
&& (await receipt.locator('[data-receipt-table="时段客流"]').innerText()).includes('527 行'))
const chip = (st) => receipt.locator(`[data-check][data-status="${st}"] [data-check-status]`).first()
const look = async (st) => ({ text: await chip(st).innerText().catch(() => ''), color: await chip(st).evaluate((el) => getComputedStyle(el).color).catch(() => '') })
const [okLook, badLook, unvLook] = [await look('passed'), await look('mismatch'), await look('unverifiable')]
check('……通过 / 不一致 / 无法核对三种状态各有不同的文字和样式',
okLook.text.includes('通过') && badLook.text.includes('不一致') && unvLook.text.includes('无法核对')
&& new Set([okLook.color, badLook.color, unvLook.color]).size === 3, JSON.stringify([okLook, badLook, unvLook]))
const r2 = receipt.locator('[data-check="R2"]')
check('……口径不同的 R2(说明)不写「不一致 31」:只有细节「31 天中 0 天相等」',
await r2.count() === 1 && await r2.locator('[data-check-counts]').count() === 0
&& !(await r2.innerText()).includes('不一致') && (await r2.innerText()).includes('31 天中 0 天相等'))
check('……区域外文字的坐标是「客流汇总!B3」(cell 本来就带工作表名,不再拼一次)',
await receipt.locator('[data-outside-text="客流汇总!B3"] [data-cell-ref="客流汇总!B3"]').count() === 1)
await shot(page, 'recipe-receipt')
// ---- 确认清单
await wizard().locator('[data-to-confirm]').click()
const list = page.locator('[data-confirm-list]')
await list.waitFor({ timeout: 4000 }).catch(() => {})
const commitBtn = list.locator('[data-commit]')
check('确认清单:没有「全选」', await list.getByRole('button', { name: /全选/ }).count() === 0 && !(await list.innerText()).includes('全选'))
check('……逐列的单位确认项(unit:日客流.全日客流 等五条)',
await list.locator('[data-confirm-item^="unit:"]').count() === 5 && await list.locator('[data-confirm-item="unit:日客流.全日客流"]').count() === 1)
check('……署名旁写「署名(未认证)」', (await list.locator('[data-sign-note]').innerText().catch(() => '')).includes('署名(未认证)'))
check('……不必勾的确认项标「可选」', await list.locator(`[data-confirm-item="${OPTIONAL_CONFIRM.id}"] [data-confirm-optional]`).count() === 1
&& await list.locator('[data-confirm-optional]').count() === 1)
check('……一项没勾时「确认并启用」禁用', await commitBtn.isDisabled() && (await commitBtn.innerText()).includes('确认并启用'))
await tickRequired(list)
check('……全勾了、可接受的核对还没写理由:仍禁用', await commitBtn.isDisabled())
check('……可接受的核对各一个「接受理由(必填)」', await list.locator('[data-acceptance]').count() === 2
&& (await list.locator('[data-acceptance="R1"]').innerText()).includes('接受理由(必填)'))
await list.locator('[data-acceptance="R1"] textarea').fill('8 月 3 日分区数据补录,检查脚本')
check('……只写了一条理由:仍禁用', await commitBtn.isDisabled())
await list.locator('[data-acceptance="K1"] textarea').fill('占位符时段本来无数据,检查脚本')
check('……全部勾上、理由都写了:可用', await commitBtn.isEnabled())
replies.commit.push(coded(409, 'base_changed', '在你试运行之后,当前版本已被更新,请重新试运行'))
replies.get.push((route) => { current = { ...current, status: 'drafting' }; return json(current)(route) })
await commitBtn.click()
await until(async () => count('/commit$') === 1, 4000)
const c1 = last(/commit$/)
check('提交的 body:confirmations 恰好是勾过的项(没勾的可选项不在里面)、acceptances 带理由、trial_id 对',
JSON.stringify([...(c1?.body?.confirmations ?? [])].sort()) === JSON.stringify(FLOW_CONFIRMS.filter((x) => x.required).map((x) => x.id).sort())
&& !(c1?.body?.confirmations ?? []).includes(OPTIONAL_CONFIRM.id)
&& c1?.body?.acceptances?.length === 2 && c1.body.acceptances.every((a) => a.reason.includes('检查脚本'))
&& c1?.body?.trial_id === current.trial.trial_id, JSON.stringify(c1?.body ?? {}).slice(0, 300))
await until(async () => await page.locator('[data-import-wizard][data-view="draft"]').count() > 0, 4000)
await page.waitForTimeout(300)
check('提交回 409 base_changed:出现「请重新试运行」,回到配方步骤',
(await page.locator('[data-wizard-notice]').innerText().catch(() => '')).includes('请重新试运行')
&& await page.locator('[data-import-wizard][data-view="draft"]').count() === 1 && await page.locator('[data-trial-run]').count() === 1)
// 再试运行一次:这回提交时试运行库已不在(trial_required)
replies.trial.push((route) => { current = { ...current, status: 'trialed', trial: flowTrial('passed') }; return json(current)(route) })
await page.locator('[data-trial-run]').click()
await until(async () => await page.locator('[data-to-confirm]').count() > 0, 4000)
await page.locator('[data-to-confirm]').click()
await tickRequired(page.locator('[data-confirm-list]'))
replies.commit.push(coded(409, 'trial_required', '试运行之后修改过配方,或试运行结果已失效:请重新试运行'))
replies.get.push((route) => { current = { ...current, status: 'drafting' }; return json(current)(route) })
await page.locator('[data-confirm-list] [data-commit]').click()
await until(async () => await page.locator('[data-import-wizard][data-view="draft"]').count() > 0, 4000)
check('提交回 409 trial_required:同样出现「请重新试运行」并回到配方步骤',
(await page.locator('[data-wizard-notice]').innerText().catch(() => '')).includes('请重新试运行')
&& await page.locator('[data-import-wizard][data-view="draft"]').count() === 1)
// 第三次:与第 3 次导入相同,未新建版本
replies.trial.push((route) => { current = { ...current, status: 'trialed', trial: flowTrial('passed', { same_as_import: { id: 'imp-3', seq: 3 } }) }; return json(current)(route) })
await page.locator('[data-trial-run]').click()
await until(async () => await page.locator('[data-to-confirm]').count() > 0, 4000)
await page.locator('[data-to-confirm]').click()
await tickRequired(page.locator('[data-confirm-list]'))
replies.commit.push(json({ source: recipeSource('src-new-flow', 'zz_flow_recipe'), import_id: 'imp-3', snapshot_id: '9'.repeat(64),
build_id: 'b'.repeat(64), recipe_id: 'rcp-9', build_reused: true, unchanged: true }, 201))
await page.locator('[data-confirm-list] [data-commit]').click()
const doneView = page.locator('[data-import-done]')
await doneView.waitFor({ timeout: 4000 }).catch(() => {})
check('提交回 unchanged: true:显示「与第 3 次导入相同」', (await doneView.innerText().catch(() => '')).includes('与第 3 次导入相同'))
check('……启用后卡片列表里有了这个源', await page.locator('[data-source="zz_flow_recipe"]').count() === 1)
await wizard().getByRole('button', { name: '完成' }).click()
await page.waitForTimeout(300)
// ---- AI 起草:从卡片「有未完成的导入」继续(同名的简单导入切换为按配方导入)
const aiStaging = () => flowStaging({
id: 'stg-ai', kind: 'switch', source: { id: 'check-manage-ai', name: 'zz_ai_offer', exists: true, import_mode: 'simple' },
draft: { recipe: null, complete: false, origin: 'rules', cards: [], questions: [], failures: ['第 28 行像合计行,但标签无法确定区间'] },
recipe: null, cards: FLOW_CARDS.slice(0, 3), questions: [FLOW_QUESTIONS[0]], answers: { 'q_placeholder:·': { value: 'reject', reason: null } },
ai: { offered: true, available: true, reason: '', model: '检查脚本模型', provider: '检查脚本接入' }, draft_partial: true,
})
replies.get.push(json(aiStaging()))
await page.locator('[data-source="zz_ai_offer"] [data-open-staging]').click()
const offer = page.locator('[data-ai-offer]')
await offer.waitFor({ timeout: 4000 }).catch(() => {})
check('继续未完成的导入:GET imports/{id} 进入起草', count('^GET /datasources/imports/stg-ai$') === 1 && await page.locator('[data-sheet-grid]').count() === 1)
check('……刷新后已选的回答处于选中状态', await page.locator('[data-question="q_placeholder:·"] [data-option="reject"] input').isChecked())
check('……部分干跑:可见文字写全「仅检查了前 500 行,完整检查在试运行时进行」',
(await page.locator('[data-draft-partial]').innerText().catch(() => '')).includes('仅检查了前 500 行,完整检查在试运行时进行'))
check('offered 且可用:有 AI 入口', await offer.count() === 1 && (await offer.innerText()).includes('让 AI 起草(会把表格结构发给模型)')
&& await offer.getByRole('button').isEnabled())
replies.preview.push(json(PREVIEW))
await offer.getByRole('button').click()
const consent = page.locator('[data-ai-consent]')
await consent.waitFor({ timeout: 4000 }).catch(() => {})
const consentText = await consent.innerText().catch(() => '')
check('点了先发 GET …/draft-ai/preview,再出同意框', count('/draft-ai/preview$') === 1 && await consent.count() === 1)
check('……同意框写模型名和「不会发送数字单元格的值」', consentText.includes('检查脚本模型') && consentText.includes('不会发送数字单元格的值'), consentText.slice(0, 120))
check('……[data-ai-preview] 是预览接口返回的原文', (await consent.locator('[data-ai-preview]').innerText().catch(() => '')).trim() === PREVIEW.text.trim())
await shot(page, 'recipe-ai-consent')
await dialog(page).getByRole('button', { name: '取消', exact: true }).click()
await page.waitForTimeout(250)
check('……点「取消」不发 POST', count('/draft-ai$') === 0 && await consent.count() === 0)
replies.preview.push(json(PREVIEW))
replies.draftAi.push((route) => json({ ...aiStaging(), recipe: FLOW_RECIPE, recipe_origin: 'ai',
ai_draft: { draft: { recipe: FLOW_RECIPE, complete: true, origin: 'ai' }, usage: [], attempts: 2, total_tokens: 4300, cost_usd: 0.012, error: null } })(route))
await offer.getByRole('button').click()
await consent.waitFor({ timeout: 4000 }).catch(() => {})
await dialog(page).getByRole('button', { name: '同意并发送' }).click()
await until(async () => count('/draft-ai$') === 1, 4000)
const ai1 = last(/draft-ai$/)
check('「同意并发送」发 POST …/draft-ai,consent: true,preview_sha256 等于预览返回的',
ai1?.body?.consent === true && ai1?.body?.preview_sha256 === PREVIEW.sha256, JSON.stringify(ai1?.body ?? {}))
const usage = page.locator('[data-ai-usage]')
await usage.waitFor({ timeout: 4000 }).catch(() => {})
check('……成功后显示用了多少 token', (await usage.innerText().catch(() => '')).includes('4,300 token'))
replies.preview.push(json(PREVIEW))
// 没有工作配方(recipe: null)时服务端的下一步是「粘贴一份完整的配方」:界面照写原话,不再另补「请在配方面板中填写」
replies.draftAi.push(coded(502, 'ai_failed', 'AI 未能起草出合法的配方。可以重试,或在配方面板中粘贴一份完整的配方'))
await offer.getByRole('button').click()
await consent.waitFor({ timeout: 4000 }).catch(() => {})
await dialog(page).getByRole('button', { name: '同意并发送' }).click()
await page.locator('[data-ai-error]').waitFor({ timeout: 4000 }).catch(() => {})
const failedText = (await page.locator('[data-ai-error]').innerText().catch(() => '')).trim()
check('ai_failed:显示服务端原话,下一步与可做的事一致(没有工作配方时不叫人去「填写」面板)',
failedText === 'AI 未能起草出合法的配方。可以重试,或在配方面板中粘贴一份完整的配方' && !failedText.includes('填写'), failedText)
replies.preview.push(json(PREVIEW))
replies.draftAi.push(coded(409, 'ai_preview_stale', '检查脚本:表格或模型设置在预览之后变了'))
await offer.getByRole('button').click()
await consent.waitFor({ timeout: 4000 }).catch(() => {})
await dialog(page).getByRole('button', { name: '同意并发送' }).click()
await until(async () => (await page.locator('[data-ai-error]').innerText().catch(() => '')).includes('预览之后变了'), 4000)
const staleText = await page.locator('[data-ai-error]').innerText().catch(() => '')
check('ai_preview_stale:显示服务端原话,并提示重新查看将要发送的内容', staleText.includes('预览之后变了') && staleText.includes('重新查看'), staleText)
await wizard().getByRole('button', { name: '稍后继续' }).click()
await page.waitForTimeout(300)
// ---- 模型接入不可用:按钮禁用并写出原因
replies.get.push(json({ ...aiStaging(), id: 'stg-ai-off', source: { id: 'check-manage-ai-off', name: 'zz_ai_unavailable', exists: true, import_mode: 'simple' },
ai: { offered: true, available: false, reason: '未配置模型接入,无法使用 AI 起草(设置 → 模型接入)', model: '', provider: '' } }))
await page.locator('[data-source="zz_ai_unavailable"] [data-open-staging]').click()
await offer.waitFor({ timeout: 4000 }).catch(() => {})
check('offered 但不可用:按钮禁用并显示原因', await offer.getByRole('button').isDisabled()
&& (await offer.innerText()).includes('未配置模型接入'))
await wizard().getByRole('button', { name: '稍后继续' }).click()
await page.waitForTimeout(200)
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('数据 · 表格:上传新一期', async () => {
const src = recipeSource('check-manage-reup', 'zz_recipe_reup')
// 试运行之后又改过配方的未完成导入(status 回到 drafting、trial 还在)
const staleSrc = recipeSource('check-manage-stale', 'zz_recipe_stale', {
open_staging: { id: 'stg-stale', kind: 'reupload', status: 'drafting', created_at: ago(60_000) } })
const replies = { reupload: [], trial: [], commit: [], recipe: [], redraft: [], discard: [] }
const take = (k) => (route, ctx) => {
const r = replies[k].shift()
return r ? r(route, ctx) : json({ detail: `检查脚本没有准备这次的回答(${k})` }, 500)(route)
}
const reupStaging = (trial, over = {}) => flowStaging({
id: 'stg-reup', kind: 'reupload', status: trial.status === 'rejected' ? 'rejected' : 'trialed',
source: { id: src.id, name: src.name, exists: true, import_mode: 'recipe' },
file: { name: '月报导出_2026-09-01_2026-09-30.xlsx', size: 12000, sha256_prefix: 'b2c3d4e5' },
draft: null, cards: [], questions: [], answers: {}, ai: { offered: false, available: true, reason: '', model: '', provider: '' },
recipe: FLOW_RECIPE, trial, ...over,
})
const diffTrial = flowTrial('passed', {
diff: [
{ kind: 'period', label: '统计期 2026-07-01 至 2026-07-31 → 2026-09-01 至 2026-09-30', requires_confirm: false },
{ kind: 'rows', label: '时段客流 527 行 → 510 行', requires_confirm: false },
{ kind: 'label_writing', label: '分段「日间」:标签「8-9」现在写作「8-9」', requires_confirm: true, confirm_id: 'diff:label_writing:日间' },
],
confirm_items: [
{ id: 'diff:label_writing:日间', label: '分段「日间」的标签写法变了:「8-9」→「8-9」', required: true, source: 'diff' },
{ id: 'outside_digits:客流汇总!B33', label: '区域外有含数字的文字「注:9月15日闭馆」', required: true, source: 'outside' },
OPTIONAL_CONFIRM,
],
})
const rejectedTrial = flowTrial('rejected', {
checks: [], confirm_items: [],
problems: [{ code: 'label_missing', category: 'structure', message: '分段「夜间」:期望的标签「23-24」在表格中没有找到', cells: ['客流汇总!B27'] }],
})
const { page, sent, natives, errors, close } = await open('/data/tables', {
recordForms: true,
handlers: [
[/^GET \/datasources$/, (route) => json([...sources, src, staleSrc])(route)],
[/^GET \/datasources\/check-manage-(?:reup|stale)\/schema$/, json({ tables: ['日客流'], summary: '', synced_at: ago(0) })],
[/^GET \/datasources\/imports\/stg-stale$/, (route) => json(reupStaging(flowTrial('passed'), {
id: 'stg-stale', status: 'drafting', source: { id: staleSrc.id, name: staleSrc.name, exists: true, import_mode: 'recipe' } }))(route)],
[/^POST \/datasources\/check-manage-reup\/reupload$/, take('reupload')],
[/^POST \/datasources\/imports\/[^/]+\/trial$/, take('trial')],
[/^POST \/datasources\/imports\/[^/]+\/commit$/, take('commit')],
[/^PUT \/datasources\/imports\/[^/]+\/recipe$/, take('recipe')],
[/^POST \/datasources\/check-manage-reup\/redraft$/, take('redraft')],
[/^DELETE \/datasources\/imports\/[^/]+$/, take('discard')],
],
})
const card = page.locator('[data-source="zz_recipe_reup"]')
const start = async (name) => {
await card.getByRole('button', { name: '上传新一期' }).click()
const dlg = dialog(page)
await dlg.locator('input[type="file"]').setInputFiles({ name, mimeType: 'application/octet-stream', buffer: Buffer.from('PK\x03\x04') })
await dlg.locator('[data-wizard-start]').click()
}
// ---- 通过:差异卡需确认的在前,并且在确认清单里;「启用」要全勾
replies.reupload.push(json(reupStaging(diffTrial), 201))
await start('月报导出_2026-09-01_2026-09-30.xlsx')
const diffCard = page.locator('[data-reupload-diff]')
await diffCard.waitFor({ timeout: 5000 }).catch(() => {})
const form = (await importForms(page)).at(-1) ?? {}
check('点「上传新一期」发 POST /datasources/{id}/reupload,带上文件',
sent.filter((s) => s.key === 'POST /datasources/check-manage-reup/reupload').length === 1
&& form.file === '<file 月报导出_2026-09-01_2026-09-30.xlsx>', JSON.stringify(form))
const diffs = await diffCard.locator('[data-diff]').evaluateAll((els) => els.map((e) => e.hasAttribute('data-requires-confirm')))
check('结果通过:显示差异卡,需确认的项排在前面', await diffCard.count() === 1 && diffs.length === 3 && diffs[0] === true && !diffs[1] && !diffs[2],
JSON.stringify(diffs))
check('……上传新一期没有 AI 入口', await page.locator('[data-ai-offer]').count() === 0)
await shot(page, 'recipe-reupload-diff')
await page.locator('[data-to-confirm]').click()
const list = page.locator('[data-confirm-list]')
await list.waitFor({ timeout: 4000 }).catch(() => {})
const enable = list.locator('[data-commit]')
check('……需确认的差异出现在确认清单里,按钮写「启用」',
await list.locator('[data-confirm-item="diff:label_writing:日间"]').count() === 1 && (await enable.innerText()).trim() === '启用')
await list.locator('[data-confirm-item="diff:label_writing:日间"] input').check()
check('……没有全勾时「启用」禁用', await enable.isDisabled())
await tickRequired(list)
check('……全勾以后可用', await enable.isEnabled())
replies.commit.push(json({ source: src, import_id: 'imp-9', snapshot_id: '6'.repeat(64), build_id: 'c'.repeat(64), recipe_id: 'rcp-1', build_reused: false, unchanged: false }, 201))
await enable.click()
await page.locator('[data-import-done]').waitFor({ timeout: 4000 }).catch(() => {})
const c1 = sent.filter((s) => /\/commit$/.test(s.key)).at(-1)
check('……启用:confirmations 是勾过的两项(没勾的可选项不在里面)', JSON.stringify([...(c1?.body?.confirmations ?? [])].sort())
=== JSON.stringify(['diff:label_writing:日间', 'outside_digits:客流汇总!B33'].sort()) && await page.locator('[data-import-done="committed"]').count() === 1)
await dialog(page).getByRole('button', { name: '完成' }).click()
await page.waitForTimeout(300)
// ---- 拒收:问题带坐标,「修改配方」回到配方面板,没有「启用」,也没有 AI 入口
replies.reupload.push(json(reupStaging(rejectedTrial), 201))
await start('月报导出_2026-10-01_2026-10-31.xlsx')
const problem = page.locator('[data-problem="label_missing"]')
await problem.waitFor({ timeout: 5000 }).catch(() => {})
check('结果拒收:问题列表带坐标', await problem.count() === 1 && await problem.locator('[data-cell-ref="客流汇总!B27"]').count() === 1)
await problem.locator('[data-cell-ref="客流汇总!B27"]').click()
await page.waitForTimeout(400)
check('……点坐标,网格滚到那一格', await inView(page.locator('[data-sheet-grid] [data-cell="B27"]')))
check('……没有「启用」、没有「下一步:逐条确认」', await page.locator('[data-to-confirm]').count() === 0 && await page.locator('[data-commit]').count() === 0
&& await dialog(page).getByRole('button', { name: '启用' }).count() === 0)
check('……没有 AI 入口', await page.locator('[data-ai-offer]').count() === 0)
check('……拒收时服务端不算差异(diff 为 null):不显示差异卡;「表与行数」写「未写入」,不显示只写了一半的行数',
await page.locator('[data-reupload-diff]').count() === 0
&& await page.locator('[data-receipt-tables="unwritten"]').count() === 1
&& await page.locator('[data-receipt-table][data-rows]').count() === 0)
await page.locator('[data-back-to-recipe]').click()
await page.waitForTimeout(300)
check('「修改配方」回到配方面板', await page.locator('[data-recipe-panel]').count() === 1 && await page.locator('[data-ai-offer]').count() === 0)
// 任一写请求遇到 staging_closed:说清楚并关掉向导
replies.trial.push(coded(409, 'staging_closed', '这次导入已结束(过期或已放弃)'))
await page.locator('[data-trial-run]').click()
await until(async () => (await page.locator('body').innerText()).includes('这次导入已结束'), 4000)
await page.waitForTimeout(300)
check('写请求回 409 staging_closed:显示「这次导入已结束」并关闭向导',
(await page.locator('body').innerText()).includes('这次导入已结束') && await page.locator('[role="dialog"]').count() === 0)
// ---- 差异卡的种类名:7.6 补上的 outside_moved 有中文名;不认识的 kind 写通用名,不露键名
replies.reupload.push(json(reupStaging(flowTrial('passed', { diff: [
{ kind: 'outside_moved', label: '区域外文字「注:数据为初步统计」从 B32 移到 B33', requires_confirm: false },
{ kind: 'zz_future_kind', label: '检查脚本:将来新增的一种变化', requires_confirm: false },
] })), 201))
await start('月报导出_2026-11-01_2026-11-30.xlsx')
const kindCard = page.locator('[data-reupload-diff]')
await kindCard.waitFor({ timeout: 5000 }).catch(() => {})
const kindText = await kindCard.innerText().catch(() => '')
check('差异卡:outside_moved 写「区域外文字挪了位置」,不认识的 kind 写「其他变化」,不露键名',
kindText.includes('区域外文字挪了位置') && kindText.includes('其他变化') && !/outside_moved|zz_future_kind/.test(kindText), kindText)
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
// ---- 试运行之后又改过配方(status 回到 drafting、trial 还在):不显示「下一步:逐条确认」,写明已失效
await page.locator('[data-source="zz_recipe_stale"] [data-open-staging]').click().catch(() => {})
await page.locator('[data-trial-stale]').waitFor({ timeout: 4000 }).catch(() => {})
check('试运行已失效:写明「上次的试运行已失效」,没有「下一步:逐条确认」',
await page.locator('[data-trial-stale]').count() === 1 && await page.locator('[data-to-confirm]').count() === 0)
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
// ---- 与某次导入相同
replies.reupload.push(json(reupStaging(flowTrial('passed', { same_as_import: { id: 'imp-2', seq: 2 }, diff: [] })), 201))
await start('月报导出_2026-08-01_2026-08-31.xlsx')
const same = page.locator('[data-same-as-import]')
await same.waitFor({ timeout: 5000 }).catch(() => {})
check('same_as_import:显示「与第 2 次导入相同」', (await same.innerText().catch(() => '')).includes('与第 2 次导入相同'))
// 放弃:先问一句(说清当前版本不受影响),确认后发 DELETE 并关掉向导
replies.discard.push((route) => route.fulfill({ status: 204, body: '' }))
await page.locator('[data-wizard-discard]').click()
const ask = page.locator('[role="dialog"]', { hasText: '放弃这次导入?' })
await ask.waitFor({ timeout: 4000 }).catch(() => {})
check('「放弃这次导入」先问一句,写明当前版本不受影响,还没发 DELETE',
(await ask.innerText().catch(() => '')).includes('当前版本不受影响') && sent.filter((s) => s.key.startsWith('DELETE ')).length === 0)
await ask.getByRole('button', { name: '放弃这次导入' }).click()
await until(async () => await page.locator('[data-import-wizard]').count() === 0, 4000)
check('……确认后发 DELETE /datasources/imports/{id},向导关闭',
sent.filter((s) => s.key === 'DELETE /datasources/imports/stg-reup').length === 1 && await page.locator('[role="dialog"]').count() === 0)
// ---- 配方里的工作表没读到:执行器不给它记账。账是空的、或缺了工作表时,回执不写「全部有去处」「两遍读取一致」
const base = flowTrial('rejected').receipt
const noLedger = flowTrial('rejected', {
checks: [], confirm_items: [],
receipt: { ...base, ledger: [], tables: [], period: null, placeholders: {}, outside_text: [] },
problems: [{ code: 'sheet_missing', category: 'structure', message: '没有找到工作表「客流汇总」(工作簿中有内容的可见工作表:「表一」「表二」)', cells: [] }],
})
const ledgerOf = async () => {
const box = page.locator('[data-trial-receipt] [data-ledger]')
await box.waitFor({ timeout: 5000 }).catch(() => {})
return box.innerText().catch(() => '')
}
replies.reupload.push(json(reupStaging(noLedger), 201))
await start('月报导出_2026-11-01_2026-11-30.xlsx')
const noneText = await ledgerOf()
check('配方里的工作表一张都没读到(账为空):回执写「未读取到配方中的工作表」,不写「全部有去处」「两遍读取一致」',
noneText.includes('未读取到配方中的工作表') && !noneText.includes('全部有去处') && !noneText.includes('两遍读取一致'), noneText)
await page.keyboard.press('Escape')
await until(async () => await page.locator('[data-import-wizard]').count() === 0, 4000)
replies.reupload.push(json(reupStaging(flowTrial('rejected', {
checks: [], confirm_items: [],
problems: [{ code: 'sheet_missing', category: 'structure', message: '没有找到工作表「分区明细」(工作簿中有内容的可见工作表:「客流汇总」)', cells: [] }],
}), { recipe: recipeWithList() }), 201))
await start('月报导出_2026-12-01_2026-12-31.xlsx')
const partText = await ledgerOf()
check('……缺了一张工作表:写明有 1 张未读取到,同样不写「全部有去处」「两遍读取一致」',
partText.includes('771') && partText.includes('配方中有 1 张工作表未读取到') && !partText.includes('全部有去处') && !partText.includes('两遍读取一致'), partText)
await page.keyboard.press('Escape')
await until(async () => await page.locator('[data-import-wizard]').count() === 0, 4000)
// ---- 修改配方:POST redraft 进入起草(以现行配方为工作配方,没有 AI 入口)
replies.redraft.push(json(reupStaging({ ...flowTrial('passed') }, { kind: 'redraft', status: 'drafting', trial: null }), 201))
await card.getByRole('button', { name: '修改配方' }).click()
await page.locator('[data-import-wizard]').waitFor({ timeout: 4000 }).catch(() => {})
check('「修改配方」发 POST /datasources/{id}/redraft,进入起草', sent.filter((s) => s.key === 'POST /datasources/check-manage-reup/redraft').length === 1
&& await page.locator('[data-import-wizard][data-view="draft"]').count() === 1 && await page.locator('[data-ai-offer]').count() === 0)
await dialog(page).getByRole('button', { name: '稍后继续' }).click()
await page.waitForTimeout(200)
replies.redraft.push(json({ detail: '当前导入的原始文件已清除', code: 'raw_missing' }, 409))
await card.getByRole('button', { name: '修改配方' }).click()
await page.locator('[data-wizard-failed]').waitFor({ timeout: 4000 }).catch(() => {})
check('……409 raw_missing:提示用「上传新一期」带着文件进入修改', (await page.locator('[data-wizard-failed]').innerText().catch(() => '')).includes('上传新一期'))
await dialog(page).getByRole('button', { name: '关闭', exact: true }).last().click()
await page.waitForTimeout(200)
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
// ---------------------------------------------------------------------------
// 期 3(P3-SPEC 10.3):修复按钮、框选转锚点、按期累积、配方对照与重新起草、改配方后回答保留。接口全部伪造,
// 夹具沿用上面的参考配方和假暂存区(假名、手写假数)。新增的界面文字一律不含「快照」「并集」「构建」「锚点」
// 「放行」「钉」,也不露机读码
// ---------------------------------------------------------------------------
const P3_FORBIDDEN = /快照|并集|构建|锚点|放行|钉/
const P3_CODES = /remove_label|ignore_cells|declare_placeholder|edit_members|rename_sheet|label_missing|row_unclaimed|selection_bottom_not_anchorable|period_overlap|q_mode|accumulate_restart|period_replace|replace_period|fact_claim_mismatch|union_tampered|build_conflict|store_unavailable/
/** 向导里可见的文字(不含隐藏元素) */
const wizardText = (page) => page.locator('[data-import-wizard]').innerText().catch(() => '')
const sha = (c) => c.repeat(64)
/** 修复、框选的预览(契约 EditPreview):没写到的字段按「没有问题、没有变化」 */
const editPreview = (over = {}) => ({
ok: true, kind: 'fix', key: '', title: '', summary: [], ops: [], anchors: [], notes: [], problems: [],
recipe_sha256_before: sha('a'), recipe_sha256_after: sha('b'), recipe_problems: [],
dry_run: { problems: [], partial: false, marks: FLOW_MARKS.map((m) => ({ ...m, block: m.role === 'context' || m.role === 'outside_text' ? null : '交叉表' })) },
replay: null, breaking: {}, accumulate_change: null, compare: null, expected: null, block: null, seq: null, ...over,
})
const emptyCompare = (over = {}) => ({
tables: [], segments: [], relations: [], sheets: [], mode: { old: 'replace', new: 'replace' },
breaking: false, units_changed: [], accumulate: null, ...over,
})
const FX = {
remove: {
id: 'fx-0a1b2c3d4e5f', kind: 'remove_label', problem_code: 'label_missing', title: '在分段「日间」的期望标签中去掉「7-8」',
cells: ['客流汇总!B10'], target: { segment: '日间', labels: ['7-8'] },
options: [{ value: 'remove', label: '去掉标签「7-8」', detail: '今后各期如果又出现「7-8」,会再次拒收', needs_reason: false, breaking: false }],
anchor: { kind: 'problem', index: 0, sheet: null },
},
ignore: {
id: 'fx-1b2c3d4e5f6a', kind: 'ignore_cells', problem_code: 'row_unclaimed', title: '按行标签忽略「补录(人次)」这一行',
cells: ['客流汇总!B32:C32'], target: { block: '交叉表', how: 'rows', labels: ['补录(人次)'] },
options: [{ value: 'ignore', label: '按行标签忽略「补录(人次)」这一行', detail: '之后各期出现这一行同样忽略', needs_reason: true, breaking: false }],
anchor: { kind: 'problem', index: 2, sheet: null },
},
placeholder: {
id: 'fx-2c3d4e5f6a7b', kind: 'declare_placeholder', problem_code: 'value_not_number', title: '把「—」声明为占位符',
cells: ['客流汇总!D12'], target: { block: '交叉表', texts: ['—'] },
options: [{ value: 'no_data', label: '把「—」也声明为无数据(存为空值)', needs_reason: false, breaking: false },
{ value: 'not_applicable', label: '声明为不适用(存为空值)', needs_reason: false, breaking: false }],
anchor: { kind: 'problem', index: 3, sheet: null },
},
members: {
id: 'fx-3d4e5f6a7b8c', kind: 'edit_members', problem_code: null, title: '按系统发现更新关系 R1 的成员',
cells: ['客流汇总!B5:B8'], target: { relation: 'R1', fact: 'F1', total: '全日客流', parts: ['分区甲', '分区乙', '分区丙'] },
options: [{ value: 'update', label: '按系统发现更新为「全日客流 = 分区甲 + 分区乙 + 分区丙」', needs_reason: false, breaking: false },
{ value: 'dismiss', label: '不再登记这条关系', needs_reason: true, breaking: false }],
anchor: { kind: 'recipe_problem', index: 0, sheet: null },
},
sheet: {
id: 'fx-4e5f6a7b8c9d', kind: 'rename_sheet', problem_code: null, title: '把配方里的工作表名更新为「客流汇总(新)」',
cells: [], target: { sheet_id: 's1', name: '客流汇总(新)' },
options: [{ value: '客流汇总(新)', label: '更新为「客流汇总(新)」', detail: '下一期不再需要确认改名', needs_reason: false, breaking: false }],
anchor: { kind: 'sheet_renamed', index: null, sheet: '客流汇总(新)' },
},
// 同一次另有一张表改名:确认项 sheet_renamed:<sid> 旁只放对应那张表的按钮(按回执 sheets.matched 对上本期表名)
sheet2: {
id: 'fx-5f6a7b8c9d0e', kind: 'rename_sheet', problem_code: null, title: '把配方里的工作表名更新为「附表(新)」',
cells: [], target: { sheet_id: 's2', name: '附表(新)' },
options: [{ value: '附表(新)', label: '更新为「附表(新)」', detail: '下一期不再需要确认改名', needs_reason: false, breaking: false }],
anchor: { kind: 'sheet_renamed', index: null, sheet: '附表(新)' },
},
}
const FIX_PROBLEMS = [
{ code: 'label_missing', category: 'structure', message: '分段「日间」:期望的标签「7-8」在表格中没有找到', cells: ['客流汇总!B10'], fix: 'remove_label', fix_ids: [FX.remove.id] },
{ code: 'cell_unclaimed', category: 'structure', message: '第 31 行有一格数字没有分段认领', cells: ['客流汇总!AG31'], fix: null, fix_ids: [] },
{ code: 'row_unclaimed', category: 'structure', message: '第 32 行:标签列有字、日期列有值,没有分段认领这一行', cells: ['客流汇总!B32:C32'], fix: 'ignore_cells', fix_ids: [FX.ignore.id] },
{ code: 'value_not_number', category: 'structure', message: '数据区有 1 格写着「—」,不是数字', cells: ['客流汇总!D12'], fix: 'declare_placeholder', fix_ids: [FX.placeholder.id] },
]
const fixEdit = (over = {}) => ({
seq: 1, kind: 'fix', key: 'remove_label:日间:7-8', title: '在分段「日间」的期望标签中去掉「7-8」', at: ago(30_000), signed_by: '检查脚本',
recipe_sha256_before: sha('a'), recipe_sha256_after: sha('b'), superseded: false, undoable: true, ...over,
})
await section('数据 · 表格:修复按钮', async () => {
const src = recipeSource('check-manage-fix', 'zz_recipe_fix')
const d13Src = recipeSource('check-manage-d13', 'zz_recipe_d13', {
open_staging: { id: 'stg-d13', kind: 'reupload', status: 'drafting', created_at: ago(60_000) } })
const renameSrc = recipeSource('check-manage-rename', 'zz_recipe_rename', {
open_staging: { id: 'stg-rename', kind: 'reupload', status: 'trialed', created_at: ago(60_000) } })
const replies = { reupload: [], preview: [], apply: [], undo: [], trial: [], get: [] }
const take = (k) => (route, ctx) => {
const r = replies[k].shift()
return r ? r(route, ctx) : json({ detail: `检查脚本没有准备这次的回答(${k})` }, 500)(route)
}
const base = (over = {}) => flowStaging({
id: 'stg-fix', kind: 'reupload', status: 'drafting', source: { id: src.id, name: src.name, exists: true, import_mode: 'recipe' },
file: { name: '月报导出_2026-09-01_2026-09-30.xlsx', size: 12000, sha256_prefix: 'c3d4e5f6' },
draft: null, cards: [], questions: [], answers: {}, ai: { offered: false, available: true, reason: '', model: '', provider: '' },
recipe: FLOW_RECIPE, recipe_sha256: sha('a'), edits: [], answers_dropped: [], ...over,
})
const rejected = flowTrial('rejected', { checks: [], confirm_items: [], problems: FIX_PROBLEMS })
const allFixes = [FX.remove, FX.ignore, FX.placeholder]
const { page, sent, natives, errors, close } = await open('/data/tables', {
recordForms: true,
handlers: [
[/^GET \/datasources$/, (route) => json([...sources, src, d13Src, renameSrc])(route)],
[/^GET \/datasources\/check-manage-[^/]+\/schema$/, json({ tables: ['日客流'], summary: '', synced_at: ago(0) })],
[/^POST \/datasources\/check-manage-fix\/reupload$/, take('reupload')],
[/^GET \/datasources\/imports\/stg-d13$/, (route) => json(base({
id: 'stg-d13', source: { id: d13Src.id, name: d13Src.name, exists: true, import_mode: 'recipe' },
recipe_problems: [{ path: '/relations/0/claims', code: 'fact_claim_mismatch', message: '关系 R1 认领的系统发现与本期不一致:本期第 5 行 = 第 6、7、8 行之和', fix_ids: [FX.members.id] }],
fixes: [FX.members], edits: [fixEdit({ key: 'add_label:分区丙:分区丙(人次)', title: '在分段「分区」加入标签「分区丙(人次)」' })],
}))(route)],
[/^GET \/datasources\/imports\/stg-rename$/, (route) => json(base({
id: 'stg-rename', status: 'trialed', source: { id: renameSrc.id, name: renameSrc.name, exists: true, import_mode: 'recipe' },
fixes: [FX.sheet, FX.sheet2],
trial: flowTrial('passed', {
receipt: { ...flowTrial('passed').receipt, sheets: {
matched: { s1: '客流汇总(新)', s2: '附表(新)' }, renamed: { 客流汇总: '客流汇总(新)', 附表: '附表(新)' }, other_visible: [], skipped_hidden: [] } },
confirm_items: [{ id: 'sheet_renamed:s1', label: '工作表「客流汇总」现在叫「客流汇总(新)」', required: true, source: 'sheet' },
{ id: 'sheet_renamed:s2', label: '工作表「附表」现在叫「附表(新)」', required: true, source: 'sheet' }],
}),
}))(route)],
[/^GET \/datasources\/imports\/[^/]+$/, take('get')],
[/^POST \/datasources\/imports\/[^/]+\/edits\/preview$/, take('preview')],
[/^POST \/datasources\/imports\/[^/]+\/edits\/apply$/, take('apply')],
[/^POST \/datasources\/imports\/[^/]+\/edits\/undo$/, take('undo')],
[/^POST \/datasources\/imports\/[^/]+\/trial$/, take('trial')],
],
})
const count = (re) => sent.filter((s) => re.test(s.key)).length
const last = (re) => [...sent].reverse().find((s) => re.test(s.key))
const panel = page.locator('[data-fix-panel]')
// ---- 拒收的上传新一期:问题旁的修复按钮只按 fix_ids 放
replies.reupload.push(json(base({ status: 'rejected', trial: rejected, draft_problems: FIX_PROBLEMS, fixes: allFixes }), 201))
await page.locator('[data-source="zz_recipe_fix"]').getByRole('button', { name: '上传新一期' }).click()
await dialog(page).locator('input[type="file"]').setInputFiles({ name: '月报导出_2026-09-01_2026-09-30.xlsx', mimeType: 'application/octet-stream', buffer: Buffer.from('PK\x03\x04') })
await dialog(page).locator('[data-wizard-start]').click()
const fixBtn = page.locator('[data-trial-receipt] [data-problem="label_missing"] [data-fix="remove_label"]')
await fixBtn.waitFor({ timeout: 5000 }).catch(() => {})
check('拒收的问题旁有「去掉标签」按钮([data-fix="remove_label"])', await fixBtn.count() === 1 && (await fixBtn.innerText()).includes('去掉标签'))
check('……没有 fix_ids 的问题旁没有修复按钮', await page.locator('[data-problem="cell_unclaimed"] [data-fix]').count() === 0)
await fixBtn.click()
await panel.waitFor({ timeout: 4000 }).catch(() => {})
const masks = await page.evaluate(() => [...document.querySelectorAll('.fixed.inset-0')].length)
check('点开后出修复面板,在向导右栏内', await page.locator('[data-wizard-side] [data-fix-panel]').count() === 1)
check('……网格仍然可见、没有被遮罩挡住(只有向导自己一层,没有另起全屏遮罩)',
await page.locator('[data-sheet-grid]').isVisible() && await inView(page.locator('[data-sheet-grid] [data-cell="B10"]'))
&& masks === 1 && await page.locator('[role="dialog"]').count() === 1, `遮罩 ${masks} 层`)
const title = await panel.locator('[data-panel-title]').innerText().catch(() => '')
check('……标题含「日间」「7-8」,选项没有默认选中', title.includes('日间') && title.includes('7-8')
&& await panel.locator('[data-fix-option] input:checked').count() === 0 && await panel.locator('[data-fix-option]').count() === 1, title)
check('……相关的格在网格上描出来', await page.locator('[data-sheet-grid] [data-fix-cell="B10"]').count() === 1)
const removeCompare = emptyCompare({ segments: [{ id: '日间', status: 'changed', title: { old: '日间时段客流(人次)', new: '日间时段客流(人次)' },
labels: { added: [], removed: ['7-8'] }, ignore: { added: [], removed: [] } }], accumulate: { change: 'compatible', added: [], retired_new: [], retired_existing: [] } })
replies.preview.push((route, { body }) => json(editPreview({ key: 'remove_label:日间:7-8', title: FX.remove.title,
summary: ['分段「日间」的期望标签去掉「7-8」,剩下 10 个'],
ops: [{ op: 'replace', path: '/sheets/0/blocks/0/segments/1/labels/expect', value: ['8-9'] }],
compare: removeCompare, seq: body.seq }))(route))
await panel.locator('[data-fix-option="remove"] input').check()
await until(async () => (await panel.locator('[data-fix-summary]').count()) > 0, 4000)
const p1 = last(/edits\/preview$/)
check('选中后发 POST …/edits/preview,body 是 {fix: {id, option}, seq}',
JSON.stringify(p1?.body?.fix) === JSON.stringify({ id: FX.remove.id, option: 'remove' }) && Number.isInteger(p1?.body?.seq)
&& Object.keys(p1?.body ?? {}).sort().join(',') === 'fix,seq', JSON.stringify(p1?.body ?? {}))
check('……摘要写的是预览返回的 summary', (await panel.locator('[data-fix-summary]').innerText()).includes('剩下 10 个'))
check('……[data-fix-compare] 显示预览返回的对照', (await panel.locator('[data-fix-compare]').innerText().catch(() => '')).includes('去掉标签:「7-8」'))
const panelText = await panel.innerText()
check('……面板文本里没有补丁路径(不含「/sheets/」「/blocks/」)', !panelText.includes('/sheets/') && !panelText.includes('/blocks/'))
check('……面板文本不含「快照」「并集」「构建」「锚点」「放行」「钉」,不露机读码', !P3_FORBIDDEN.test(panelText) && !P3_CODES.test(panelText), panelText.slice(0, 120))
await page.locator('[data-preview-toggle] button', { hasText: '修改后' }).click()
await page.waitForTimeout(150)
check('……网格切到「修改后」时按预览的干跑着色([data-preview-mark])', await page.locator('[data-sheet-grid] [data-preview-mark]').count() > 0)
replies.apply.push((route) => json(base({ trial: rejected, draft_problems: FIX_PROBLEMS.slice(1), fixes: [FX.ignore, FX.placeholder],
edits: [fixEdit()], recipe_sha256: sha('b') }))(route))
await panel.locator('[data-fix-apply]').click()
const edits = page.locator('[data-edits]')
await edits.waitFor({ timeout: 4000 }).catch(() => {})
const a1 = last(/edits\/apply$/)
check('「应用修复」发 POST …/edits/apply,expected_sha256 等于预览返回的 recipe_sha256_after',
a1?.body?.expected_sha256 === sha('b') && JSON.stringify(a1?.body?.fix) === JSON.stringify({ id: FX.remove.id, option: 'remove' }),
JSON.stringify(a1?.body ?? {}))
check('……返回带 edits 的暂存区:[data-edits] 有一条,有「撤销上一次修改」', await edits.locator('[data-edit]').count() === 1
&& (await edits.locator('[data-edit-undo]').innerText().catch(() => '')).includes('撤销上一次修改'))
check('……面板关闭,提示重新试运行', await panel.count() === 0
&& (await page.locator('[data-wizard-notice]').innerText().catch(() => '')).includes('请重新试运行'))
replies.undo.push((route) => json(base({ trial: rejected, draft_problems: FIX_PROBLEMS, fixes: allFixes, edits: [] }))(route))
await edits.locator('[data-edit-undo]').click()
await until(async () => count(/edits\/undo$/) === 1, 4000)
await page.waitForTimeout(200)
check('点「撤销上一次修改」发 POST …/edits/undo', count(/edits\/undo$/) === 1 && JSON.stringify(last(/edits\/undo$/)?.body) === '{}'
&& await page.locator('[data-edits]').count() === 0)
// ---- 需要理由的提议:不自动预览;理由改了预览作废;应用发的理由与最后一次预览逐字相同
await page.locator('[data-wizard-side] [data-problem="row_unclaimed"] [data-fix="ignore_cells"]').click()
await panel.waitFor({ timeout: 4000 }).catch(() => {})
const previews0 = count(/edits\/preview$/)
await panel.locator('[data-fix-option="ignore"] input').check()
await page.waitForTimeout(300)
check('需要理由的选项:选中后不自动发预览;理由为空时「预览」「应用修复」都禁用',
count(/edits\/preview$/) === previews0 && await panel.locator('[data-fix-preview]').isDisabled() && await panel.locator('[data-fix-apply]').isDisabled())
const ignoreReply = (after) => (route, { body }) => json(editPreview({ key: 'ignore_cells:rows:交叉表:补录(人次)', title: FX.ignore.title,
summary: [`按行标签忽略「补录(人次)」,理由:${body.fix.reason}`], recipe_sha256_after: after, seq: body.seq }))(route)
replies.preview.push(ignoreReply(sha('c')))
await panel.locator('[data-fix-reason]').fill('表下补录的一行,下月起不再出现,检查脚本')
await panel.locator('[data-fix-preview]').click()
await until(async () => count(/edits\/preview$/) === previews0 + 1, 4000)
await page.waitForTimeout(200)
check('……填理由、点「预览」后发 preview,body 带理由',
last(/edits\/preview$/)?.body?.fix?.reason === '表下补录的一行,下月起不再出现,检查脚本' && await panel.locator('[data-fix-apply]').isEnabled())
await panel.locator('[data-fix-reason]').fill('表下补录的一行,检查脚本改过理由')
await page.waitForTimeout(150)
check('……预览之后改理由:「应用修复」禁用,提示「理由已修改,请重新预览」',
await panel.locator('[data-fix-apply]').isDisabled() && (await panel.locator('[data-fix-stale-hint]').innerText().catch(() => '')).includes('理由已修改,请重新预览'))
replies.preview.push(ignoreReply(sha('d')))
await panel.locator('[data-fix-preview]').click()
await until(async () => count(/edits\/preview$/) === previews0 + 2, 4000)
await page.waitForTimeout(200)
check('……重新预览后「应用修复」才可用', await panel.locator('[data-fix-apply]').isEnabled() && await panel.locator('[data-fix-stale-hint]').count() === 0)
replies.apply.push(coded(409, 'edit_stale', '配方在预览之后有变化,请重新预览'))
await panel.locator('[data-fix-apply]').click()
await until(async () => count(/edits\/apply$/) === 2, 4000)
await page.waitForTimeout(200)
const a2 = last(/edits\/apply$/)
check('……apply 的理由与最后一次预览逐字相同,expected_sha256 是最后一次预览的',
a2?.body?.fix?.reason === '表下补录的一行,检查脚本改过理由' && a2?.body?.expected_sha256 === sha('d'), JSON.stringify(a2?.body ?? {}))
check('返回 409 edit_stale:留在面板,「应用修复」禁用,提示「配方在预览之后有变化,请重新预览」',
await panel.count() === 1 && await panel.locator('[data-fix-apply]').isDisabled()
&& (await panel.innerText()).includes('配方在预览之后有变化,请重新预览'))
replies.preview.push(ignoreReply(sha('e')))
await panel.locator('[data-fix-preview]').click()
await until(async () => count(/edits\/preview$/) === previews0 + 3, 4000)
await page.waitForTimeout(200)
replies.apply.push(coded(409, 'fix_stale', '这条修复建议已不适用'))
replies.get.push((route) => json(base({ trial: rejected, draft_problems: FIX_PROBLEMS, fixes: allFixes }))(route))
const gets0 = count(/^GET \/datasources\/imports\/stg-fix$/)
await panel.locator('[data-fix-apply]').click()
await until(async () => count(/^GET \/datasources\/imports\/stg-fix$/) === gets0 + 1, 4000)
await page.waitForTimeout(200)
check('返回 409 fix_stale:面板关闭、暂存区重新获取,提示问题已变化',
await panel.count() === 0 && count(/^GET \/datasources\/imports\/stg-fix$/) === gets0 + 1
&& (await page.locator('[data-wizard-notice]').innerText().catch(() => '')).includes('问题已变化'))
// ---- 乱序回包:连续切换两个选项,第一次的回包晚到,只显示第二次的
await page.locator('[data-wizard-side] [data-problem="value_not_number"] [data-fix="declare_placeholder"]').click()
await panel.waitFor({ timeout: 4000 }).catch(() => {})
replies.preview.push((route, { body }) => delayed(1500, editPreview({ summary: ['检查脚本:第一次预览'], seq: body.seq }))(route))
replies.preview.push((route, { body }) => json(editPreview({ summary: ['检查脚本:第二次预览'], seq: body.seq }))(route))
await panel.locator('[data-fix-option="no_data"] input').check()
await page.waitForTimeout(100)
await panel.locator('[data-fix-option="not_applicable"] input').check()
await page.waitForTimeout(2000)
const orderText = await panel.locator('[data-fix-summary]').innerText().catch(() => '')
check('乱序回包:第一次预览的回包晚到,界面只显示第二次的 summary',
orderText.includes('第二次预览') && !orderText.includes('第一次预览'), orderText)
// ---- 预览 ok=false(200,带 problems):原因逐条显示、坐标可点,「应用修复」禁用,不能只剩一块空的预览区
replies.preview.push((route, { body }) => json(editPreview({ ok: false, summary: [], recipe_sha256_after: null, dry_run: null, seq: body.seq,
problems: [{ code: 'edit_blocked', category: 'recipe', cells: ['客流汇总!D12'], message: '检查脚本:这条修复现在不能应用,原因写在这里' }] }))(route))
await panel.locator('[data-fix-option="no_data"] input').check()
await until(async () => (await panel.locator('[data-edit-preview="blocked"]').count()) > 0, 4000)
await page.waitForTimeout(100)
check('伪造 ok=false 的修复预览:原因可见(坐标可点),「应用修复」禁用,面板留着',
(await panel.locator('[data-fix-problems] [data-fix-problem]').innerText().catch(() => '')).includes('原因写在这里')
&& await panel.locator('[data-fix-problem] [data-cell-ref="客流汇总!D12"]').count() === 1
&& await panel.locator('[data-fix-apply]').isDisabled() && await panel.count() === 1)
// 原因是 edit_not_applicable(提议对应的内容已不在当前配方里):按错误表处理——刷新暂存区、关掉面板、提示问题已变化
const goneMsg = '这条修复建议对应的内容已不在当前的配方里:配方在提出建议之后又被改过。请重新查看问题和修复建议'
replies.preview.push((route, { body }) => json(editPreview({ ok: false, summary: [], recipe_sha256_after: null, dry_run: null, seq: body.seq,
problems: [{ code: 'edit_not_applicable', category: 'recipe', cells: [], message: goneMsg }] }))(route))
replies.get.push((route) => json(base({ trial: rejected, draft_problems: FIX_PROBLEMS, fixes: allFixes }))(route))
const gets1 = count(/^GET \/datasources\/imports\/stg-fix$/)
await panel.locator('[data-fix-option="not_applicable"] input').check()
await until(async () => count(/^GET \/datasources\/imports\/stg-fix$/) === gets1 + 1, 4000)
await page.waitForTimeout(200)
const goneNotice = await page.locator('[data-wizard-notice]').innerText().catch(() => '')
check('……ok=false 的原因是 edit_not_applicable:面板关闭、暂存区重新获取,提示问题已变化并附服务端原话',
await panel.count() === 0 && count(/^GET \/datasources\/imports\/stg-fix$/) === gets1 + 1
&& goneNotice.includes('问题已变化') && goneNotice.includes('已不在当前的配方里'), goneNotice)
// ---- 确认清单:修复的确认项在「修改」组
replies.trial.push((route) => json(base({ status: 'trialed', fixes: [], edits: [fixEdit()], trial: flowTrial('passed', {
confirm_items: [
{ id: 'fix:remove_label:日间:7-8', label: '在分段「日间」的期望标签中去掉「7-8」', required: true, source: 'edit' },
{ id: 'diff:label_writing:日间', label: '分段「日间」的标签写法变了', required: true, source: 'diff' },
{ id: 'unit:日客流.全日客流', label: '「全日客流(人次)」→ 列「全日客流」,单位 人次', required: true, source: 'recipe' },
] }) }))(route))
await page.locator('[data-trial-run]').click()
await page.locator('[data-to-confirm]').waitFor({ timeout: 4000 }).catch(() => {})
await page.locator('[data-to-confirm]').click()
const editGroup = page.locator('[data-confirm-group="edit"]')
await editGroup.waitFor({ timeout: 4000 }).catch(() => {})
check('确认清单里有 fix:remove_label:日间:7-8,在「修改」组里',
await editGroup.locator('[data-confirm-item="fix:remove_label:日间:7-8"]').count() === 1
&& (await editGroup.locator('[data-confirm-group-title]').innerText()).trim() === '修改')
await page.keyboard.press('Escape')
await until(async () => await page.locator('[data-import-wizard]').count() === 0, 4000)
// ---- D13 两步修复:应用 ② 之后静态校验报关系成员对不上;抽屉关着时,试运行按钮上方就有出路
await page.locator('[data-source="zz_recipe_d13"] [data-open-staging]').click()
const bar = page.locator('[data-recipe-problems-bar]')
await bar.waitFor({ timeout: 4000 }).catch(() => {})
check('D13:配方有问题时,抽屉关着也有 [data-recipe-problems-bar](在试运行按钮上方)',
await bar.count() === 1 && await page.locator('[data-recipe-panel]').count() === 0 && (await bar.innerText()).includes('配方有 1 个问题')
&& await page.locator('[data-trial-run]').isDisabled())
await bar.locator('[data-recipe-problems-toggle]').click()
check('……展开后有「更新关系成员」([data-fix="edit_members"])', await bar.locator('[data-fix="edit_members"]').count() === 1
&& (await bar.locator('[data-fix="edit_members"]').innerText()).includes('更新关系成员'))
await bar.locator('[data-fix="edit_members"]').click()
check('……点了打开修复面板', await page.locator('[data-fix-panel="fx-3d4e5f6a7b8c"]').count() === 1)
await page.keyboard.press('Escape')
await until(async () => await page.locator('[data-import-wizard]').count() === 0, 4000)
// ---- 工作表改名:回执的改名说明旁有「更新工作表名」,确认项旁也有
await page.locator('[data-source="zz_recipe_rename"] [data-open-staging]').click()
const renamed = page.locator('[data-sheet-renamed]')
await renamed.waitFor({ timeout: 4000 }).catch(() => {})
/** 某处旁边的「更新工作表名」按钮各指向哪个提议 */
const renameIdsIn = (loc) => loc.locator('[data-fix="rename_sheet"]').evaluateAll((els) => els.map((e) => e.getAttribute('data-fix-id')))
const r1 = await renameIdsIn(renamed.locator('[data-sheet-renamed-item="客流汇总(新)"]'))
const r2 = await renameIdsIn(renamed.locator('[data-sheet-renamed-item="附表(新)"]'))
check('工作表改名:回执的每条改名说明旁各有一个 [data-fix="rename_sheet"],指向这张表的提议',
JSON.stringify(r1) === JSON.stringify([FX.sheet.id]) && JSON.stringify(r2) === JSON.stringify([FX.sheet2.id])
&& (await renamed.innerText()).includes('客流汇总(新)'), JSON.stringify({ r1, r2 }))
await page.locator('[data-to-confirm]').click()
await page.locator('[data-confirm-list]').waitFor({ timeout: 4000 }).catch(() => {})
const c1 = await renameIdsIn(page.locator('[data-confirm-item="sheet_renamed:s1"]'))
const c2 = await renameIdsIn(page.locator('[data-confirm-item="sheet_renamed:s2"]'))
check('……确认项 sheet_renamed:<sid> 旁也有「更新工作表名」,只放这张表的那一个(按回执的 sheets.matched 对上本期表名),不列全部',
JSON.stringify(c1) === JSON.stringify([FX.sheet.id]) && JSON.stringify(c2) === JSON.stringify([FX.sheet2.id]), JSON.stringify({ c1, c2 }))
await page.locator('[data-confirm-item="sheet_renamed:s1"] [data-fix="rename_sheet"]').click()
check('……点了回到起草、打开修复面板', await page.locator('[data-fix-panel="fx-4e5f6a7b8c9d"]').count() === 1
&& await page.locator('[data-import-wizard][data-view="draft"]').count() === 1)
const allText = await wizardText(page)
check('整个向导的新增文字不含「快照」「并集」「构建」「锚点」「放行」「钉」', !P3_FORBIDDEN.test(allText), allText.match(P3_FORBIDDEN)?.[0] ?? '')
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
/** 实验室用例 c01(合成、假名):表头在 C5,上方标题、单位、编制,下方合计行、空行、备注;另有一张「说明」 */
function c01Grids() {
const cells = [
[1, 3, '2026年8月 销售月报', 'text'], [2, 3, '单位:万元', 'text'], [3, 3, '编制:财务部 日期:2026-09-05', 'text'],
[5, 3, '地区', 'text'], [5, 4, '产品', 'text'], [5, 5, '销量', 'text'], [5, 6, '金额', 'text'],
[6, 3, '分区甲', 'text'], [6, 4, '产品甲', 'text'], [6, 5, '10', 'number'], [6, 6, '100', 'number'],
[7, 3, '分区乙', 'text'], [7, 4, '产品乙', 'text'], [7, 5, '20', 'number'], [7, 6, '120', 'number'],
[8, 3, '分区丙', 'text'], [8, 4, '产品丙', 'text'], [8, 5, '15', 'number'], [8, 6, '80', 'number'],
[9, 3, '合计', 'text'], [9, 5, '45', 'number'], [9, 6, '300', 'number'],
[11, 3, '注:数据来源于业务系统,金额含税。', 'text'], [12, 3, '制表人:经办甲', 'text'],
]
const rows = []; for (let r = 1; r <= 12; r++) rows.push(r)
return [
{ sheet: '月报', bounds: 'C1:F12', total_rows: 12, total_cols: 4, truncated: false, rows, cols: [3, 4, 5, 6], cells,
formulas: {}, merges: ['C1:F1'], hidden_rows: [], hidden_cols: [] },
{ sheet: '说明', bounds: 'A1:A2', total_rows: 2, total_cols: 1, truncated: false, rows: [1, 2], cols: [1],
cells: [[1, 1, '本表仅供检查脚本使用', 'text'], [2, 1, '数字均为手写假数', 'text']], formulas: {}, merges: [], hidden_rows: [], hidden_cols: [] },
]
}
const C01_MARKS = [
['outside_text', 'C1', null], ['outside_text', 'C2', null], ['outside_text', 'C3', null], ['col_header', 'C5:F5', '列表1'],
['value', 'C6:F8', '列表1'], ['total_label', 'C9', '列表1'], ['total_value', 'E9:F9', '列表1'],
['outside_text', 'C11', null], ['outside_text', 'C12', null],
].map(([role, ref, block]) => ({ sheet: '月报', role, ref, block }))
const C01_RECIPE = {
recipe_format: 'agentlab-recipe/2', sheets: [{ id: 's1', match: { name: '月报' }, blocks: [{ id: '列表1', layout: 'list', table: '月报',
columns: [{ header: '地区', name: '地区', type: 'TEXT' }, { header: '产品', name: '产品', type: 'TEXT' },
{ header: '销量', name: '销量', type: 'INTEGER' }, { header: '金额', name: '金额', type: 'INTEGER' }],
rows: { blank_rows: 'stop', total_row: { label_column: '地区', pick: '合计', keep_as: '月报_表内合计' } } }] }],
tables: [{ name: '月报', grain: ['地区', '产品'], units: { 金额: '万元' } }],
}
await section('数据 · 表格:框选转锚点', async () => {
const src = recipeSource('check-manage-c01', 'zz_recipe_c01', {
open_staging: { id: 'stg-c01', kind: 'redraft', status: 'drafting', created_at: ago(60_000) } })
const c01Staging = (over = {}) => flowStaging({
id: 'stg-c01', kind: 'redraft', status: 'drafting', source: { id: src.id, name: src.name, exists: true, import_mode: 'recipe' },
file: { name: 'c01_literal.xlsx', size: 9000, sha256_prefix: 'd4e5f6a7' }, grids: c01Grids(), marks: C01_MARKS,
draft: null, cards: [], questions: [], answers: {}, ai: { offered: false, available: true, reason: '', model: '', provider: '' },
recipe: C01_RECIPE, recipe_sha256: sha('a'), candidates: {}, edits: [], answers_dropped: [], fixes: [],
draft_problems: [{ code: 'outside_number', category: 'confirm', message: '区域外有含数字的文字', cells: ['月报!C1'] },
{ code: 'cell_unclaimed', category: 'structure', message: '检查脚本:第 400 行有一格没有去处', cells: ['月报!C400'], fix_ids: [] }],
...over,
})
const replies = { preview: [], apply: [] }
const take = (k) => (route, ctx) => {
const r = replies[k].shift()
return r ? r(route, ctx) : json({ detail: `检查脚本没有准备这次的回答(${k})` }, 500)(route)
}
const listPreview = (body, over = {}) => editPreview({
kind: 'selection', key: '列表1', title: '按框选替换列表「列表1」', summary: ['替换现有的列表「列表1」:表头按文字定位'],
anchors: ['地区', '产品', '销量', '金额'].map((text, i) => ({ kind: 'header', text, cell: `月报!${'CDEF'[i]}5` })),
replay: { expected: { header: 'C5:F5', data: 'C6:F8', total: 'C9:F9' }, actual: { header: 'C5:F5', data: 'C6:F8', total: 'C9:F9' }, match: true, diffs: [], window_rows: null },
expected: { header: 'C5:F5', data: 'C6:F8', total: 'C9:F9' }, block: '列表1', dry_run: { problems: [], partial: false, marks: C01_MARKS },
compare: emptyCompare(), seq: body.seq, ...over,
})
const handlers = [
[/^GET \/datasources$/, (route) => json([...sources, src])(route)],
[/^GET \/datasources\/check-manage-c01\/schema$/, json({ tables: ['月报'], summary: '', synced_at: ago(0) })],
[/^GET \/datasources\/imports\/stg-c01$/, (route) => json(c01Staging())(route)],
[/^POST \/datasources\/imports\/[^/]+\/edits\/preview$/, take('preview')],
[/^POST \/datasources\/imports\/[^/]+\/edits\/apply$/, take('apply')],
]
const { page, sent, natives, errors, close } = await open('/data/tables', { handlers })
const previews = () => sent.filter((s) => /edits\/preview$/.test(s.key))
await page.locator('[data-source="zz_recipe_c01"] [data-open-staging]').click()
const grid = page.locator('[data-sheet-grid]')
await grid.locator('[data-cell="C5"]').waitFor({ timeout: 5000 }).catch(() => {})
const center = async (cell) => {
const b = await grid.locator(`[data-cell="${cell}"]`).boundingBox()
return { x: b.x + b.width / 2, y: b.y + b.height / 2 }
}
const dragFromTo = async (a, b, mid) => {
const [pa, pb] = [await center(a), await center(b)]
await page.mouse.move(pa.x, pa.y)
await page.mouse.down()
let midState = null
if (mid) {
const pm = await center(mid)
await page.mouse.move(pm.x, pm.y, { steps: 4 })
await page.waitForTimeout(120)
midState = {
tds: await grid.locator('td').count(),
sel: await grid.locator('[data-selection]').getAttribute('data-selection').catch(() => null),
box: await grid.locator('[data-selection]').boundingBox().catch(() => null),
}
}
await page.mouse.move(pb.x, pb.y, { steps: 4 })
await page.waitForTimeout(120)
await page.mouse.up()
await page.waitForTimeout(150)
return midState
}
// ---- 未开框选:拖动不产生选区,也不发预览
const tds0 = await grid.locator('td').count()
await dragFromTo('C5', 'F8')
check('未开框选时用鼠标拖动:不产生 [data-selection],不发预览', await grid.locator('[data-selection]').count() === 0 && previews().length === 0)
// ---- 打开框选,从 C5 拖到 F8
await grid.locator('[data-select-toggle]').click()
const mid = await dragFromTo('C5', 'F8', 'E7')
const selection = grid.locator('[data-selection]')
const endBox = await selection.boundingBox().catch(() => null)
check('打开「框选」,从 C5 拖到 F8:出现 [data-selection="C5:F8"]', (await selection.getAttribute('data-selection').catch(() => '')) === 'C5:F8')
check('……选区栏写「已选 C5:F8」', (await grid.locator('[data-selection-bar]').innerText().catch(() => '')).includes('已选 C5:F8'))
check('……拖动过程中网格的 td 数量不变,只有覆盖层在变(拖到 E7 时选区是 C5:E7,比松手时窄)',
mid?.tds === tds0 && (await grid.locator('td').count()) === tds0 && mid?.sel === 'C5:E7'
&& !!mid?.box && !!endBox && mid.box.width < endBox.width, JSON.stringify({ tds0, mid, endBox }))
// ---- 「框选为…」选「列表(含表头)」
replies.preview.push((route, { body }) => json(listPreview(body))(route))
await grid.locator('[data-selection-menu]').selectOption('list')
const sp = page.locator('[data-selection-preview]')
await sp.waitFor({ timeout: 4000 }).catch(() => {})
const pv = previews().at(-1)?.body
const want = { sheet: '月报', ref: 'C5:F8', as: 'list', options: { header_rows: 1, bottom: 'box' } }
// 按键排序后逐层比较:键的先后不算差别,嵌套对象的每个键都要对上
const canon = (v) => (Array.isArray(v) ? v.map(canon)
: v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])])) : v)
const sameJson = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b))
check('选「列表(含表头)」发 POST …/edits/preview,body 是 {selection: {sheet, ref, as, options: {header_rows: 1, bottom: "box"}}, seq}',
previews().length === 1 && sameJson(pv?.selection, want) && Number.isInteger(pv?.seq) && Object.keys(pv ?? {}).sort().join(',') === 'selection,seq', JSON.stringify(pv ?? {}))
const anchors = await sp.locator('[data-anchor]').allInnerTexts()
check('……预览面板的定位文字依次是地区、产品、销量、金额', JSON.stringify(anchors.map((x) => x.trim())) === JSON.stringify(['地区', '产品', '销量', '金额']), anchors.join(','))
check('……有 [data-replay-match="true"] 和「重放结果与框选一致」', await sp.locator('[data-replay-match="true"]').count() === 1
&& (await sp.innerText()).includes('重放结果与框选一致'))
const dashed = await selection.evaluate((el) => getComputedStyle(el).borderTopStyle).catch(() => '')
const solid = await grid.locator('[data-replay-mark]').first().evaluate((el) => getComputedStyle(el).borderTopStyle).catch(() => '')
check('……网格上选区画虚线、重放识别的范围画实线([data-replay-mark])', dashed === 'dashed' && solid === 'solid'
&& await grid.locator('[data-replay-mark]').count() === 3, `${dashed} / ${solid}`)
const spText = await page.locator('[data-selection-panel]').innerText()
check('……面板整段文本不含「正则」「regex」「pattern」,并含「按文字定位」', !/正则|regex|pattern/i.test(spText) && spText.includes('按文字定位'))
check('……面板文本不含「快照」「并集」「构建」「锚点」「放行」「钉」', !P3_FORBIDDEN.test(spText), spText.match(P3_FORBIDDEN)?.[0] ?? '')
// ---- 换算不了:「应用」禁用,原因可见
replies.preview.push((route, { body }) => json(listPreview(body, { ok: false, anchors: [], replay: null, dry_run: null, summary: [],
recipe_sha256_after: null, problems: [{ code: 'selection_bottom_not_anchorable', category: 'recipe', cells: ['月报!C8:F8'],
message: '第 8 行还有数据:按文字无法表达「到第 7 行为止」。请把框扩大到数据的实际末尾(第 8 行),改选「下边界按规则推断」,或删掉文件里多出的行' }] }))(route))
await grid.locator('[data-selection-ref]').fill('C5:F7')
await grid.locator('[data-selection-ref]').press('Enter')
await grid.locator('[data-selection-menu]').selectOption('list')
await until(async () => previews().length === 2 && (await page.locator('[data-selection-preview="blocked"]').count()) === 1, 4000)
check('伪造 ok=false(下边界无法按文字表达):「应用」禁用,原因可见',
await page.locator('[data-selection-apply]').isDisabled()
&& (await page.locator('[data-selection-problem]').innerText().catch(() => '')).includes('第 8 行还有数据')
&& await page.locator('[data-selection-problem] [data-cell-ref="月报!C8:F8"]').count() === 1)
// ---- 部分干跑:只比对前 500 行
replies.preview.push((route, { body }) => json(listPreview(body, { replay: {
expected: { header: 'C5:F5', data: 'C6:F8', total: 'C9:F9' }, actual: { header: 'C5:F5', data: 'C6:F8', total: 'C9:F9' }, match: true, diffs: [], window_rows: 500 } }))(route))
await page.locator('[data-selection-bottom="auto"] input').check()
await until(async () => previews().length === 3, 4000)
await page.waitForTimeout(200)
check('改选「按规则推断」重新预览(bottom: "auto");部分干跑时写「仅比对前 500 行」',
previews().at(-1)?.body?.selection?.options?.bottom === 'auto'
&& (await page.locator('[data-replay-window]').innerText().catch(() => '')).includes('仅比对前 500 行')
&& await page.locator('[data-selection-apply]').isEnabled())
replies.apply.push((route) => json(c01Staging({ edits: [fixEdit({ kind: 'selection', key: 'list:列表1', title: '按框选替换列表「列表1」' })] }))(route))
await page.locator('[data-selection-apply]').click()
await until(async () => sent.some((s) => /edits\/apply$/.test(s.key)), 4000)
await page.waitForTimeout(200)
const ap = [...sent].reverse().find((s) => /edits\/apply$/.test(s.key))?.body
check('「应用」发 POST …/edits/apply:选区与预览时相同,expected_sha256 是预览返回的;之后选区清掉',
ap?.selection?.ref === 'C5:F7' && ap?.selection?.options?.bottom === 'auto' && ap?.expected_sha256 === sha('b')
&& await grid.locator('[data-selection]').count() === 0 && await page.locator('[data-selection-panel]').count() === 0, JSON.stringify(ap ?? {}))
// ---- 键盘:方向键移动当前格,Shift + 方向键扩选,Esc 只取消选区
await grid.locator('[data-grid-box]').focus()
await page.waitForTimeout(100)
// 当前格可能停在上一次拖动、输入的地方:按它离 C5 有几行几列按方向键(c01 的行列在预览里是连续的)
const start = await grid.locator('[data-grid-cursor]').getAttribute('data-grid-cursor').catch(() => '')
const m = /^([A-Z]+)(\d+)$/.exec(start ?? '')
if (m) {
const dc = 'C'.charCodeAt(0) - m[1].charCodeAt(0)
const dr = 5 - Number(m[2])
for (let i = 0; i < Math.abs(dc); i++) await page.keyboard.press(dc > 0 ? 'ArrowRight' : 'ArrowLeft')
for (let i = 0; i < Math.abs(dr); i++) await page.keyboard.press(dr > 0 ? 'ArrowDown' : 'ArrowUp')
}
const cur = await grid.locator('[data-grid-cursor]').getAttribute('data-grid-cursor').catch(() => '')
check('键盘:框选模式下聚焦网格有当前格([data-grid-cursor]),方向键把它移到 C5', !!m && cur === 'C5', `${start} → ${cur}`)
for (let i = 0; i < 3; i++) await page.keyboard.press('Shift+ArrowRight')
for (let i = 0; i < 3; i++) await page.keyboard.press('Shift+ArrowDown')
await page.waitForTimeout(100)
check('……Shift + 右三下、下三下:[data-selection="C5:F8"]', (await grid.locator('[data-selection]').getAttribute('data-selection').catch(() => '')) === 'C5:F8')
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
check('……按 Esc 选区消失,向导仍然开着(没有当成关闭)', await grid.locator('[data-selection]').count() === 0
&& await page.locator('[data-import-wizard]').count() === 1)
// ---- 区域输入
await grid.locator('[data-selection-ref]').fill('C5:F8')
await grid.locator('[data-selection-ref]').press('Enter')
await page.waitForTimeout(100)
check('区域输入:在 [data-selection-ref] 里输入「C5:F8」得到同样的选区', (await grid.locator('[data-selection]').getAttribute('data-selection').catch(() => '')) === 'C5:F8')
await grid.locator('[data-selection-ref]').fill('C5:')
await grid.locator('[data-selection-ref]').press('Enter')
check('……输入「C5:」提示写法不对', (await grid.locator('[data-selection-ref-error]').innerText().catch(() => '')).includes('写法不对'))
// ---- 合并区:c01 的标题 C1:F1 是一个合并格。选区与合并区相交时扩到整个合并区(Excel 的习惯),输入和拖动两条路都守住
await grid.locator('[data-selection-ref]').fill('D1:E2')
await grid.locator('[data-selection-ref]').press('Enter')
await page.waitForTimeout(100)
const mergedByRef = await grid.locator('[data-selection]').getAttribute('data-selection').catch(() => '')
check('区域输入「D1:E2」(与合并区 C1:F1 相交):选区扩到整个合并区,[data-selection="C1:F2"]', mergedByRef === 'C1:F2', mergedByRef)
await grid.locator('[data-selection-cancel]').click()
await dragFromTo('D2', 'C1')
const mergedByDrag = await grid.locator('[data-selection]').getAttribute('data-selection').catch(() => '')
check('……从 D2 拖到合并格 C1:同样扩到 C1:F2(不是只到 D 列的 C1:D2)', mergedByDrag === 'C1:F2', mergedByDrag)
await grid.locator('[data-selection-ref]').fill('C5:F8')
await grid.locator('[data-selection-ref]').press('Enter')
await grid.getByRole('tab', { name: '说明' }).click()
await page.waitForTimeout(200)
check('切换工作表页签后选区清空', await page.locator('[data-sheet-grid] [data-selection]').count() === 0)
await page.locator('[data-sheet-grid]').getByRole('tab', { name: '月报' }).click()
await page.waitForTimeout(150)
// ---- 定位到预览范围之外的格:说清楚,不静默无反应
await page.locator('[data-wizard-side] [data-cell-ref="月报!C400"]').click()
await page.waitForTimeout(200)
check('定位到第 400 行的坐标时,出现「这一格不在预览范围内」的提示',
(await page.locator('[data-grid-out-of-range]').innerText().catch(() => '')).includes('这一格不在预览范围内'))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
// ---- 窄视口(390 宽):用区域输入完成框选并发出预览;选区栏完整可见,没有横向滚动
const narrow = await open('/data/tables', { handlers, viewport: { width: 390, height: 844 } })
const np = narrow.page
await np.locator('[data-source="zz_recipe_c01"] [data-open-staging]').click()
const ngrid = np.locator('[data-sheet-grid]')
await ngrid.locator('[data-select-toggle]').waitFor({ timeout: 5000 }).catch(() => {})
await ngrid.locator('[data-select-toggle]').click()
await ngrid.locator('[data-selection-ref]').fill('C5:F8')
await ngrid.locator('[data-selection-ref]').press('Enter')
replies.preview.push((route, { body }) => json(listPreview(body))(route))
await ngrid.locator('[data-selection-menu]').selectOption('list')
await until(async () => narrow.sent.some((s) => /edits\/preview$/.test(s.key)), 4000)
const nb = narrow.sent.find((s) => /edits\/preview$/.test(s.key))?.body
const barBox = await ngrid.locator('[data-selection-bar]').boundingBox().catch(() => null)
const fits = await ngrid.locator('[data-selection-bar]').evaluate((el) => el.scrollWidth <= el.clientWidth + 1).catch(() => false)
const bodyScroll = await np.locator('[role="dialog"] [data-modal-body]').evaluate((el) => el.scrollWidth - el.clientWidth).catch(() => 99)
check('窄视口(390 宽):用区域输入完成框选并发出预览', nb?.selection?.ref === 'C5:F8' && nb?.selection?.as === 'list', JSON.stringify(nb ?? {}))
check('……选区栏完整可见(在视口内、没有被裁),向导没有横向滚动',
!!barBox && barBox.x >= 0 && barBox.x + barBox.width <= 390 && fits && bodyScroll <= 1,
JSON.stringify({ barBox, fits, bodyScroll }))
check('……没有运行时报错', narrow.errors.length === 0, narrow.errors[0] ?? '')
await narrow.close()
})
/** 按期累积的计划(契约 AccumulatePlan):默认是参考配方 8 月加 9 月(P3-SPEC 2.12 的数字) */
const AUG = { import_id: 'imp-8', seq: 1, start: '2026-08-01', end: '2026-08-31', file_name: '月报导出_2026-08-01_2026-08-31.xlsx', new: false,
rows: { 日客流: 31, 时段客流: 527, 时段客流_表内合计: 93 }, blockers: [] }
const SEP = { import_id: null, seq: null, start: '2026-09-01', end: '2026-09-30', file_name: '月报导出_2026-09-01_2026-09-30.xlsx', new: true,
rows: { 日客流: 30, 时段客流: 510, 时段客流_表内合计: 90 }, blockers: [] }
const plan = (over = {}) => ({
mode: 'accumulate', action: 'append', period: { start: '2026-09-01', end: '2026-09-30' }, parts: [AUG, SEP],
replaces: null, dropped: [], overlaps: [], gaps: [], backfill: false, change: 'same', added: [], retired_new: [], retired_existing: [],
semantic: [], label_sets: [], mode_switch: null, reason: null,
union: { union_id: sha('9'), db_sha256: sha('8'), rows: { 日客流: 61, 时段客流: 1037, 时段客流_表内合计: 183 } }, ...over,
})
const unionChecks = ['union_rows', 'union_pk', 'union_period'].map((kind, i) => flowCheck(`U${i + 1}`, kind,
['各表行数等于各期之和', '主键在各期之间没有重复', '各期的日期都在该期统计期内'][i], 'passed'))
await section('数据 · 表格:按期累积', async () => {
const src = recipeSource('check-manage-acc', 'zz_recipe_acc', {
open_staging: { id: 'stg-acc', kind: 'redraft', status: 'drafting', created_at: ago(60_000) } })
const MODE_Q = { id: 'q_mode', text: '这份报表每期怎么更新', default: null, options: [
{ value: 'accumulate', label: '按期累积(建议)', needs_reason: false }, { value: 'replace', label: '每期替换', needs_reason: false }] }
const MODE_CARD = { id: 'mode', title: '导入模式:按期累积(建议)', reason: '这是按统计期出的报表:每期以统计期为键累积,跨期可以比较;也可以选每期替换', cells: [], question: 'q_mode' }
let current = flowStaging({
id: 'stg-acc', kind: 'redraft', status: 'drafting', source: { id: src.id, name: src.name, exists: true, import_mode: 'recipe' },
draft: null, cards: [...FLOW_CARDS.filter((c) => c.id !== 'mode'), MODE_CARD], questions: [...FLOW_QUESTIONS, MODE_Q], answers: {},
ai: { offered: false, available: true, reason: '', model: '', provider: '' }, recipe: { ...FLOW_RECIPE, mode: 'accumulate' },
draft_problems: [], edits: [], fixes: [], answers_dropped: [],
})
const replies = { answers: [], trial: [], commit: [], get: [] }
const take = (k) => (route, ctx) => {
const r = replies[k].shift()
return r ? r(route, ctx) : json({ detail: `检查脚本没有准备这次的回答(${k})` }, 500)(route)
}
const trialWith = (status, extra) => (route) => { current = { ...current, status: status === 'rejected' ? 'rejected' : 'trialed', trial: flowTrial(status, extra) }; return json(current)(route) }
const { page, sent, natives, errors, close } = await open('/data/tables', {
handlers: [
[/^GET \/datasources$/, (route) => json([...sources, src])(route)],
[/^GET \/datasources\/check-manage-acc\/schema$/, json({ tables: ['日客流'], summary: '', synced_at: ago(0) })],
[/^GET \/datasources\/imports\/stg-acc$/, (route) => (replies.get.length ? take('get')(route) : json(current)(route))],
[/^POST \/datasources\/imports\/[^/]+\/answers$/, take('answers')],
[/^POST \/datasources\/imports\/[^/]+\/trial$/, take('trial')],
[/^POST \/datasources\/imports\/[^/]+\/commit$/, take('commit')],
],
})
const last = (re) => [...sent].reverse().find((s) => re.test(s.key))
const reopen = async () => {
await page.locator('[data-source="zz_recipe_acc"] [data-open-staging]').click()
await page.locator('[data-import-wizard]').waitFor({ timeout: 5000 }).catch(() => {})
}
const back = async () => { await page.locator('[data-back-to-recipe]').click(); await page.waitForTimeout(150) }
const runTrial = async (reply) => {
replies.trial.push(reply)
await page.locator('[data-trial-run]').click()
await page.locator('[data-trial-receipt]').waitFor({ timeout: 4000 }).catch(() => {})
await page.waitForTimeout(150)
}
// ---- 导入模式的问题:单选、无默认,两个选项下方各一句后果
await reopen()
const q = page.locator('[data-question="q_mode"]')
await q.waitFor({ timeout: 4000 }).catch(() => {})
const details = await q.locator('[data-option-detail]').allInnerTexts()
check('q_mode 排在最后:问题单选、没有默认选中', await q.locator('input[type="radio"]').count() === 2
&& await q.locator('input[type="radio"]:checked').count() === 0
&& (await page.locator('[data-question]').last().getAttribute('data-question')) === 'q_mode')
check('……两个选项下方各有一句后果', details.length === 2 && details[0].includes('部分重叠的文件会被拒收') && details[1].includes('此前各期留在历史版本中'),
details.join(' | '))
replies.answers.push((route, { body }) => { current = { ...current, answers: Object.fromEntries(Object.entries(body.answers).map(([k, v]) => [k, { reason: null, ...v }])) }; return json(current)(route) })
await q.locator('[data-option="accumulate"] input').check()
await until(async () => !!last(/answers$/), 4000)
check('……选「按期累积(建议)」发 answers', last(/answers$/)?.body?.answers?.q_mode?.value === 'accumulate', JSON.stringify(last(/answers$/)?.body ?? {}))
const qText = await page.locator('[data-suggestions]').innerText()
check('……问题区不露机读码(不写 q_mode)', !qText.includes('q_mode'))
// ---- 新增一期:两期,本期标「本期」,行数分两栏
await runTrial(trialWith('passed', {
accumulate: plan(), union_checks: unionChecks,
receipt: { ...flowTrial('passed').receipt, rows_excluded: [
{ sheet: '客流汇总', reason: 'ignored_rows', rows: [[32, 32]], cells: 3, anchor: '补录(人次)', block: '交叉表' },
{ sheet: '客流汇总', reason: 'total_not_kept', rows: [[28, 30]], cells: 93, anchor: null, block: '交叉表' },
] },
}))
const dropped = page.locator('[data-trial-receipt] [data-rows-dropped]')
check('回执里「排除的行」按原因分组,原因写中文、不露枚举值', await dropped.locator('[data-excluded-group]').count() === 2
&& (await dropped.innerText()).includes('按配方忽略的行') && (await dropped.innerText()).includes('「补录(人次)」')
&& !/ignored_rows|total_not_kept/.test(await dropped.innerText()))
await dropped.locator('[data-excluded-rows="32-32"]').click()
await page.waitForTimeout(400)
check('……行号可以点:网格滚到那一行', await inView(page.locator('[data-sheet-grid] [data-cell="B32"]')))
const accPlan = page.locator('[data-accumulate-plan]')
const cellOf = async (t, col) => (await accPlan.locator(`[data-plan-table="${t}"] [data-rows-${col}]`).innerText().catch(() => '')).trim()
check('action=append:[data-accumulate-plan] 有两期,本期标「本期」', await accPlan.locator('[data-plan-part]').count() === 2
&& (await accPlan.locator('[data-plan-part="2026-09-01~2026-09-30"]').innerText()).includes('本期')
&& !(await accPlan.locator('[data-plan-part="2026-08-01~2026-08-31"]').innerText()).includes('本期'))
check('……「启用后当前版本」一栏写 61、1037、183,「本期」一栏写 30、510、90',
(await accPlan.locator('[data-col-after]').innerText()).includes('启用后当前版本') && (await accPlan.locator('[data-col-this]').innerText()).includes('本期')
&& await cellOf('日客流', 'after') === '61' && await cellOf('时段客流', 'after') === '1,037' && await cellOf('时段客流_表内合计', 'after') === '183'
&& await cellOf('日客流', 'this') === '30' && await cellOf('时段客流', 'this') === '510' && await cellOf('时段客流_表内合计', 'this') === '90')
check('……整体核对 U1–U3 列出', await accPlan.locator('[data-plan-checks] [data-check]').count() === 3)
const planText = await accPlan.innerText()
check('……计划里不写「并集」「快照」,行数不叫「并集行数」', !P3_FORBIDDEN.test(planText) && planText.includes('新增一期'), planText.slice(0, 80))
// ---- 重新开始累积:移出的期标「将不在当前版本中」,确认项写「此前」
await back()
await runTrial(trialWith('passed', {
accumulate: plan({ action: 'restart', parts: [SEP], dropped: [AUG], change: 'semantic', semantic: ['表「日客流」列「全日客流」的单位 人次 → 万人次'], union: null }),
confirm_items: [{ id: 'accumulate_restart', label: '表结构有不兼容的变化:启用后当前版本只含本期,此前 1 期不再出现在当前版本中', required: true, source: 'accumulate' }],
}))
check('action=restart:被移出的期标「将不在当前版本中」',
(await accPlan.locator('[data-plan-part="2026-08-01~2026-08-31"][data-plan-state="dropped"]').innerText().catch(() => '')).includes('将不在当前版本中'))
await page.locator('[data-to-confirm]').click()
await page.locator('[data-confirm-list]').waitFor({ timeout: 4000 }).catch(() => {})
check('……确认项文案含「此前」', (await page.locator('[data-confirm-item="accumulate_restart"]').innerText().catch(() => '')).includes('此前'))
await dialog(page).getByRole('button', { name: '返回回执' }).click()
await page.waitForTimeout(150)
// ---- 部分重叠:拒收,问题列表有重叠说明,没有「下一步」
await back()
await runTrial(trialWith('rejected', {
checks: [], confirm_items: [],
accumulate: plan({ action: 'rejected', parts: [AUG], overlaps: [AUG], union: null, period: { start: '2026-08-15', end: '2026-09-14' } }),
problems: [{ code: 'period_overlap', category: 'structure', cells: [], fix_ids: [],
message: '本期统计期 2026-08-15 至 2026-09-14 与已有的 2026-08-01 至 2026-08-31 部分重叠:按期累积要求各期互不重叠。请检查文件的统计期;如需重新开始累积,可以在数据源卡片的「版本」中启用更早的版本,或在配方中改为每期替换' }],
}))
check('action=rejected(部分重叠):问题列表有重叠说明,没有「下一步:逐条确认」',
(await page.locator('[data-problem="period_overlap"]').innerText().catch(() => '')).includes('部分重叠')
&& await page.locator('[data-to-confirm]').count() === 0 && await accPlan.locator('[data-plan-overlaps]').count() === 1)
// ---- 替换该期:确认项在「累积」组,排在差异项之前,不勾不能启用;差异卡的空缺需确认
await back()
await runTrial(trialWith('passed', {
accumulate: plan({ action: 'replace_period', parts: [AUG, SEP], replaces: { ...SEP, import_id: 'imp-9', seq: 2, new: false } }),
diff: [{ kind: 'period_gap', label: '2026-08-01 至 2026-08-31 与 2026-10-01 至 2026-10-31 之间有空缺', requires_confirm: true, confirm_id: 'diff:period_gap:2026-09-01~2026-09-30' },
{ kind: 'period_replaced', label: '替换已有的一期 2026-09-01 至 2026-09-30', requires_confirm: false }],
confirm_items: [
{ id: 'unit:日客流.全日客流', label: '「全日客流(人次)」→ 列「全日客流」,单位 人次', required: true, source: 'recipe' },
{ id: 'diff:period_gap:2026-09-01~2026-09-30', label: '各期之间有空缺:2026-09-01 至 2026-09-30', required: true, source: 'diff' },
{ id: 'period_replace:2026-09-01~2026-09-30', label: '替换已有的一期 2026-09-01 至 2026-09-30(第 2 次导入):启用后该期以本次为准', required: true, source: 'accumulate' },
],
}))
const gap = page.locator('[data-reupload-diff] [data-diff="period_gap"]')
check('差异卡的 period_gap 需确认(「各期之间的空缺」)', (await gap.getAttribute('data-requires-confirm').catch(() => null)) === 'diff:period_gap:2026-09-01~2026-09-30'
&& (await gap.innerText().catch(() => '')).includes('各期之间的空缺'))
check('……被替换的那一期标「将被替换」', (await accPlan.locator('[data-plan-state="replaced"]').innerText().catch(() => '')).includes('将被替换'))
await page.locator('[data-to-confirm]').click()
const list = page.locator('[data-confirm-list]')
await list.waitFor({ timeout: 4000 }).catch(() => {})
const order = await list.locator('[data-confirm-item]').evaluateAll((els) => els.map((e) => e.getAttribute('data-confirm-item')))
const ri = order.indexOf('period_replace:2026-09-01~2026-09-30')
check('replace_period:确认清单里有 period_replace:2026-09-01~2026-09-30,在「累积」组里,排在差异项之前',
await list.locator('[data-confirm-group="accumulate"] [data-confirm-item="period_replace:2026-09-01~2026-09-30"]').count() === 1
&& ri >= 0 && order.every((id, i) => !id.startsWith('diff:') || i > ri), order.join(' > '))
for (const id of order.filter((x) => !x.startsWith('period_replace:'))) await list.locator(`[data-confirm-item="${id}"] input`).check()
check('……不勾它不能启用', await list.locator('[data-commit]').isDisabled())
await list.locator('[data-confirm-item="period_replace:2026-09-01~2026-09-30"] input').check()
check('……勾上之后可以启用', await list.locator('[data-commit]').isEnabled())
await dialog(page).getByRole('button', { name: '返回回执' }).click()
await page.waitForTimeout(150)
// ---- 上次接受的理由:只读显示,理由框不预填
await back()
await runTrial(trialWith('needs_decision', {
accumulate: plan(), prior_acceptances: [{ check_id: 'R1', reason: '九月分区数据补录,检查脚本', signed_by: '检查脚本', at: ago(86400_000), import_seq: 2 }],
}))
check('伪造 prior_acceptances:回执里可接受的核对旁有「上次接受的理由」',
(await page.locator('[data-trial-receipt] [data-check="R1"] [data-prior-acceptance="R1"]').innerText().catch(() => '')).includes('上次接受的理由:九月分区数据补录'))
await page.locator('[data-to-confirm]').click()
await list.waitFor({ timeout: 4000 }).catch(() => {})
const acc = list.locator('[data-acceptance="R1"]')
check('……确认清单里可接受的核对旁有「上次接受的理由」(第 2 次导入、署名(未认证)),理由框是空的',
(await acc.innerText().catch(() => '')).includes('上次接受的理由') && (await acc.innerText()).includes('第 2 次导入')
&& (await acc.innerText()).includes('署名(未认证)') && (await acc.locator('textarea').inputValue()) === '')
await tickRequired(list)
await list.locator('[data-acceptance="R1"] textarea').fill('本期补录,检查脚本')
await list.locator('[data-acceptance="K1"] textarea').fill('占位符时段本来无数据,检查脚本')
replies.commit.push(coded(409, 'union_tampered', '试运行生成的数据文件已被改动,请重新试运行'))
replies.get.push((route) => { current = { ...current, status: 'drafting' }; return json(current)(route) })
await list.locator('[data-commit]').click()
await until(async () => await page.locator('[data-import-wizard][data-view="draft"]').count() > 0, 4000)
await page.waitForTimeout(200)
check('commit 返回 409 union_tampered:退回起草并提示重新试运行',
(await page.locator('[data-wizard-notice]').innerText().catch(() => '')).includes('请重新试运行') && await page.locator('[data-trial-run]').count() === 1)
await runTrial(trialWith('passed', { accumulate: plan() }))
await page.locator('[data-to-confirm]').click()
await tickRequired(page.locator('[data-confirm-list]'))
replies.commit.push(coded(409, 'build_conflict', '导入失败:服务端已有的同版本数据文件与登记的不一致,且无法用本次上传恢复,当前版本未受影响。请联系管理员检查数据目录'))
await page.locator('[data-confirm-list] [data-commit]').click()
await until(async () => (await page.locator('[data-confirm-error]').count()) > 0, 4000)
await page.waitForTimeout(150)
check('commit 返回 409 build_conflict:留在确认页,显示原话,「确认并启用」禁用',
await page.locator('[data-confirm-list]').count() === 1 && await page.locator('[data-confirm-list] [data-commit]').isDisabled()
&& (await page.locator('[data-confirm-error]').innerText()).includes('请联系管理员检查数据目录'))
await page.keyboard.press('Escape')
await until(async () => await page.locator('[data-import-wizard]').count() === 0, 4000)
// ---- 已完成页:服务端的同版本数据文件曾被改动,已用本次上传的文件恢复
current = { ...current, status: 'trialed', trial: flowTrial('passed', { accumulate: plan() }) }
await reopen()
await page.locator('[data-to-confirm]').click()
await tickRequired(page.locator('[data-confirm-list]'))
replies.commit.push(json({ source: src, import_id: 'imp-10', snapshot_id: sha('6'), build_id: sha('c'), recipe_id: 'rcp-2',
build_reused: true, unchanged: false, build_restored: true, parts: 2, snapshot_reused: false }, 201))
await page.locator('[data-confirm-list] [data-commit]').click()
await page.locator('[data-import-done]').waitFor({ timeout: 4000 }).catch(() => {})
check('commit 返回 build_restored=true:已完成页有恢复提示',
(await page.locator('[data-build-restored]').innerText().catch(() => '')).includes('已用本次上传的文件恢复'))
await dialog(page).getByRole('button', { name: '完成' }).click()
await page.waitForTimeout(200)
// ---- 只读进程:写请求回 503 store_unavailable,显示服务端原话、不自动重试
current = { ...current, status: 'drafting', trial: null }
await reopen()
const trials0 = sent.filter((s) => /\/trial$/.test(s.key)).length
replies.trial.push(coded(503, 'store_unavailable', '数据目录正被另一个 AgentLab 进程使用:上传表格的版本只支持单进程部署,本进程不写入版本存储'))
await page.locator('[data-trial-run]').click()
await until(async () => (await page.locator('[data-wizard-notice]').innerText().catch(() => '')).includes('数据目录正被另一个'), 4000)
await page.waitForTimeout(1200)
check('任一写请求返回 503 store_unavailable:显示服务端原话,不自动重试',
(await page.locator('[data-wizard-notice]').innerText().catch(() => '')).includes('数据目录正被另一个 AgentLab 进程使用')
&& sent.filter((s) => /\/trial$/.test(s.key)).length === trials0 + 1)
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('数据 · 表格:配方对照与重新起草', async () => {
const src = recipeSource('check-manage-redraft', 'zz_recipe_redraft', {
open_staging: { id: 'stg-redraft', kind: 'redraft', status: 'drafting', created_at: ago(60_000) } })
const ignoreRecipe = JSON.parse(JSON.stringify(FLOW_RECIPE))
ignoreRecipe.sheets[0].blocks[0].ignore_rows = [{ label: '补录(人次)', reason: '表下补录的一行,检查脚本' }]
let current = flowStaging({
id: 'stg-redraft', kind: 'redraft', status: 'drafting', source: { id: src.id, name: src.name, exists: true, import_mode: 'recipe' },
draft: null, cards: [], questions: [], answers: {}, ai: { offered: false, available: true, reason: '', model: '', provider: '' },
recipe: ignoreRecipe, draft_problems: [], edits: [], fixes: [], answers_dropped: [],
})
const aligned = { ...JSON.parse(JSON.stringify(FLOW_RECIPE)), mode: 'accumulate' }
// 与服务端 compare_recipes 同一口径:单位变化另在 units_changed(排最前),tables[].breaking 是这张表其余破坏性变化的
// 原话;占位符含义是表级变化(没有列),只出现在原话里。同一张表既改了单位、又改了类型和占位符含义
const unitCompare = emptyCompare({
tables: [{ name: '日客流', status: 'changed', columns: [
{ name: '分区丙', status: 'added', old: null, new: { type: 'INTEGER', unit: '人次', source: '分区丙(人次)' }, changes: [] },
{ name: '全日客流', status: 'changed', old: { type: 'INTEGER', unit: '人次' }, new: { type: 'INTEGER', unit: '万人次' }, changes: ['unit'] },
{ name: '分区甲', status: 'changed', old: { type: 'INTEGER', unit: '人次', source: '分区甲(人次)' },
new: { type: 'REAL', unit: '人次', source: '分区甲(人次)' }, changes: ['type'] },
], grain: { old: ['日期'], new: ['日期'], changed: false }, kind: { old: 'data', new: 'data' },
breaking: ['列「分区甲」类型 INTEGER → REAL', '值列的占位符「—」含义 无数据 → 不适用'] }],
// 重新起草换了分段:新增的分段在 labels.added 里给全部标签,去掉的分段在 labels.removed 里给全部标签(compare_recipes
// 对空的一边自然给出全部,WP-3 评审修复)。界面要把标题和标签写进同一条,不能只剩一个分段 id
segments: [
{ id: '夜间', status: 'added', title: { old: null, new: '夜间时段客流(人次)' },
labels: { added: ['22-23', '23-24'], removed: [] }, ignore: { added: [], removed: [] } },
{ id: '旧分区', status: 'removed', title: { old: '旧分区客流(人次)', new: null },
labels: { added: [], removed: ['分区丁(人次)', '分区戊(人次)'] }, ignore: { added: [], removed: [] } },
],
breaking: true, units_changed: [{ table: '日客流', column: '全日客流', old: '人次', new: '万人次' }],
})
const replies = { redraft: [], recipe: [], trial: [] }
const take = (k) => (route, ctx) => {
const r = replies[k].shift()
return r ? r(route, ctx) : json({ detail: `检查脚本没有准备这次的回答(${k})` }, 500)(route)
}
const { page, sent, natives, errors, close } = await open('/data/tables', {
handlers: [
[/^GET \/datasources$/, (route) => json([...sources, src])(route)],
[/^GET \/datasources\/check-manage-redraft\/schema$/, json({ tables: ['日客流'], summary: '', synced_at: ago(0) })],
[/^GET \/datasources\/imports\/stg-redraft$/, (route) => json(current)(route)],
[/^POST \/datasources\/imports\/[^/]+\/redraft-rules$/, take('redraft')],
[/^PUT \/datasources\/imports\/[^/]+\/recipe$/, take('recipe')],
[/^POST \/datasources\/imports\/[^/]+\/trial$/, take('trial')],
],
})
const puts = () => sent.filter((s) => /^PUT /.test(s.key))
await page.locator('[data-source="zz_recipe_redraft"] [data-open-staging]').click()
await page.locator('[data-recipe-drawer-toggle]').waitFor({ timeout: 5000 }).catch(() => {})
await page.locator('[data-recipe-drawer-toggle]').click()
const panel = page.locator('[data-recipe-panel]')
await panel.waitFor({ timeout: 4000 }).catch(() => {})
check('RecipePanel 里有「导入模式」单选(按期累积 / 每期替换),选项下方写后果',
await panel.locator('[data-recipe-mode] [data-mode-option] input[type="radio"]').count() === 2
&& (await panel.locator('[data-recipe-mode]').innerText()).includes('按期累积') && (await panel.locator('[data-recipe-mode]').innerText()).includes('每期替换')
&& await panel.locator('[data-recipe-mode] [data-option-detail]').count() === 2)
const rule = panel.locator('[data-ignore-rule]')
check('……带 ignore_rows 的配方:忽略规则以只读列表显示(不是输入框)', await rule.count() === 1
&& (await rule.innerText()).includes('补录(人次)') && await rule.locator('input, textarea').count() === 0)
// ---- 规则起草不完整:没有完整配方,对照也是 null。只说为什么不能采用,不写「配方没有变化」
replies.redraft.push(json({ draft: { recipe: null, complete: false, origin: 'rules', cards: [], questions: [],
failures: ['工作表「客流汇总」:没有找到日期表头(检查脚本)'] }, aligned_recipe: null, alignment: [], compare: null }))
await page.locator('[data-redraft-rules]').click()
const rc = page.locator('[data-redraft-compare]')
await rc.locator('[data-redraft-incomplete]').waitFor({ timeout: 4000 }).catch(() => {})
const incText = await rc.innerText().catch(() => '')
check('规则起草不完整(aligned_recipe、compare 都是 null):写明无法采用和原因,不写「配方没有变化」,「采用」禁用',
incText.includes('无法采用') && incText.includes('没有找到日期表头') && !incText.includes('配方没有变化')
&& await rc.locator('[data-compare-none]').count() === 0 && await rc.locator('[data-redraft-adopt]').isDisabled(), incText)
// ---- 按规则重新起草:对照、名字对齐;「采用」先确认,取消不发请求
replies.redraft.push(json({ draft: { recipe: aligned, complete: true, origin: 'rules', cards: [], questions: [], failures: [] },
aligned_recipe: aligned, alignment: ['表「客流汇总_按日」对齐为现行配方的「日客流」'], compare: unitCompare }))
await page.locator('[data-redraft-rules]').click()
await until(async () => (await rc.innerText().catch(() => '')).includes('对齐为现行配方的「日客流」'), 4000)
check('redraft 暂存区里点 [data-redraft-rules] 发 POST …/redraft-rules,显示对照和名字对齐',
sent.filter((s) => /redraft-rules$/.test(s.key)).length === 2 && (await rc.innerText().catch(() => '')).includes('对齐为现行配方的「日客流」'))
check('……重新起草的对照里,新增分段写出标题和全部标签', (await rc.locator('[data-change-kind="segment_added"]').innerText().catch(() => ''))
.includes('新增分段「夜间」,标题「夜间时段客流(人次)」,标签:「22-23」、「23-24」'))
await rc.locator('[data-redraft-adopt]').click()
const ask = page.locator('[role="dialog"]', { hasText: '采用重新起草的配方?' })
await ask.waitFor({ timeout: 4000 }).catch(() => {})
check('「采用」先弹确认框,文案含「将被替换」', (await ask.innerText().catch(() => '')).includes('将被替换'))
await ask.getByRole('button', { name: '取消' }).click()
await page.waitForTimeout(250)
check('……取消则不发请求', puts().length === 0)
replies.recipe.push((route, { body }) => { current = { ...current, recipe: body.recipe, recipe_origin: 'rules_redraft' }; return json(current)(route) })
await rc.locator('[data-redraft-adopt]').click()
await ask.waitFor({ timeout: 4000 }).catch(() => {})
await ask.getByRole('button', { name: '采用' }).click()
await until(async () => puts().length === 1, 4000)
check('……确认后发 PUT …/recipe,body 是 {recipe: aligned_recipe, origin: "rules_redraft"}',
JSON.stringify(puts()[0]?.body) === JSON.stringify({ recipe: aligned, origin: 'rules_redraft' }), JSON.stringify(puts()[0]?.body ?? {}).slice(0, 160))
// ---- 采用后的试运行:对照里单位变化排第一;确认清单里有 redraft_adopted
replies.trial.push((route) => { current = { ...current, status: 'trialed', trial: flowTrial('passed', {
recipe_compare: unitCompare,
confirm_items: [...FLOW_CONFIRMS.slice(0, 3), { id: 'redraft_adopted', label: '采用按规则重新起草的配方:表「客流汇总_按日」对齐为「日客流」', required: true, source: 'edit' }],
}) }; return json(current)(route) })
await page.locator('[data-trial-run]').click()
const cmp = page.locator('[data-wizard-side] [data-recipe-compare]')
await cmp.waitFor({ timeout: 4000 }).catch(() => {})
const kinds = await cmp.locator('[data-change-kind]').evaluateAll((els) => els.map((e) => e.getAttribute('data-change-kind')))
check('伪造 recipe_compare(单位变了一列、新增一列):[data-recipe-compare] 里单位变化排第一',
kinds[0] === 'unit' && kinds.includes('column_added') && (await cmp.locator('[data-change-kind="unit"]').innerText()).includes('人次 → 万人次'), kinds.join(','))
const lines = (await cmp.locator('[data-change-kind]').allInnerTexts()).map((x) => x.trim())
check('……同一张表另有类型变化和占位符含义变化:占位符含义这条破坏性原话照样列出(标破坏性),类型变化、单位变化各只列一次',
lines.some((x) => x.includes('占位符「—」含义 无数据 → 不适用') && x.includes('日客流'))
&& await cmp.locator('[data-breaking]', { hasText: '占位符「—」含义' }).count() === 1
&& lines.filter((x) => x.includes('分区甲') && x.includes('REAL')).length === 1
&& lines.filter((x) => x.includes('万人次')).length === 1, lines.join(' | '))
const segAdded = await cmp.locator('[data-change-kind="segment_added"]').allInnerTexts()
const segRemoved = await cmp.locator('[data-change-kind="segment_removed"]').allInnerTexts()
check('新增的分段:同一条里写出标题和全部标签(labels.added),不另摊一条「加入标签」',
segAdded.length === 1 && segAdded[0].includes('新增分段「夜间」') && segAdded[0].includes('标题「夜间时段客流(人次)」')
&& segAdded[0].includes('标签:「22-23」、「23-24」')
&& await cmp.locator('[data-change-kind="labels"]').count() === 0, [...segAdded, ...lines].join(' | '))
check('……去掉的分段:写出原标题和原有的全部标签(labels.removed)',
segRemoved.length === 1 && segRemoved[0].includes('去掉分段「旧分区」') && segRemoved[0].includes('原标题「旧分区客流(人次)」')
&& segRemoved[0].includes('原有标签:「分区丁(人次)」、「分区戊(人次)」'), segRemoved.join(' | '))
await page.locator('[data-to-confirm]').click()
await page.locator('[data-confirm-list]').waitFor({ timeout: 4000 }).catch(() => {})
check('伪造采用后的试运行:确认清单里有 redraft_adopted', await page.locator('[data-confirm-list] [data-confirm-item="redraft_adopted"]').count() === 1)
const text = await wizardText(page)
check('……新增的文字不含「快照」「并集」「构建」「锚点」「放行」「钉」,不露机读码', !P3_FORBIDDEN.test(text) && !/redraft_adopted|rules_redraft/.test(text))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('数据 · 表格:改配方后回答保留', async () => {
const src = recipeSource('check-manage-carry', 'zz_recipe_carry', {
open_staging: { id: 'stg-carry', kind: 'redraft', status: 'drafting', created_at: ago(60_000) } })
let current = flowStaging({
id: 'stg-carry', kind: 'redraft', status: 'drafting', source: { id: src.id, name: src.name, exists: true, import_mode: 'recipe' },
ai: { offered: false, available: true, reason: '', model: '', provider: '' }, recipe: FLOW_RECIPE,
answers: { 'q_placeholder:·': { value: 'null', reason: null } }, draft_problems: [], fixes: [], answers_dropped: [],
edits: [fixEdit()], recipe_sha256: sha('b'),
})
const replies = { recipe: [], trial: [] }
const take = (k) => (route, ctx) => {
const r = replies[k].shift()
return r ? r(route, ctx) : json({ detail: `检查脚本没有准备这次的回答(${k})` }, 500)(route)
}
const { page, sent, natives, errors, close } = await open('/data/tables', {
handlers: [
[/^GET \/datasources$/, (route) => json([...sources, src])(route)],
[/^GET \/datasources\/check-manage-carry\/schema$/, json({ tables: ['日客流'], summary: '', synced_at: ago(0) })],
[/^GET \/datasources\/imports\/stg-carry$/, (route) => json(current)(route)],
[/^PUT \/datasources\/imports\/[^/]+\/recipe$/, take('recipe')],
[/^POST \/datasources\/imports\/[^/]+\/trial$/, take('trial')],
],
})
const puts = () => sent.filter((s) => /^PUT /.test(s.key))
await page.locator('[data-source="zz_recipe_carry"] [data-open-staging]').click()
await page.locator('[data-edits]').waitFor({ timeout: 5000 }).catch(() => {})
check('有未被覆盖的修改:[data-edits] 有「撤销上一次修改」', await page.locator('[data-edits] [data-edit-undo]').count() === 1)
await page.locator('[data-recipe-drawer-toggle]').click()
const panel = page.locator('[data-recipe-panel]')
await panel.waitFor({ timeout: 4000 }).catch(() => {})
replies.recipe.push(async (route, { body }) => {
current = { ...current, recipe: body.recipe, answers: { 'q_placeholder:·': { value: 'null', reason: null } },
answers_dropped: [{ id: 'q_relation:F2', text: '系统发现的关系 F2 要登记吗', reason: 'changed' },
{ id: 'q_relation:F9', text: '「—」是否表示无数据', reason: 'gone' }],
edits: [fixEdit({ superseded: true, undoable: false })] }
// 晚一点返回:好在这次保存还没回来时再改一处(配方面板把它排在后面,等这次返回后接着发)
await new Promise((r) => setTimeout(r, 1200))
return json(current)(route)
})
replies.recipe.push((route, { body }) => { current = { ...current, recipe: body.recipe }; return json(current)(route) })
await panel.locator('[data-field="/tables/0/units/分区甲"]').selectOption('人')
const ask = page.locator('[role="dialog"]', { hasText: '保存对配方的修改?' })
await ask.waitFor({ timeout: 4000 }).catch(() => {})
check('PUT 之前有未被覆盖的修改:先确认(写明含已做的 1 项修改、将被替换),还没发 PUT',
(await ask.innerText().catch(() => '')).includes('含已做的 1 项修改') && (await ask.innerText()).includes('将被替换') && puts().length === 0)
await ask.getByRole('button', { name: '保存' }).click()
await until(async () => puts().length === 1, 4000)
// 这次保存还没返回:再改一处。返回之后修改已被覆盖,排在后面的这一批不该再问一次(再问时点取消,这一处就丢了)
await panel.locator('[data-field="/tables/0/units/分区乙"]').selectOption('人')
await until(async () => puts().length === 2 || (await ask.count()) > 0, 6000)
await page.waitForTimeout(200)
const askedAgain = await ask.count()
const put2 = puts()[1]?.body?.recipe?.tables?.[0]?.units ?? {}
check('保存还没返回时又改了一处:返回后接着保存这一处,不再弹「保存对配方的修改?」,第二次 PUT 带着两处修改',
askedAgain === 0 && puts().length === 2 && put2.分区乙 === '人' && put2.分区甲 === '人',
`再次确认 ${askedAgain} 次,PUT ${puts().length} 次,${JSON.stringify(put2)}`)
if (askedAgain) {
// 旧行为会再问一次:点「保存」让后面的检查接着走
await ask.getByRole('button', { name: '保存' }).click()
await until(async () => puts().length === 2, 4000)
}
await page.waitForTimeout(300)
check('PUT 返回的 answers 里保留了已选项:该选项仍是选中状态',
await page.locator('[data-question="q_placeholder:·"] [data-option="null"] input').isChecked())
const dropped = page.locator('[data-answers-dropped]')
const lines = await dropped.locator('[data-answers-dropped-reason]').allInnerTexts()
check('返回 answers_dropped 时出现 [data-answers-dropped],changed 和 gone 分两句,列出问题文字(不露 id)',
lines.length === 2 && lines[0].includes('需要重新回答') && lines[0].includes('系统发现的关系 F2 要登记吗')
&& lines[1].includes('已不适用') && lines[1].includes('「—」是否表示无数据') && !(await dropped.innerText()).includes('q_relation'), lines.join(' | '))
const editsBox = page.locator('[data-edits]')
check('PUT 之后 edits 全部 superseded:[data-edits] 里标「已被覆盖」,没有「撤销上一次修改」',
(await editsBox.locator('[data-edit-superseded]').innerText().catch(() => '')).includes('已被覆盖') && await editsBox.locator('[data-edit-undo]').count() === 0)
replies.recipe.push((route, { body }) => { current = { ...current, recipe: body.recipe }; return json(current)(route) })
await panel.locator('[data-field="/tables/0/units/全日客流"]').selectOption('人')
await until(async () => puts().length === 3, 4000)
check('……修改都已被覆盖:之后再改配方不再弹确认', await page.locator('[role="dialog"]', { hasText: '保存对配方的修改?' }).count() === 0
&& puts().length === 3)
replies.trial.push((route) => { current = { ...current, status: 'trialed', trial: flowTrial('passed', { confirm_items: FLOW_CONFIRMS }) }; return json(current)(route) })
await page.locator('[data-trial-run]').click()
await page.locator('[data-to-confirm]').waitFor({ timeout: 4000 }).catch(() => {})
await page.locator('[data-to-confirm]').click()
await page.locator('[data-confirm-list]').waitFor({ timeout: 4000 }).catch(() => {})
check('……确认清单里没有 fix:*', await page.locator('[data-confirm-list] [data-confirm-item^="fix:"]').count() === 0
&& await page.locator('[data-confirm-list] [data-confirm-item]').count() > 0)
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 }
// 写外部系统的能力:服务端在工具项上给 write_rehearsal(和执行接口同一个判断),在工具库里执行只演练
const payTool = { id: 'pay_demo', name: 'pay_demo', description: '检查脚本的写入能力', category: '自定义 · http',
source: 'custom', dangerous: true, runtime_approval: true, write_rehearsal: true, schema: { properties: { amount: { type: 'number' } } } }
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, payTool])(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()],
// 写入能力(服务端 tools/custom.held_write):不发送,交回将要发送的请求,标 rehearsal
[/^POST \/custom-tools\/test$/, (route, { body }) => json(body?.config?.url?.includes('pay') ? { ok: true, rehearsal: true, duration_ms: 3,
result: { rehearsal: true, message: '演练:写入能力不在这里真的发送,下面是将要发送的请求', endpoint: 'erp', environment: 'test',
request: { method: 'POST', url: 'https://erp.example.com/test/payments', query: {}, body: { amount: 3 },
headers: ['Authorization', 'Idempotency-Key'] } } }
: 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]')))
}
{
// 写入能力在这里只演练(执行不先确认、也不真的发出):提示条不说「请求会实际发出」;别的外接工具照旧
await page.locator('nav[aria-label="工具列表"] button', { hasText: 'pay_demo' }).first().click()
await page.waitForTimeout(300)
const bar = page.locator('[data-tool-side-effect]')
const barText = await bar.innerText().catch(() => '')
check('写入能力:提示条写这里只演练、不真的发送,不说请求会实际发出', await bar.getAttribute('data-tool-side-effect') === 'rehearsal'
&& barText.includes('这里只演练,不真的发送') && !barText.includes('实际发出') && !barText.includes('确认后才会执行'), barText)
await page.locator('nav[aria-label="工具列表"] button', { hasText: 'broken_demo' }).first().click()
await page.waitForTimeout(300)
const other = await bar.innerText().catch(() => '')
check('……不是写入能力的自定义工具照旧警示请求会实际发出', await bar.getAttribute('data-tool-side-effect') === 'send'
&& other.includes('请求会实际发出'), other)
}
{
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('核对接口地址'))
// 写入能力:工具页试运行只演练(对外写入只经调用工具节点:要确认、带幂等键、留生效记录),不说「成功」(V2-13)
await ed.getByLabel(/^URL/).fill('https://api.example.com/pay')
await tryBtn.click()
const rehearsed = trialBox.locator('[data-tool-result="rehearsal"]')
check('……写入能力:写明演练、不在这里真的发送,不说成功', await rehearsed.waitFor({ timeout: 3000 }).then(() => true, () => false)
&& (await rehearsed.innerText()).includes('演练:写入能力不在这里真的发送') && !(await rehearsed.innerText()).includes('成功'))
const plan = await rehearsed.locator('[data-tool-rehearsal]').innerText().catch(() => '')
check('……列出将要发送的方法和地址、环境、请求头的名字、请求体', plan.includes('POST https://erp.example.com/test/payments')
&& plan.includes('测试环境') && plan.includes('Authorization、Idempotency-Key') && plan.includes('"amount": 3'), plan.replace(/\s+/g, ' '))
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))
// 和能力治理同一种说法(P5):三档只用于探索运行;正式运行一律问人,能力登记里「正式运行免审批」的只读能力除外
const trustText = await page.locator('[data-trust-block]').innerText()
check('……旁边说清三档的差别,写明正式运行不看这个设置、和能力治理的免审批一致',
trustText.includes('这里的设置只用于探索运行') && trustText.includes('「正式运行免审批」的只读能力除外')
&& !trustText.includes('一律需人工审批。'), trustText.replace(/\s+/g, ' '))
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)