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