mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 15:21:58 +00:00
f4755056e6
为什么:探查改成了后台任务(POST /introspect 回 202 和任务),界面不能再等同步结果;几千张表的库要能设范围、看命中数, 目录清单要说清表为什么不在,范围外的表要找得到、加得进来。这一波直接进 main,不能有半截的功能。 - 数据源卡片:「探查结构」开始后台任务,进行中每秒取一次进度(进度条、正在读哪张、排在谁后面),「停止」停在当前一批之后; 结束时换上任务带回的数据源、说一句结果;最近一次没做完(部分读不到、停止、中断、失败)常驻一行,读不到的表可展开。 规则命中超过上限时照实说命中数并给「调整探查范围」;已有任务时跟上那一个;对象清单拿不到时重取卡片显示原因。 有范围外的表时写「探查范围 400 / 3,000」。换 schema 后的重新探查、按配置重新探查、新建后的探查都走任务。 - 探查范围面板(IntrospectScopeDialog):全部对象 / 按规则,包含、排除(排除优先)、显式列出、显式移出、包含视图、上限; 边改边数命中(停手 400 毫秒问服务端,不连库),超上限写明会被拒绝、「保存并探查」点不了;一律在范围内的表说明和举例; 按需加入的留痕;没有对象索引时「刷新对象索引」(dry_run)。界面不算命中,规则只在服务端一处。 - 数据目录:表清单一次画 200 行、「显示更多」;表结构里没有的按 absent_reason 分开说(只有真删除说「表已不在」),老服务端 不给原因时照旧;有范围外的表时搜索词同时在对象索引里找,命中列在清单下面、「加入范围」后清单重取;顶部说明按探查范围写 (老缓存照旧说只探查了前 N 张、请重新探查);表详情按原因说;起草、剖析排在探查后面时写明。 - 运行面板的查看记录读 added_to_scope,展开区写「按需加入探查范围」。 - 类型、接口、文案各写各的块(types.ts / typesApp.ts 末尾、client.ts 的 datasources 和 dataCatalog、terms.ts 的 INTROSPECT_TEXT / INTROSPECT_SCOPE_TEXT / CATALOG_ABSENT_TEXT / CATALOG_SCOPE_TEXT),SCHEMA_PARTIAL_TEXT 改成按探查范围说。 - 检查:check-manage 的假探查改回 202 和任务、加「数据 · 后台探查与探查范围」一节(超上限、刷新对象索引、边改边数、 保存并探查、进度、停止、结果);check-catalog 的「只探查了一部分表」改成老缓存的说法,加「探查范围:三种不在、分批显示、 加入范围」一节。两项检查 1,135 项全过。 - introspect_jobs 的一句报错不用「连不上」(check-copy)。
4715 lines
354 KiB
JavaScript
4715 lines
354 KiB
JavaScript
// 管理页(工具 / 知识 / 数据 / 设置)的交互回归。
|
||
//
|
||
// 这几页的毛病都不在"渲染不出来",而在交互的细处:写操作失败一声不响、中文
|
||
// 输入法选词的回车把半句话写进长期记忆、原生 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('数据 · 数据库', 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)
|
||
// 口径同 README「哪些角色看得到原值」:未开启登录时对谁都只减少暴露,开了登录对使用者是边界——两头都说清
|
||
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)
|
||
// 有问题的配方:一条对得上字段(单位),一条对不上(挂到面板顶部)
|
||
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 === 7, 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 === 8, 4000)
|
||
check('「粘贴配方」发 PUT,body 就是粘贴的配方', JSON.stringify(puts()[7]?.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)
|