mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:41:57 +00:00
535 lines
39 KiB
JavaScript
535 lines
39 KiB
JavaScript
// 工具页的「能力」「端点」页签和设置页的「身份」「通知」「审计」的检查(应用平台 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)
|