Files
AgentLab/scripts/check-capabilities.mjs

535 lines
39 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 工具页的「能力」「端点」页签和设置页的「身份」「通知」「审计」的检查(应用平台 A6、A7)。
//
// 守的是:
// - 能力列表标出读写效果,同步得出和治理后不一致时标出来;治理带 version 保存(乐观锁),409 时载入最新的、不覆盖;
// - 端点的认证头只显示掩码,提交掩码值表示保持原值;名称空着、不合法、重名的那一行标红、不让保存;允许访问内网只有
// 管理员能开;测试连接按环境发,照服务端的话写(2xx 才是成功,能连上但 404 是提醒);
// - 关登录(默认)时所有人等同管理员:治理、退役、身份设置都能改;
// - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名;
// - 邮件(04 细案 B2):「邮件服务」一节管理员改、搭建者只看,口令只说设没设、留空不改,有没保存的改动时不能发测试邮件;
// 每天的待办汇总的发送时间;邮件服务没启用时不列「邮件」格式;个人邮件渠道不填地址、写「发到账号邮箱 …」,没有邮箱照实
// 写发不出去;投递记录里邮件成功写「已交给邮件服务器」,不写已送达;
// - 能力字段的待审阅(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: [] },
]
const NOTIFY_ITEMS = [
{ id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true,
events: ['task.opened'], enabled: true }]
async function open(path, { conflict = false, me = OFF, settings = null, notify = null, deliveries = [] } = {}) {
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: [], channelWrites: [], mailTests: 0,
values: settings ? JSON.parse(JSON.stringify(settings)) : { ui: { theme: 'dark' } } }
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') {
const body = route.request().postDataJSON()
state.prefs.push(body)
// 同服务端:给了的组并进去(mail 的口令不回、只说设没设),交回整份
for (const [k, v] of Object.entries(body?.values ?? {})) {
state.values[k] = { ...(state.values[k] ?? {}), ...v }
if (k === 'mail') {
const pw = v.password
state.values.mail = { ...state.values.mail, password: null,
password_set: typeof pw === 'string' ? pw !== '' : !!state.values.mail.password_set }
}
}
return json(route, state.values)
}
return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, state.values)
})
await page.route(/\/api\/settings\/mail\/test$/, (route) => {
state.mailTests += 1
return json(route, { ok: true, message: '已交给邮件服务器(发到 root@example.com),是否送达以邮件服务器为准,请到收件箱确认' })
})
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) => {
const req = route.request()
if (req.method() === 'POST') {
const body = req.postDataJSON()
state.channelWrites.push(body)
return json(route, { id: 'ch-new', owner_id: null, url_host: '', signed: false, enabled: true, ...body }, 201)
}
return json(route, { items: notify?.items ?? NOTIFY_ITEMS, ...(notify?.mail ? { mail: notify.mail } : {}) })
})
await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: deliveries }))
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']
// 第 3 波起:定时评测的两个事件只推全局渠道(评测是搭建者们共用的一把尺,不归哪个人;服务端 notify/service.notify_eval);
// 访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道。第 4 波:数据源有变化
// 要处理只推全局渠道(按源汇总,notify_source_changed);口径召回数变了的推全局渠道和答案主人的个人渠道(notify_recall),
// 不归哪个应用
check('全局渠道:推给全局渠道的几项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1)
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'source.changed', 'ledger.recall', 'eval.regressed',
'eval.failed', 'key.expiring']),
JSON.stringify(await listed()))
const text = await dlg.innerText()
// 只推定时发起的评测:叫法照实带「定时」,勾上的人不会以为手动发起的那次也推
check('评测的两个事件照实说是定时的', ['定时评测有退步', '定时评测没有评完'].every((t) => text.includes(t)), text)
check('从来推不到的不列', !['待办升级', '待办超时', '待办处理完', '动作失败', '动作已生效'].some((t) => text.includes(t)), text)
// 第 4 波起本轮登记的六项都推送了:「登记了还没推送的不列」这一条没有对象了,改成核对最后打开的两项都列着
check('……「数据源有变化要处理」「口径升版,答案的数变了」列着(第 4 波打开了订阅)',
['数据源有变化要处理', '口径升版,答案的数变了'].every((t) => text.includes(t)), text)
check('只推个人渠道的「每天的待办汇总」全局渠道不列', !text.includes('每天的待办汇总'), 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', 'ledger.recall', 'task.digest', 'key.expiring']),
JSON.stringify(await listed()))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
const MAIL = { enabled: true, host: 'smtp.example.com', port: 587, security: 'starttls', username: 'agentlab', password: null,
password_set: true, from_addr: 'agentlab@example.com', from_name: 'AgentLab', default_for_assignees: false }
await section('设置 → 通知:邮件服务一节、每天的待办汇总', async () => {
const { page, state, errors } = await open('/settings/notify', { settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } },
notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } })
const box = page.locator('[data-mail-section="edit"]')
check('管理员看得到、改得了邮件服务', await waitFor(page, async () => await box.count() === 1)
&& await box.locator('[data-mail-field="host"]').inputValue() === 'smtp.example.com')
check('口令只说设没设,输入框是空的', await box.locator('[data-mail-field="password"]').inputValue() === ''
&& (await box.innerText()).includes('已设置;留空表示不修改'))
check('没改动时能发测试邮件', !(await box.locator('[data-mail-test]').isDisabled()))
await box.locator('[data-mail-field="host"]').fill('mail.example.org')
check('有没保存的改动时先保存再发', await box.locator('[data-mail-test]').isDisabled() && (await box.innerText()).includes('先保存再发测试邮件'))
await box.locator('[data-mail-save]').click()
check('保存交的是 mail 一组,口令没填就不带', await waitFor(page, async () => state.prefs.length === 1)
&& state.prefs[0].values.mail.host === 'mail.example.org' && !('password' in state.prefs[0].values.mail),
JSON.stringify(state.prefs[0]))
await box.locator('[data-mail-test]').click()
check('测试邮件照服务端的话写「已交给邮件服务器」', await waitFor(page, async () => state.mailTests === 1)
&& await waitFor(page, async () => (await page.locator('body').innerText()).includes('已交给邮件服务器(发到')))
check('写明退信不追踪', (await box.locator('[data-mail-handoff]').innerText()).includes('退信不追踪'))
const digest = page.locator('[data-digest-section="edit"]')
check('待办汇总的发送时间', await digest.locator('[data-digest-at]').inputValue() === '09:00')
await digest.locator('[data-digest-at]').fill('17:30')
await digest.locator('[data-digest-save]').click()
check('只交汇总时间这一项', await waitFor(page, async () => state.prefs.length === 2)
&& JSON.stringify(state.prefs[1]) === JSON.stringify({ values: { notify: { digest_at: '17:30' } } }), JSON.stringify(state.prefs[1]))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
const builder = { mode: 'local', username: 'bea', display_name: '搭建者', role: 'builder', groups: [], verified: true }
const ro = await open('/settings/notify', { me: builder, settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } } })
const roBox = ro.page.locator('[data-mail-section="readonly"]')
check('搭建者只看:输入框都不能改、没有保存和测试', await waitFor(ro.page, async () => await roBox.count() === 1)
&& await roBox.locator('[data-mail-field="host"]').isDisabled() && await roBox.locator('[data-mail-save]').count() === 0
&& await roBox.locator('[data-mail-test]').count() === 0 && (await roBox.innerText()).includes('只有管理员能修改邮件服务'))
})
await section('设置 → 通知:邮件格式、个人邮件渠道发到账号邮箱、投递记录', async () => {
const off = await open('/settings/notify', { notify: { items: NOTIFY_ITEMS, mail: { enabled: false, account_email: null } } })
await off.page.getByRole('button', { name: '添加渠道' }).first().click()
const offDlg = off.page.locator('[role="dialog"]')
const options = await offDlg.locator('[data-notify-format-select] option').allInnerTexts()
check('邮件服务没启用:不列「邮件」,写明为什么', !options.includes('邮件')
&& (await offDlg.locator('[data-notify-mail-off]').innerText()).includes('邮件服务没有启用'), options.join('、'))
const me = { mode: 'local', username: 'uma', display_name: '小乌', role: 'user', groups: [], verified: true, user_id: 'u-uma' }
const items = [
{ id: 'm1', name: '我的邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false,
events: ['task.opened'], enabled: true, mail_to: ['uma@example.com'] },
{ id: 'm2', name: '旧邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false,
events: ['task.opened'], enabled: true, mail_to: [] },
]
const deliveries = [
{ id: 'd1', channel_id: 'm1', event: 'task.opened', status: 'ok', http_status: null, error: null, format: 'email', created_at: '2026-10-15T02:00:00Z' },
]
const { page, state, errors } = await open('/settings/notify', { me, deliveries,
notify: { items, mail: { enabled: true, account_email: 'uma@example.com' } } })
const row = page.locator('[data-notify-channel="我的邮件"]')
check('个人邮件渠道写「发到账号邮箱 …」', await waitFor(page, async () => await row.count() === 1)
&& (await row.locator('[data-notify-target]').innerText()).includes('发到账号邮箱 uma@example.com'))
check('账号没有邮箱的照实写发不出去', (await page.locator('[data-notify-channel="旧邮件"] [data-notify-target]').innerText())
.includes('没有邮箱地址'))
await page.getByRole('button', { name: '添加渠道' }).first().click()
const dlg = page.locator('[role="dialog"]')
await dlg.locator('[data-notify-format-select]').selectOption('email')
check('个人邮件渠道不填地址,写发到账号邮箱', await dlg.locator('[data-notify-mail-to="account"]').count() === 1
&& (await dlg.locator('[data-notify-mail-to="account"]').innerText()).includes('发到账号邮箱 uma@example.com')
&& await dlg.locator('input[placeholder="https://"]').count() === 0)
await dlg.getByLabel('名称').fill('待办邮件')
await dlg.locator('[data-notify-save]').click()
check('建的时候不带地址', await waitFor(page, async () => state.channelWrites.length === 1)
&& state.channelWrites[0].format === 'email' && state.channelWrites[0].scope === 'user' && !('url' in state.channelWrites[0]),
JSON.stringify(state.channelWrites[0]))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
const admin = await open('/settings/notify', { deliveries, notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } })
const d = admin.page.locator('[data-delivery-format="email"]')
check('投递记录里邮件成功写「已交给邮件服务器」,不写已送达', await waitFor(admin.page, async () => await d.count() === 1)
&& (await d.innerText()).includes('已交给邮件服务器') && !(await d.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)