Files
AgentLab/scripts/check-capabilities.mjs
T
ThearchyHelios 941723b17c 端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人
- 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效
- 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释
- 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标
- 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
2026-10-15 07:15:37 +08:00

387 lines
27 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('能力:待审阅复用数据目录的审阅队列,成批确认逐项带能力的治理版本', 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)