Files
AgentLab/scripts/check-capabilities.mjs
T
ThearchyHelios 479cec0af7 通知事件一次登记(总方案 X6、5.1):六个新事件进 notify/service.EVENTS,推给哪几条路也登记在那一处
复核指出 X6 漏做:第 2、3 波的数据源变化、口径召回、评测、触达四块都要往 EVENTS、EVENT_LABEL 和前端设置页的事件列表里
加东西,正是 X6 要避免的同一处多方冲突;消息里能放什么也没有一处先定好。

做了什么:
- EVENTS 改成登记表:每个事件一行,叫法 + 推给哪几条路(scopes:global 全局渠道自己订阅、user 相关的人自己的个人渠道、
  app 应用版本 spec.notify 的订阅)。本轮新登记 source.changed、ledger.recall、eval.regressed、eval.failed、task.digest、
  key.expiring,scopes 都是空的——登记了、还没有推送。实现推送的那一块只改自己那一行的 scopes(各行之间隔着说明行,几块并行
  改也不会挨着冲突),前端叫法表不用再动。EVENT_LABEL 从登记表派生,老的 import 不变。
- 模块说明写清消息结构:标题、状态、条数、链接,加编号和搭建时起的名字;不放报告和答案里的数、表名、列名、取值、SQL、
  按运行数据渲染出来的句子。
- _channels 只走事件登记了的路;没登记的事件直接报错(推送之前先登记)。现有五个事件登记的路照现在的做法,行为不变。
- 接口:订阅按登记核对(subscribe_problem):没推送的「还没有推送,暂时不能订阅」,范围不对的「不推给范围是「应用」的
  渠道」。只核对这次新勾的:以前存下的照旧,开关、改名不会因此 422。GET /api/notify/events 多给 scopes(空的照实给)。
- 前端:terms.ts 的 eventLabel 和 EVENTS 一一对应、逐字一致,六个新事件先有叫法;设置页的渠道编辑框、应用的通知改按
  GET /api/notify/events 列(这种范围收得到的才列,已经勾着的照样列出来能取消;取不到时照实说)。

顺带修掉的(就在要改的那张列表上):设置页以前按前端叫法表的键列事件,里面有五个服务端从来不认的(待办升级、待办超时、
待办处理完、动作失败、动作已生效),一勾保存就 422;改派、升级、催办只在投递记录里出现,另起 deliveryEventLabel(加上测试
消息),记录里照实写是哪一种。服务端 EVENT_LABEL 的四句老叫法(有待办派给我、实例完成、实例失败、写入结果未知)改成和界面
同一句,界面上看到的字不变。

验证:新增 test_notify_events(登记表、前后端逐字对照、接口拒收、只走登记了的路),去掉 _channels 的按路过滤或「只核对新勾
的」回跑各有一项失败;check-capabilities 加一段「可订阅的事件照服务端登记列」,把列表改回按叫法表的键列,四项失败。
2026-10-16 12:12:14 +08:00

412 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 工具页的「能力」「端点」页签和设置页的「身份」「通知」「审计」的检查(应用平台 A6、A7)。
//
// 守的是:
// - 能力列表标出读写效果,同步得出和治理后不一致时标出来;治理带 version 保存(乐观锁),409 时载入最新的、不覆盖;
// - 端点的认证头只显示掩码,提交掩码值表示保持原值;名称空着、不合法、重名的那一行标红、不让保存;允许访问内网只有
// 管理员能开;测试连接按环境发,照服务端的话写(2xx 才是成功,能连上但 404 是提醒);
// - 关登录(默认)时所有人等同管理员:治理、退役、身份设置都能改;
// - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名;
// - 能力字段的待审阅(D-i)复用数据目录的审阅队列:成批确认逐项带能力的治理版本;
// - 自定义工具可以绑定登记的端点(只填路径);MCP 的认证头按掩码编辑;本地子进程只有管理员能建、能改(A6);
// - 单点登录映射管理员:用户或组至少一项;开了登录的使用者在设置页只剩个人的主题和通知,换主题不写服务端(D-s)。
//
// 不写库:/api/capabilities、/api/endpoints、/api/auth、/api/users、/api/groups、/api/audit、/api/notify 全部伪造,
// 伪造的接口改一份内存里的数据。其余写请求一律 409 并记账,检查结束时必须是空的。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-capabilities.mjs
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const ONLY = (process.env.CAPABILITIES_ONLY ?? '').split(',').filter(Boolean)
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`=== ${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()
}
}
const waitFor = async (page, fn, ms = 5000) => {
for (let t = 0; t < ms; t += 100) {
if (await fn()) return true
await page.waitForTimeout(100)
}
return false
}
// ---------------------------------------------------------------- 夹具(通用示例名)
const cap = (name, extra = {}) => ({
name, system: 'erp', title: name, description: '', params_schema: { type: 'object', properties: {} }, returns_schema: null,
tabular: null, effect_system: 'read', effect: null, approval: 'inherit', unattended_write: false, fields: {}, masked: [],
credential_mode: 'service', endpoint_id: null, lookup: null, version: 1, missing: false, synced_at: '2026-10-10T02:00:00Z',
updated_by: null, ...extra,
})
const CAPS = [
cap('erp_query'),
cap('erp_pay', { effect_system: 'dynamic', effect: 'write', approval: 'confirm' }),
cap('mcp:crm/find', { system: 'crm', effect_system: 'read' }),
cap('db_query__shop', { system: 'shop', effect_system: 'dynamic' }),
]
const ENDPOINTS = [{
id: 'ep1', name: 'erp', description: '示例 ERP', idempotency_header: 'Idempotency-Key', timeout_s: 20, version: 3,
environments: {
prod: { base_url: 'https://erp.example.com/api', headers: { Authorization: 'Bearer ****1234' }, allow_intranet: false, verify_tls: true },
},
}]
// 形状同服务端 data/capability_review.review_queue:能力名、位置(指针、字段、写好的位置说法)、值、来源、状态、优先级、原因、运行次数、版本
const REVIEW = [
{ capability: 'erp_pay', system: 'erp', title: '付款', effect: 'write', pointer: '/amount', field: 'unit', field_text: '单位',
field_label: '返回字段 /amount 的单位', value: '元', display: '元', source: 'model', status: 'proposed', note: null, priority: 9,
reasons: ['写入能力', '被 12 次运行用过'], runs: 12, version: 4 },
{ capability: 'erp_query', system: 'erp', title: '查询', effect: 'read', pointer: '/items/*/name', field: 'label', field_text: '中文名',
field_label: '返回字段 /items/*/name 的中文名', value: '品名', display: '品名', source: 'naming', status: 'proposed', note: null,
priority: 3, reasons: ['被 3 次运行用过'], runs: 3, version: 2 },
]
const OFF = { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false }
const MCP_ROWS = [
{ id: 'm1', name: 'files', transport: 'stdio', command: 'npx', args: ['-y', 'fs'], env: {}, url: null, enabled: true, headers: {},
endpoint_id: null, status: 'ok', last_error: null, tools_cache: [] },
{ id: 'm2', name: 'crm', transport: 'http', command: null, args: [], env: {}, url: 'https://crm.example.com/mcp', enabled: true,
headers: { Authorization: 'Bearer ****9a2c' }, endpoint_id: null, status: 'ok', last_error: null, tools_cache: [] },
]
async function open(path, { conflict = false, me = OFF } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
opened.add(ctx)
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
const state = { caps: JSON.parse(JSON.stringify(CAPS)), endpoints: JSON.parse(JSON.stringify(ENDPOINTS)), governs: [], endpointWrites: [],
tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [] }
await page.route(/\/api\//, (route) => {
const req = route.request()
if (req.method() === 'GET') return route.continue()
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
return json(route, { detail: '检查脚本不写库' }, 409)
})
await page.route(/\/api\/auth\/me$/, (route) => json(route, me))
await page.route(/\/api\/capabilities(\?.*)?$/, (route) => json(route, { items: state.caps }))
await page.route(/\/api\/capabilities\/review-queue(\?.*)?$/, (route) => {
const url = new URL(route.request().url())
state.reviews.push(url.search)
const name = url.searchParams.get('capability')
const items = REVIEW.filter((i) => !name || i.capability === name)
return json(route, { total: items.length, items })
})
await page.route(/\/api\/capabilities\/review\/batch$/, (route) => {
const body = route.request().postDataJSON()
state.batches.push(body)
return json(route, { results: body.items.map((i) => (i.capability === 'erp_query'
? { capability: i.capability, pointer: i.pointer, field: i.field, ok: false, version: null,
error: '能力「erp_query」的治理设置刚被修改过,请重新载入后再审阅', code: 'capability_conflict' }
: { capability: i.capability, pointer: i.pointer, field: i.field, ok: true, version: i.if_version + 1, error: null, code: null })) })
})
await page.route(/\/api\/custom-tools(\/[^/]+)?$/, (route) => {
const req = route.request()
if (req.method() === 'GET') return json(route, [])
state.tools.push({ method: req.method(), body: req.postDataJSON() })
return json(route, { id: 't1', ...req.postDataJSON() })
})
await page.route(/\/api\/mcp\/servers(\/[^/]+)?$/, (route) => {
const req = route.request()
if (req.method() === 'GET') return json(route, MCP_ROWS)
state.mcp.push({ method: req.method(), url: new URL(req.url()).pathname, body: req.postDataJSON() })
return json(route, { id: 'm9', ...req.postDataJSON() })
})
await page.route(/\/api\/settings$/, (route) => {
if (route.request().method() !== 'GET') { state.prefs.push(route.request().postDataJSON()); return json(route, {}) }
return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, { ui: { theme: 'dark' } })
})
await page.route(/\/api\/capabilities\/sync$/, (route) => { state.syncs += 1; return json(route, { changed: 2 }) })
await page.route(/\/api\/capabilities\/[^/]+\/governance$/, (route) => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3])
const body = route.request().postDataJSON()
state.governs.push({ name, body })
const cur = state.caps.find((c) => c.name === name)
if (conflict && state.governs.length === 1) {
cur.version += 1
cur.updated_by = '别人'
return json(route, { detail: '这个能力刚被其他人修改过' }, 409)
}
Object.assign(cur, body, { version: cur.version + 1 })
return json(route, cur)
})
await page.route(/\/api\/endpoints(\/[^/?]+)?(\/test)?(\?.*)?$/, (route) => {
const req = route.request()
const url = new URL(req.url())
const parts = url.pathname.split('/')
if (parts[4] === 'test') {
state.tests.push({ id: parts[3], env: url.searchParams.get('env') })
// 服务端按状态码照实说(capabilities/endpoints.probe):2xx 才 ok;能连上但 404 的 reachable 为真、ok 为假
return json(route, state.probe ?? { ok: true, reachable: true, status: 200, message: '连接成功:基础地址返回 HTTP 200' })
}
if (req.method() === 'GET') return json(route, { items: state.endpoints })
state.endpointWrites.push({ method: req.method(), body: req.postDataJSON?.() ?? null })
if (req.method() === 'PATCH') {
const ep = state.endpoints.find((e) => e.id === parts[3])
Object.assign(ep, req.postDataJSON(), { version: ep.version + 1 })
return json(route, ep)
}
return json(route, { ok: true })
})
await page.route(/\/api\/auth\/settings$/, (route) => {
if (route.request().method() === 'PUT') { state.settings.push(route.request().postDataJSON()); return json(route, { ok: true }) }
return json(route, { mode: 'off', env_locked: false, oidc: {}, user_can_chat: false })
})
await page.route(/\/api\/users$/, (route) => json(route, { items: [] }))
await page.route(/\/api\/groups$/, (route) => json(route, { items: [{ id: 'g1', name: 'finance', label: '财务', source: 'oidc', members: ['li'] }] }))
await page.route(/\/api\/audit(\?.*)?$/, (route) => json(route, { items: [
{ id: 'a1', at: '2026-10-10T02:00:00Z', actor: '张工', verified: false, action: 'capability.govern', subject_kind: 'capability',
subject_id: 'erp_pay', data: { version: 2 } }] }))
await page.route(/\/api\/notify\/channels$/, (route) => json(route, { items: [
{ id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true,
events: ['task.opened'], enabled: true }] }))
await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: [] }))
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
return { page, state, errors }
}
const writes = []
const browser = await chromium.launch({ executablePath: CHROME })
// ================================================================
await section('能力列表:效果、筛选、同步', async () => {
const { page, state, errors } = await open('/tools/capabilities')
// 「能力」页签里还有「全部能力 / 待审阅」两个子页签(D-i),按名字整字匹配
check('工具页有「能力」页签', await page.getByRole('tab', { name: '能力', exact: true }).count() === 1,
(await page.locator('[role=tab]').allInnerTexts()).join(' | '))
check('列出全部能力', await page.locator('[data-capability]').count() === CAPS.length)
const pay = page.locator('[data-capability="erp_pay"] [data-capability-effect]')
check('治理后的效果和同步得出的不一致时标出来', (await pay.innerText()).includes('写入') && (await pay.innerText()).includes('*'))
check('没治理过的按同步得出的效果显示', (await page.locator('[data-capability="erp_query"] [data-capability-effect]').innerText()).includes('只读'))
await page.locator('[data-capability="mcp:crm/find"]').click()
// 右栏的治理设置是点开之后另取的:等它出来再判
check('能力名带斜杠也能打开(地址转义)', await waitFor(page, async () =>
await page.locator('[data-capability-editor="mcp:crm/find"]').count() === 1), page.url())
await page.locator('[data-capability-sync]').click()
check('立即同步', await waitFor(page, async () => state.syncs === 1))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
await section('能力治理:带 version 保存;别人刚改过时载入最新的、不覆盖', async () => {
const { page, state } = await open('/tools/capabilities/erp_pay', { conflict: true })
const ed = page.locator('[data-capability-editor="erp_pay"]')
await ed.waitFor()
check('写入能力给无人值守写入的开关', await ed.locator('[data-capability-unattended]').count() === 1)
await ed.locator('[data-capability-masked]').fill('/data/items/*/phone')
await page.locator('[data-capability-save]').click()
check('保存带上读到的 version', await waitFor(page, async () => state.governs.length === 1) && state.governs[0].body.version === 1,
JSON.stringify(state.governs[0]?.body))
check('遮罩字段一行一个,写成列表', JSON.stringify(state.governs[0]?.body.masked) === JSON.stringify(['/data/items/*/phone']))
check('409:说明被别人改过,载入最新内容', await waitFor(page, async () => (await page.locator('body').innerText()).includes('刚被其他人修改过')))
check('载入的是最新版本', await waitFor(page, async () => (await page.locator('[data-capability-editor="erp_pay"]').innerText()).includes('v2')))
})
// 查询能力由平台按登记的端点替它按幂等键去查:只能是同一端点上、绑定这个端点的只读能力。写记忆这类改平台自身状态的
// 内置工具不是只读,也不是端点上的读取,不能出现在候选里
await section('能力治理:查询能力只列同一端点上的只读能力', async () => {
const { page, state } = await open('/tools/capabilities/erp_pay')
for (const c of state.caps) if (['erp_pay', 'erp_query', 'mcp:crm/find'].includes(c.name)) c.endpoint_id = 'ep1'
state.caps.push(cap('memory_write', { system: 'builtin', effect_system: 'dynamic' }), cap('memory_recall', { system: 'builtin' }),
cap('crm_read', { system: 'crm', endpoint_id: 'ep2' }))
await page.reload({ waitUntil: 'networkidle' })
const ed = page.locator('[data-capability-editor="erp_pay"]')
await ed.waitFor()
const opts = await ed.locator('[data-capability-lookup] option').evaluateAll((els) => els.map((e) => e.value).filter(Boolean))
check('只列同一端点上的只读能力:写记忆、别的端点、MCP、没绑端点的都不列', opts.join(',') === 'erp_query', opts.join(','))
})
await section('端点:两个环境、认证头只显示掩码、测试连接', async () => {
const { page, state } = await open('/tools/endpoints')
const ep = page.locator('[data-endpoint="erp"]')
check('列出端点', await ep.count() === 1)
check('认证头只显示掩码', (await ep.locator('[data-endpoint-headers]').innerText()).includes('****1234'))
check('没有测试环境照实写', (await ep.locator('[data-endpoint-env="test"]').innerText()).includes('未配置'))
await ep.locator('[data-endpoint-test="prod"]').click()
check('测试连接按环境发', await waitFor(page, async () => state.tests.length === 1) && state.tests[0].env === 'prod', JSON.stringify(state.tests))
check('2xx:成功,照服务端的话写', await waitFor(page, async () =>
(await page.locator('[data-toast="ok"]').last().innerText().catch(() => '')).includes('连接成功:基础地址返回 HTTP 200')))
// 能连上但基础地址回 404:不是「连接成功(HTTP 404)」,是提醒,照服务端的话写清楚
state.probe = { ok: false, reachable: true, status: 404, message: '能连上,但对方系统上没有这个地址(HTTP 404)' }
await ep.locator('[data-endpoint-test="prod"]').click()
const warned = await waitFor(page, async () =>
(await page.locator('[data-toast="warn"]').last().innerText().catch(() => '')).includes('能连上,但对方系统上没有这个地址(HTTP 404)'))
check('能连上但 404:提醒,不说连接成功', warned
&& !(await page.locator('[data-toast="warn"]').last().innerText()).includes('连接成功'))
await ep.getByRole('button', { name: '编辑' }).click()
const ed = page.locator('[data-endpoint-editor]')
await ed.waitFor()
check('关登录时(所有人等同管理员)允许访问内网可以开', await ed.locator('[data-endpoint-intranet="prod"]').isEnabled())
// 认证头名称空着不让存:存成 {"": ""} 之后这个环境的每个请求都发不出去(V2-04)
const prod = ed.locator('[data-endpoint-env-editor="prod"]')
await prod.getByRole('button', { name: '添加认证头' }).click()
check('新加一行、名称空着:标红并说明,不让保存', await prod.locator('[data-endpoint-header-error]').innerText().catch(() => '') === '请填写请求头名称,或删掉这一行'
&& await page.locator('[data-endpoint-save]').isDisabled())
const names = prod.getByRole('textbox', { name: '请求头名称', exact: true })
await names.last().fill('X Token')
check('名称带空格:说明只能用哪些字符,仍不让保存', (await prod.locator('[data-endpoint-header-error]').innerText()).includes('只能用英文字母')
&& await page.locator('[data-endpoint-save]').isDisabled())
await names.last().fill('authorization')
check('和已有的重名(不分大小写):标出来', (await prod.locator('[data-endpoint-header-error]').innerText()) === '名称重复'
&& await page.locator('[data-endpoint-save]').isDisabled())
await prod.getByRole('button', { name: '删除请求头 authorization', exact: true }).click()
check('删掉那一行:可以保存', await prod.locator('[data-endpoint-header-error]').count() === 0 && await page.locator('[data-endpoint-save]').isEnabled())
await page.locator('[data-endpoint-save]').click()
check('不改认证头:原样提交掩码值(服务端认作保持原值)', await waitFor(page, async () => state.endpointWrites.length === 1)
&& state.endpointWrites[0].body?.environments?.prod?.headers?.Authorization === 'Bearer ****1234'
&& Object.keys(state.endpointWrites[0].body?.environments?.prod?.headers ?? {}).length === 1
&& state.endpointWrites[0].body?.version === 3, JSON.stringify(state.endpointWrites[0]?.body))
})
await section('设置:身份、通知、审计', async () => {
const { page, state } = await open('/settings/identity')
const tabs = await page.locator('[role=tab]').allInnerTexts()
check('设置页有身份、通知、审计三个页签', ['身份', '通知', '审计'].every((t) => tabs.some((x) => x.includes(t))), tabs.join(' | '))
check('登录方式三选一', await page.locator('[data-identity-mode-option]').count() === 3)
await page.locator('[data-identity-mode-option="local"]').click()
check('从关闭切到本地账号:要求同时建出第一个管理员', await page.locator('[data-identity-bootstrap]').count() === 1)
check('没填管理员不能保存', await page.locator('[data-identity-save]').isDisabled())
check('单点登录同步来的组只读(没有编辑按钮)', await page.locator('[data-group="finance"] button', { hasText: '编辑' }).count() === 0)
check('还没有保存任何设置', state.settings.length === 0)
await page.goto(`${WEB}/settings/notify`, { waitUntil: 'networkidle' })
const ch = page.locator('[data-notify-channel="财务群"]')
check('通知渠道只显示主机名', await ch.count() === 1 && (await ch.innerText()).includes('open.feishu.cn'))
await page.goto(`${WEB}/settings/audit`, { waitUntil: 'networkidle' })
const row = page.locator('[data-audit-row="capability.govern"]')
check('审计照实标自报署名', await row.count() === 1 && (await row.innerText()).includes('自报署名'))
})
await section('设置 → 通知:可订阅的事件照服务端登记列(总方案 X6)', async () => {
// 列表从真后端的 GET /api/notify/events 来(GET 放行):这种范围的渠道收得到的才列;从来推不到的(以前列着、一勾就 422
// 的「待办升级」「动作已生效」……)和登记了还没推送的(评测、召回、待办汇总……)都不列
const { page, errors } = await open('/settings/notify')
await page.getByRole('button', { name: '添加渠道' }).first().click()
const dlg = page.locator('[role="dialog"]')
const listed = () => dlg.locator('[data-notify-event]').evaluateAll((els) => els.map((e) => e.getAttribute('data-notify-event')))
const scope = async (value) => {
await dlg.getByLabel('范围', { exact: true }).selectOption(value)
return waitFor(page, async () => await dlg.locator(`[data-notify-events="${value}"]`).count() === 1)
}
const every = ['task.opened', 'run.finished', 'run.failed', 'action.unknown']
check('全局渠道:推给全局渠道的五项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1)
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done']), JSON.stringify(await listed()))
const text = await dlg.innerText()
check('从来推不到的不列', !['待办升级', '待办超时', '待办处理完', '动作失败', '动作已生效'].some((t) => text.includes(t)), text)
check('登记了还没推送的不列', !['评测有退步', '评测失败', '每天的待办汇总', '访问密钥快到期', '数据源有变化要处理', '口径升版,答案的数变了']
.some((t) => text.includes(t)), text)
check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」', await scope('app')
&& JSON.stringify(await listed()) === JSON.stringify(every), JSON.stringify(await listed()))
check('个人渠道:五项都列', await scope('user') && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done']),
JSON.stringify(await listed()))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
await section('能力:待审阅复用数据目录的审阅队列,成批确认逐项带能力的治理版本', async () => {
const { page, state, errors } = await open('/tools/capabilities?view=review')
check('左栏换成待审阅视图', await page.locator('[data-capability-views="review"]').count() === 1)
const rows = page.locator('[data-review-item]')
check('列出待审阅的能力字段', await waitFor(page, async () => await rows.count() === 2), String(await rows.count()))
check('位置照服务端的说法(返回字段 /amount 的单位)', (await rows.first().locator('[data-review-where]').innerText()).includes('返回字段 /amount 的单位'))
check('使用次数照能力说', (await rows.first().innerText()).includes('被 12 次运行用过'))
check('不分「影响数字的项 / 名称和含义」', await page.locator('[data-review-kinds]').count() === 0)
await page.locator('[data-review-select-all]').check()
await page.locator('[data-review-action="confirm"]').click()
await page.locator('[role="dialog"]').getByRole('button', { name: /确认 2 项/ }).click()
check('成批确认:一次请求、逐项带能力名、位置、字段和治理版本', await waitFor(page, async () => state.batches.length === 1)
&& JSON.stringify(state.batches[0].items.map((i) => [i.capability, i.pointer, i.field, i.action, i.if_version]))
=== JSON.stringify([['erp_pay', '/amount', 'unit', 'confirm', 4], ['erp_query', '/items/*/name', 'label', 'confirm', 2]]),
JSON.stringify(state.batches[0]))
check('冲突的那项照实标出(说的是能力,不是表)', await waitFor(page, async () =>
(await page.locator('[data-review-summary]').innerText()).includes('所在能力刚被修改过')))
await rows.first().locator('[data-review-open]').click()
check('点开一项:右栏是这个能力的治理设置,标出那个位置', await waitFor(page, async () =>
await page.locator('[data-capability-editor="erp_pay"] [data-capability-focus="/amount"]').count() === 1), page.url())
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
await section('自定义工具:绑定登记的端点,只填路径', async () => {
const { page, state } = await open('/tools/custom')
await page.getByRole('button', { name: '新建工具' }).first().click()
const dlg = page.locator('[role="dialog"]')
await dlg.locator('input[placeholder="query_order_status"]').fill('erp_order')
await dlg.locator('[data-custom-tool-endpoint]').selectOption('ep1')
check('绑定后只填路径,不再要完整地址', await dlg.locator('[data-custom-tool-path]').count() === 1
&& await dlg.locator('input[placeholder^="https://api.example.com"]').count() === 0)
await dlg.locator('[data-custom-tool-path]').fill('/orders/{{ order_id }}')
await dlg.getByRole('button', { name: '保存', exact: true }).click()
const body = (await waitFor(page, async () => state.tools.length === 1)) ? state.tools[0].body : null
check('保存的配置是 {endpoint, method, path, query},没有地址和请求头', body?.config?.endpoint === 'ep1'
&& body.config.path === '/orders/{{ order_id }}' && !('url' in body.config) && !('headers' in body.config), JSON.stringify(body?.config))
})
await section('MCP:认证头按掩码编辑;本地子进程只有管理员能建、能改', async () => {
const builder = { mode: 'local', username: 'li', display_name: '李工', role: 'builder', groups: [], verified: true }
const { page, state } = await open('/tools/mcp', { me: builder })
await page.locator('article', { hasText: 'files' }).getByRole('button', { name: '编辑' }).click()
let dlg = page.locator('[role="dialog"]')
check('搭建者打开本地子进程服务:只读,说明原因,没有保存', await dlg.locator('[data-mcp-stdio-locked]').count() === 1
&& await dlg.getByRole('button', { name: '保存', exact: true }).count() === 0)
await dlg.getByRole('button', { name: '取消' }).click()
await page.locator('article', { hasText: 'crm' }).getByRole('button', { name: '编辑' }).click()
dlg = page.locator('[role="dialog"]')
check('认证头显示掩码', await dlg.locator('[data-mcp-header-value]').first().inputValue() === 'Bearer ****9a2c')
check('传输方式里本地子进程不可选', await dlg.locator('option[value="stdio"]').isDisabled())
await dlg.getByRole('button', { name: '保存', exact: true }).click()
check('不改认证头:原样提交掩码(服务端认作保持原值)', await waitFor(page, async () => state.mcp.length === 1)
&& state.mcp[0].body?.headers?.Authorization === 'Bearer ****9a2c', JSON.stringify(state.mcp[0]?.body))
})
await section('身份:单点登录映射管理员,用户或组至少一项', async () => {
const { page } = await open('/settings/identity')
await page.locator('[data-identity-mode-option="oidc"]').click()
check('有「映射为管理员的用户」', await page.locator('[data-identity-admin-users]').count() === 1)
check('用户和组都没填:说明至少一项,不能保存', (await page.locator('[data-identity-oidc]').innerText()).includes('至少要配置一个映射为管理员的用户或组')
&& await page.locator('[data-identity-save]').isDisabled())
await page.locator('[data-identity-admin-users]').fill('admin')
check('只填了管理员用户:可以保存', await page.locator('[data-identity-save]').isEnabled())
})
await section('设置:开了登录的使用者只剩个人的主题和通知,换主题不写服务端', async () => {
const user = { mode: 'local', username: 'wang', display_name: '王五', role: 'user', groups: [], verified: true }
const { page, state, errors } = await open('/settings/identity', { me: user })
const tabs = (await page.locator('[role=tab]').allInnerTexts()).map((t) => t.trim())
check('页签只剩偏好设置和通知', tabs.length === 2 && tabs.some((t) => t.includes('偏好')) && tabs.some((t) => t.includes('通知')), tabs.join(' | '))
check('只给管理员的页签地址纠正到偏好设置', new URL(page.url()).pathname === '/settings/prefs', page.url())
check('偏好只有主题,没有署名和运行默认值', await page.locator('[data-prefs-personal]').count() === 1
&& await page.locator('#pref-actor').count() === 0 && await page.locator('[data-prefs-shared]').count() === 0)
await page.getByRole('radio', { name: /浅色/ }).click()
await page.waitForTimeout(300)
check('换主题只存本机,不往服务端写', state.prefs.length === 0, JSON.stringify(state.prefs))
check('说明主题只保存在当前浏览器', (await page.locator('body').innerText()).includes('主题只保存在当前浏览器中'))
check('没有报错提示', !(await page.locator('body').innerText()).includes('未能保存到设置'))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)