mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 16:01:59 +00:00
应用平台集成(前端):钩子按名字管理并只显示一次密钥、按新口径补算、应用助手对话历史、设置页按角色收起与身份设置补管理员用户名、按需视图每日上限、界面偏好按角色只存本地、使用者问数据用数据源名单并在运行流连不上时改轮询、能力字段待审阅、记录页按来源筛选与待办 id、编辑器的入口 / 表单 / 待办外观 JSON 分区、画布 ?publish=1、工具页绑定端点与 MCP 认证头掩码编辑、派给自己的新待办系统通知、样例与检查脚本改走待办接口(含统一复核后的修复)
This commit is contained in:
+195
-11
@@ -5,7 +5,11 @@
|
||||
// - 只能固定已发布且未退役的版本(A1);新建应用从版本确定性派生,不靠模型(A8、P3);
|
||||
// - 属性面板由组件目录的 props schema 驱动;绑定种类只能从目录登记的里挑;可见性表达式只编辑、不在浏览器求值(A2、P7);
|
||||
// - 固定版本升版必须选处置,不给默认值,写清后果(口径升版是必须处置的事件);
|
||||
// - 助手只润色:应用操作到一条落一条,final 的整份 spec 为准;ask 结束这一轮。
|
||||
// - 助手只润色:应用操作到一条落一条,final 的整份 spec 为准;ask 结束这一轮;
|
||||
// - 助手对话按应用一段(D-e):先取或建、请求带 conversation_id、每轮落一条(问题、说明、查看记录),之前的轮次显示出来;
|
||||
// - 钩子按名字(D-g):声明 ∪ 已有的行,标出发布版里有没有、生成过密钥没有;密钥只显示一次;
|
||||
// - 入口、表单、待办外观按 JSON 编辑(带格式校验),检查的 path 落在这几块上时定位到 JSON 的那一行;
|
||||
// - 按新口径补算(D-f):固定版本升了版、处置为按新口径重算、视图里有趋势时才给按钮,确认框写明最多发起几次运行。
|
||||
//
|
||||
// 不写库:/api/apps、/api/workflows、/api/copilot/app-stream 全在浏览器里用 page.route 伪造,伪造的应用接口按收到的
|
||||
// 请求改一份内存里的应用(保存、检查、发布都读它)。其余写请求一律 409 并记账,检查结束时必须是空的。
|
||||
@@ -86,7 +90,15 @@ const SPEC = {
|
||||
hooks: [], notify: [],
|
||||
}
|
||||
|
||||
async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition', stream = [] } = {}) {
|
||||
const LINT = [
|
||||
{ level: 'error', code: 'app.expr_arith', message: '可见性表达式不能有算术', path: '/pages/0/body/1' },
|
||||
{ level: 'error', code: 'app.binding_target_missing', message: '绑定的指标在固定的版本里不存在', path: 'pages[0].body[0].children[0]' },
|
||||
{ level: 'warning', code: 'app.view_source_empty', message: '视图暂时没有可读的运行', path: 'views.wk' },
|
||||
]
|
||||
|
||||
async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition', stream = [], spec = SPEC, lint = LINT,
|
||||
versions = [], versionSpec = null, hooks = [], conversation = { id: 'conv-ae', kind: 'app', title: '', turns: [] },
|
||||
backfill = undefined, plan = null } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
opened.add(ctx)
|
||||
await ctx.addInitScript(() => { try { localStorage.setItem('agentlab_actor', '检查脚本') } catch { /* noop */ } })
|
||||
@@ -96,9 +108,10 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition',
|
||||
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 app = { id: 'app-ae', name: SPEC.name, description: '', icon: 'app', status: 'active', published_version: 2, level: 'published',
|
||||
needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: '2026-10-10T02:00:00Z', draft: clone(SPEC),
|
||||
draft_bindings: { slots: {} }, created_by: '检查脚本' }
|
||||
const state = { app, patches: [], lints: 0, publishes: [], derives: [], creates: [], streams: [], pages: [] }
|
||||
needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: '2026-10-10T02:00:00Z', draft: clone(spec),
|
||||
draft_bindings: { slots: {} }, created_by: '检查脚本', ...(backfill ? { backfill } : {}) }
|
||||
const state = { app, patches: [], lints: 0, publishes: [], derives: [], creates: [], streams: [], pages: [],
|
||||
hooks: clone(hooks), rotates: [], hookPatches: [], hookDeletes: [], backfills: [], turns: [], turnPatches: [], conversations: 0 }
|
||||
|
||||
await page.route(/\/api\//, (route) => {
|
||||
const req = route.request()
|
||||
@@ -137,11 +150,30 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition',
|
||||
}
|
||||
if (sub === 'lint') {
|
||||
state.lints += 1
|
||||
return json(route, { level: null, issues: [
|
||||
{ level: 'error', code: 'app.expr_arith', message: '可见性表达式不能有算术', path: '/pages/0/body/1' },
|
||||
{ level: 'error', code: 'app.binding_target_missing', message: '绑定的指标在固定的版本里不存在', path: 'pages[0].body[0].children[0]' },
|
||||
{ level: 'warning', code: 'app.view_source_empty', message: '视图暂时没有可读的运行', path: 'views.wk' },
|
||||
] })
|
||||
return json(route, { level: null, issues: lint })
|
||||
}
|
||||
if (sub === 'hooks') {
|
||||
const [, , , , , name, action] = url.pathname.split('/')
|
||||
if (!name) return json(route, { items: state.hooks })
|
||||
const hook = decodeURIComponent(name)
|
||||
if (action === 'rotate') {
|
||||
state.rotates.push(hook)
|
||||
return json(route, { id: `h-${hook}`, name: hook, kind: 'signal', entry_id: null, enabled: true, has_secret: true,
|
||||
in_draft: true, in_published: false, url_path: `/api/hooks/h-${hook}`, created_at: '2026-10-14T02:00:00Z',
|
||||
secret: `secret-of-${hook}-0123456789` })
|
||||
}
|
||||
if (req.method() === 'PATCH') { state.hookPatches.push({ hook, body: req.postDataJSON() }); return json(route, {}) }
|
||||
if (req.method() === 'DELETE') { state.hookDeletes.push(hook); return route.fulfill({ status: 204, body: '' }) }
|
||||
}
|
||||
if (sub === 'assistant-conversation') {
|
||||
state.conversations += 1
|
||||
return json(route, conversation)
|
||||
}
|
||||
if (sub === 'views' && url.pathname.endsWith('/backfill')) {
|
||||
// GET:补算计划(不发起运行);POST:发起,回发起了几次、跳过几次
|
||||
if (req.method() === 'GET') return plan ? json(route, plan) : json(route, { detail: '没有这个接口' }, 404)
|
||||
state.backfills.push(url.pathname.split('/')[5])
|
||||
return json(route, { view: 'wk', launched: 7, skipped: 1, run_ids: [] })
|
||||
}
|
||||
if (sub === 'publish') {
|
||||
const body = req.postDataJSON()
|
||||
@@ -152,7 +184,10 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition',
|
||||
}
|
||||
return json(route, { ok: true, version: 3, issues: [] })
|
||||
}
|
||||
if (sub === 'versions') return json(route, { items: [] })
|
||||
if (sub === 'versions') {
|
||||
return json(route, rest ? { ...versions.find((v) => v.version === Number(rest)), spec: versionSpec ?? spec, bindings: { slots: {} } }
|
||||
: { items: versions })
|
||||
}
|
||||
if (sub === 'grants') return json(route, { items: [] })
|
||||
if (sub === 'pages') {
|
||||
state.pages.push(url.search)
|
||||
@@ -162,6 +197,17 @@ async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition',
|
||||
return route.fallback()
|
||||
})
|
||||
await page.route(/\/api\/notify\/channels$/, (route) => json(route, { items: [] }))
|
||||
// 助手对话的轮次:开一轮、落说明和查看记录
|
||||
await page.route(/\/api\/conversations\/conv-ae\/turns(\/[^/]+)?$/, (route) => {
|
||||
const req = route.request()
|
||||
if (req.method() === 'POST') {
|
||||
state.turns.push(req.postDataJSON())
|
||||
return json(route, { id: `turn-${state.turns.length}`, seq: state.turns.length, question: req.postDataJSON().question, answer: '',
|
||||
explanation: '', status: 'running', error: '' })
|
||||
}
|
||||
state.turnPatches.push(req.postDataJSON())
|
||||
return json(route, { id: 'turn-1', seq: 1, question: '', answer: '', explanation: '', status: 'done', error: '' })
|
||||
})
|
||||
await page.route(/\/api\/copilot\/app-stream$/, (route) => {
|
||||
state.streams.push(route.request().postDataJSON())
|
||||
return route.fulfill({ status: 200, contentType: 'text/event-stream', body: stream.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
|
||||
@@ -286,6 +332,144 @@ await section('助手:应用操作实时落到草稿上,look 和 ask 照 dec
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
})
|
||||
|
||||
await section('助手对话:取或建这个应用的对话、请求带 conversation_id、每轮落一条,之前的轮次显示出来', async () => {
|
||||
const look = { op: 'look', id: 'L1', by: 'system', need: '固定的工作流版本', status: 'ok', elapsed_ms: 60,
|
||||
objects: [{ kind: 'workflow_version', named: ['周销售 v7'], missing: [] }] }
|
||||
const conversation = { id: 'conv-ae', kind: 'app', title: '', turns: [
|
||||
{ id: 'old-1', seq: 1, question: '先前的问题:指标放最上面', answer: '', explanation: '把指标挪到了首页顶部', status: 'done', error: '',
|
||||
meta: { looks: [look] } }] }
|
||||
const stream = [look, { op: 'final', spec: clone(SPEC), issues: [], explanation: '调整了首页的顺序' }]
|
||||
const { page, state, errors } = await open({ stream, conversation })
|
||||
check('进编辑器取或建一次对话', await waitFor(page, async () => state.conversations === 1))
|
||||
const panel = page.locator('[data-app-assistant]')
|
||||
check('之前的轮次显示出来(问题)', await waitFor(page, async () => (await panel.innerText()).includes('先前的问题:指标放最上面')))
|
||||
check('……带着当时查看了什么', (await panel.innerText()).includes('按固定版本查看了 1 个工作流版本'))
|
||||
await page.locator('[data-app-assistant] textarea').fill('报告放到第二页')
|
||||
await page.keyboard.press('Enter')
|
||||
check('请求带上这段对话', await waitFor(page, async () => state.streams.length === 1) && state.streams[0].conversation_id === 'conv-ae',
|
||||
JSON.stringify(state.streams[0]?.conversation_id))
|
||||
check('这一轮落库:开一轮带问题', await waitFor(page, async () => state.turns.length === 1) && state.turns[0].question === '报告放到第二页',
|
||||
JSON.stringify(state.turns))
|
||||
check('……再落说明和查看记录', await waitFor(page, async () => state.turnPatches.length === 1)
|
||||
&& state.turnPatches[0].explanation === '调整了首页的顺序' && state.turnPatches[0].meta?.looks?.length === 1
|
||||
&& state.turnPatches[0].status === 'done', JSON.stringify(state.turnPatches[0]))
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
})
|
||||
|
||||
await section('钩子:按名字列出声明和密钥状态;生成的密钥只显示一次', async () => {
|
||||
const spec = { ...clone(SPEC), hooks: [{ name: 'paid', kind: 'signal', entry: null }] }
|
||||
// 形状同服务端 process/hooks.hook_out:声明在草稿 / 发布版里没有、密钥生成过没有、收请求的地址
|
||||
const hooks = [
|
||||
{ id: null, name: 'paid', kind: 'signal', entry_id: null, enabled: false, has_secret: false, in_draft: true, in_published: false,
|
||||
url_path: null, created_at: null },
|
||||
{ id: 'h-old', name: 'old', kind: 'start', entry_id: 'apply', enabled: true, has_secret: true, in_draft: false, in_published: false,
|
||||
url_path: '/api/hooks/h-old', created_at: '2026-10-01T02:00:00Z' },
|
||||
]
|
||||
const { page, state } = await open({ path: '/apps/app-ae/edit/hooks', spec, hooks })
|
||||
const paid = page.locator('[data-app-hook="paid"]')
|
||||
check('声明的钩子标出还没生成密钥、发布版里还没有', await waitFor(page, async () => await paid.locator('[data-app-hook-state="none"]').count() === 1)
|
||||
&& (await paid.innerText()).includes('发布版里还没有'))
|
||||
check('草稿里没声明、但生成过密钥的单独列出', await page.locator('[data-app-hook="old"][data-app-hook-undeclared]').count() === 1)
|
||||
await paid.locator('[data-app-hook-rotate="paid"]').click()
|
||||
check('生成密钥按名字发', await waitFor(page, async () => state.rotates.length === 1) && state.rotates[0] === 'paid', JSON.stringify(state.rotates))
|
||||
const shown = page.locator('[data-app-hook-secret="paid"]')
|
||||
check('密钥只显示这一次:值、复制、说明', await waitFor(page, async () => await shown.count() === 1)
|
||||
&& (await shown.locator('[data-app-hook-secret-value]').innerText()).includes('secret-of-paid-0123456789')
|
||||
&& (await shown.innerText()).includes('只显示这一次') && await shown.getByRole('button', { name: '复制' }).count() >= 1)
|
||||
check('……附入站地址', (await shown.locator('[data-app-hook-url]').innerText()).endsWith('/api/hooks/h-paid'))
|
||||
await page.locator('[data-app-hook-delete="old"]').click()
|
||||
await page.locator('[role="dialog"]').getByRole('button', { name: '删除密钥' }).click()
|
||||
check('删除密钥按名字发', await waitFor(page, async () => state.hookDeletes.length === 1) && state.hookDeletes[0] === 'old')
|
||||
await page.locator('[data-app-nav="pages"]').click()
|
||||
await page.locator('[data-app-nav="hooks"]').click()
|
||||
check('离开这一节再回来:密钥不再显示', await page.locator('[data-app-hook-secret]').count() === 0)
|
||||
})
|
||||
|
||||
await section('入口与表单:JSON 编辑、格式校验、检查的 path 定位到那一行', async () => {
|
||||
const spec = { ...clone(SPEC),
|
||||
entries: [{ id: 'apply', label: '发起', pin: 'weekly', form: { $ref: '#/forms/apply' }, map: { amount: 'form.amount' } }],
|
||||
forms: { apply: { format: 'agentlab.form/1', type: 'object', required: ['amount'],
|
||||
properties: { amount: { type: 'number', title: '金额', minimum: 0 }, note: { type: 'string', title: '说明' } } } } }
|
||||
const lint = [{ level: 'error', code: 'app.entry_form_mismatch', message: '入口表单和固定版本的入口字段对不上',
|
||||
path: 'forms.apply.properties.note' }]
|
||||
const { page, state } = await open({ path: '/apps/app-ae/edit/forms', spec, lint })
|
||||
const entries = page.locator('[data-app-json="entries"]')
|
||||
await entries.locator('textarea').fill('[{"id": "apply",')
|
||||
check('JSON 格式不对:照实说,不能应用', await entries.locator('[data-app-json-error]').count() === 1
|
||||
&& await entries.locator('[data-app-json-apply]').isDisabled())
|
||||
await entries.locator('textarea').fill(JSON.stringify([{ id: 'apply', label: '发起', pin: 'nope', form: { $ref: '#/forms/apply' }, map: {} }]))
|
||||
check('形状检查:固定版本的别名不存在', (await entries.locator('[data-app-json-issues]').innerText()).includes('固定版本「nope」不存在'))
|
||||
await entries.locator('[data-app-json-reset]').click()
|
||||
const forms = page.locator('[data-app-json="forms"]')
|
||||
const next = clone(spec.forms)
|
||||
next.apply.properties.amount.maximum = 10000
|
||||
await forms.locator('textarea').fill(JSON.stringify(next, null, 2))
|
||||
await forms.locator('[data-app-json-apply]').click()
|
||||
await page.locator('[data-app-save]').click()
|
||||
check('应用到草稿后保存:表单的数据约束跟着存', await waitFor(page, async () => state.patches.length === 1)
|
||||
&& state.patches[0].draft?.forms?.apply?.properties?.amount?.maximum === 10000, JSON.stringify(state.patches[0]?.draft?.forms))
|
||||
await page.locator('[data-app-nav="check"]').click()
|
||||
await page.locator('[data-app-issue="app.entry_form_mismatch"] [data-app-issue-locate]').click()
|
||||
await page.waitForURL('**/edit/forms')
|
||||
const located = await waitFor(page, async () => Number(await page.locator('[data-app-json="forms"]').getAttribute('data-app-json-located')) > 0)
|
||||
check('检查的 path 落在表单上:跳到入口与表单,在 JSON 里定位到那个字段', located,
|
||||
await page.locator('[data-app-json="forms"]').getAttribute('data-app-json-located'))
|
||||
})
|
||||
|
||||
await section('按新口径补算:服务端给了能补算的视图,确认框写明会发起几次、跳过几次', async () => {
|
||||
const spec = { ...clone(SPEC), pins: [{ ...SPEC.pins[0], version: 6 }], pages: [{ id: 'home', title: '本周概览', body: [
|
||||
{ id: 'trend', type: 'metric', props: {}, bind: { view: 'wk', series: 'kpi.gmv', last: 8 } }] }] }
|
||||
// 形状同服务端 apps/backfill.Plan.out:应用详情的 backfill 列出能补算的视图,GET …/backfill 是最新的计划
|
||||
const planned = { view: 'wk', alias: 'weekly', source: 'formal', from_version: 5, to_version: 6, periods: 8, will_launch: 6, will_skip: 2 }
|
||||
const { page, state } = await open({ spec, backfill: [planned], plan: planned })
|
||||
const btn = page.locator('[data-app-backfill="wk"]')
|
||||
check('视图来源下面给「按新口径补算历史」', await waitFor(page, async () => await btn.count() === 1))
|
||||
check('次数照服务端算好的', await btn.getAttribute('data-app-backfill-max') === '6')
|
||||
await btn.click()
|
||||
const dlg = page.locator('[role="dialog"]')
|
||||
await dlg.waitFor({ timeout: 4000 })
|
||||
check('确认框写明会发起几次、跳过几次、从哪一版到哪一版', (await dlg.innerText()).includes('将发起 6 次正式运行')
|
||||
&& (await dlg.innerText()).includes('2 次的输入已经补算过') && (await dlg.innerText()).includes('v5') && (await dlg.innerText()).includes('v6'))
|
||||
await dlg.getByRole('button', { name: '发起 6 次运行' }).click()
|
||||
check('按视图发起补算', await waitFor(page, async () => state.backfills.length === 1) && state.backfills[0] === 'wk', JSON.stringify(state.backfills))
|
||||
check('提示发起了几次、跳过几次', await waitFor(page, async () => (await page.locator('body').innerText()).includes('已发起 7 次补算,跳过 1 次')))
|
||||
await page.goto(`${WEB}/apps/app-ae`, { waitUntil: 'networkidle' })
|
||||
check('应用页(管理)也给补算入口', await waitFor(page, async () => await page.locator('[data-app-backfill-panel] [data-app-backfill="wk"]').count() === 1))
|
||||
})
|
||||
|
||||
await section('按新口径补算:老服务端没给计划时按版本列表推,写明最多几次', async () => {
|
||||
const spec = { ...clone(SPEC), pins: [{ ...SPEC.pins[0], version: 6 }], pages: [{ id: 'home', title: '本周概览', body: [
|
||||
{ id: 'trend', type: 'metric', props: {}, bind: { view: 'wk', series: 'kpi.gmv', last: 8 } }] }] }
|
||||
const versions = [
|
||||
{ version: 2, spec_hash: 'h2', level: 'published', pins: [{ ...SPEC.pins[0], version: 6 }], dispositions: { weekly: 'recompute' },
|
||||
note: '', published_by: '检查脚本', published_at: '2026-10-12T02:00:00Z' },
|
||||
{ version: 1, spec_hash: 'h1', level: 'published', pins: [{ ...SPEC.pins[0], version: 5 }], dispositions: {},
|
||||
note: '', published_by: '检查脚本', published_at: '2026-10-01T02:00:00Z' },
|
||||
]
|
||||
const { page, state } = await open({ spec, versions })
|
||||
const btn = page.locator('[data-app-backfill="wk"]')
|
||||
check('视图来源下面给「按新口径补算历史」', await waitFor(page, async () => await btn.count() === 1))
|
||||
check('上限按趋势取的最近几次', await btn.getAttribute('data-app-backfill-max') === '8')
|
||||
await btn.click()
|
||||
const dlg = page.locator('[role="dialog"]')
|
||||
await dlg.waitFor({ timeout: 4000 })
|
||||
check('确认框写明最多发起几次运行', (await dlg.innerText()).includes('最多发起 8 次正式运行'))
|
||||
await dlg.getByRole('button', { name: '发起最多 8 次运行' }).click()
|
||||
check('按视图发起补算', await waitFor(page, async () => state.backfills.length === 1))
|
||||
})
|
||||
|
||||
await section('按新口径补算:处置不是按新口径重算时不给按钮', async () => {
|
||||
const versions = [
|
||||
{ version: 2, spec_hash: 'h2', level: 'published', pins: [{ ...SPEC.pins[0], version: 6 }], dispositions: { weekly: 'dual' },
|
||||
note: '', published_by: null, published_at: '2026-10-12T02:00:00Z' },
|
||||
{ version: 1, spec_hash: 'h1', level: 'published', pins: [{ ...SPEC.pins[0], version: 5 }], dispositions: {},
|
||||
note: '', published_by: null, published_at: '2026-10-01T02:00:00Z' },
|
||||
]
|
||||
const { page } = await open({ versions })
|
||||
await page.waitForTimeout(500)
|
||||
check('两版并列:没有补算按钮', await page.locator('[data-app-backfill]').count() === 0)
|
||||
})
|
||||
|
||||
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
|
||||
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
|
||||
await browser.close()
|
||||
|
||||
+41
-2
@@ -8,7 +8,9 @@
|
||||
// - 使用者(user 角色):导航只有「应用」和「设置」,别的地址送回应用首页,⌥ 数字切不到看不见的页;没有署名框,
|
||||
// 账户菜单写显示名、角色、退出登录;待办卡写「以登录身份处理」;
|
||||
// - 搭建者:导航九项都在,账户菜单写搭建者;
|
||||
// - 关登录:/login 直接回首页;导航底部是署名入口,没有账户菜单;应用首页挂「未开启登录」的说明。
|
||||
// - 关登录:/login 直接回首页;导航底部是署名入口,没有账户菜单;应用首页挂「未开启登录」的说明;
|
||||
// - 管理员允许使用者问数据时(D-c):问数据页只取数据源的名字(/api/datasources/names),不取完整列表和表结构;
|
||||
// - 使用者的设置页只剩偏好设置(主题)和通知,换主题只存本机、不写服务端(D-s)。
|
||||
//
|
||||
// 身份接口在浏览器层答掉(/api/auth/**),按 state 切登录方式和角色;其余 GET 放行(外壳要它),写请求拦下记在探针里。
|
||||
// 编排者的完整关口另起一套开着 local 登录的服务跑真接口,见 docs/design §6.3。
|
||||
@@ -84,8 +86,9 @@ async function open(state) {
|
||||
if (p === '/api/auth/me') {
|
||||
if (state.mode === 'off') return route.fulfill({ json: { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
|
||||
if (!state.who) return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode } })
|
||||
return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true } })
|
||||
return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true, user_can_chat: !!state.chat } })
|
||||
}
|
||||
state.reads?.push(p)
|
||||
if (state.mode !== 'off' && (!state.who || state.expire)) {
|
||||
return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode } })
|
||||
}
|
||||
@@ -93,6 +96,13 @@ async function open(state) {
|
||||
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 1, apps: 1, other: 0 } })
|
||||
if (p === '/api/tasks') return route.fulfill({ json: { items: [TASK] } })
|
||||
if (p === '/api/tasks/task_x') return route.fulfill({ json: TASK })
|
||||
// 管理员允许使用者问数据时,问数据页只拿得到数据源的名字和说明
|
||||
if (p === '/api/datasources/names') {
|
||||
return state.chat ? route.fulfill({ json: { items: [{ id: 'src1', name: 'shop', description: '门店销售' }] } })
|
||||
: route.fulfill({ status: 403, json: { detail: '没有权限' } })
|
||||
}
|
||||
if (p === '/api/conversations') return route.fulfill({ json: [] })
|
||||
if (p === '/api/settings' && state.who === 'user') return route.fulfill({ status: 403, json: { detail: '没有权限' } })
|
||||
// 使用者碰不到这些:服务端回 403
|
||||
if (state.who === 'user' && /^\/api\/(workflows|approvals|runs|tools|providers|skills|kb|datasources)/.test(p)) {
|
||||
return route.fulfill({ status: 403, json: { detail: '没有权限' } })
|
||||
@@ -202,6 +212,35 @@ await section('关登录:登录页回首页、署名入口、说明行', async
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('使用者问数据(管理员开了):只取数据源的名字,不取完整列表和表结构', async () => {
|
||||
const state = { mode: 'local', who: 'user', chat: true, writes: [], reads: [] }
|
||||
const page = await open(state)
|
||||
await page.goto(`${WEB}/chat`)
|
||||
await nav(page).waitFor({ timeout: 15000 })
|
||||
check('导航有「问数据」', (await navLabels(page)).includes('问数据'), (await navLabels(page)).join(','))
|
||||
await page.waitForTimeout(1200)
|
||||
check('取了数据源的名字', state.reads.includes('/api/datasources/names'), state.reads.filter((x) => x.includes('datasources')).join(','))
|
||||
check('没取完整的数据源列表和表结构', !state.reads.some((x) => x === '/api/datasources' || /^\/api\/datasources\/[^/]+\/schema/.test(x)),
|
||||
state.reads.filter((x) => x.includes('datasources')).join(','))
|
||||
check('没取只给搭建者的目录(工作流、工具、模型接入)', !state.reads.some((x) => /^\/api\/(workflows|tools|providers|skills)$/.test(x)),
|
||||
state.reads.filter((x) => /workflows|tools|providers|skills/.test(x)).join(','))
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('使用者的设置页:只剩偏好设置和通知,换主题只存本机', async () => {
|
||||
const state = { mode: 'local', who: 'user', writes: [], reads: [] }
|
||||
const page = await open(state)
|
||||
await page.goto(`${WEB}/settings`)
|
||||
await page.locator('[role=tab]').first().waitFor({ timeout: 15000 })
|
||||
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(' | '))
|
||||
await page.getByRole('radio', { name: /深色/ }).click()
|
||||
await page.waitForTimeout(400)
|
||||
check('换主题不往服务端写', !state.writes.some((w) => w.path === '/api/settings'), state.writes.map((w) => w.path).join(','))
|
||||
check('不提示「未能保存到设置」', !(await page.locator('body').innerText()).includes('未能保存到设置'))
|
||||
check('启动时也不去读服务端的设置', !state.reads.includes('/api/settings'))
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
|
||||
process.exit(failed ? 1 : 0)
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// - 能力列表标出读写效果,同步得出和治理后不一致时标出来;治理带 version 保存(乐观锁),409 时载入最新的、不覆盖;
|
||||
// - 端点的认证头只显示掩码,提交掩码值表示保持原值;允许访问内网只有管理员能开;测试连接按环境发;
|
||||
// - 关登录(默认)时所有人等同管理员:治理、退役、身份设置都能改;
|
||||
// - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名。
|
||||
// - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名;
|
||||
// - 能力字段的待审阅(D-i)复用数据目录的审阅队列:成批确认逐项带能力的治理版本;
|
||||
// - 自定义工具可以绑定登记的端点(只填路径);MCP 的认证头按掩码编辑;本地子进程只有管理员能建、能改(A6);
|
||||
// - 单点登录映射管理员:用户或组至少一项;开了登录的使用者在设置页只剩个人的主题和通知,换主题不写服务端(D-s)。
|
||||
//
|
||||
// 不写库:/api/capabilities、/api/endpoints、/api/auth、/api/users、/api/groups、/api/audit、/api/notify 全部伪造,
|
||||
// 伪造的接口改一份内存里的数据。其余写请求一律 409 并记账,检查结束时必须是空的。
|
||||
@@ -65,7 +68,24 @@ const ENDPOINTS = [{
|
||||
},
|
||||
}]
|
||||
|
||||
async function open(path, { conflict = false } = {}) {
|
||||
// 形状同服务端 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()
|
||||
@@ -74,7 +94,7 @@ async function open(path, { conflict = false } = {}) {
|
||||
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: [] }
|
||||
tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [] }
|
||||
|
||||
await page.route(/\/api\//, (route) => {
|
||||
const req = route.request()
|
||||
@@ -82,8 +102,39 @@ async function open(path, { conflict = false } = {}) {
|
||||
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
|
||||
return json(route, { detail: '检查脚本不写库' }, 409)
|
||||
})
|
||||
await page.route(/\/api\/auth\/me$/, (route) => json(route, { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false }))
|
||||
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])
|
||||
@@ -204,6 +255,86 @@ await section('设置:身份、通知、审计', async () => {
|
||||
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 page.locator('[data-capability-editor="erp_pay"] [data-capability-focus="/amount"]').count() === 1)
|
||||
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()
|
||||
|
||||
@@ -143,7 +143,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
|
||||
ctx.setDefaultTimeout(6000)
|
||||
const page = await ctx.newPage()
|
||||
const hits = { artifacts: [], runs: [], writes: [] }
|
||||
const hits = { artifacts: [], runs: [], writes: [], responds: [], tasks: [] }
|
||||
const state = { broken: true, runFail: true, issues: [] }
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
@@ -159,10 +159,24 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark' } = {}) {
|
||||
if (path === '/api/conversations') {
|
||||
return route.fulfill({ json: req.method() === 'GET' ? [] : { id: 'conv-challenge', kind: 'canvas', title: '', turns: [] } })
|
||||
}
|
||||
// 审批卡的内容在待办卡里(应用平台 X4):批准、驳回走 /api/tasks/{id}/respond。预览给合成审批带上了待办
|
||||
// (task-<审批 id>,frontend/src/dev/taskOf.ts),记下请求体、回一条凑齐了的待办
|
||||
const respond = path.match(/^\/api\/tasks\/([^/]+)\/respond$/)
|
||||
if (respond && req.method() === 'POST') {
|
||||
hits.responds.push({ task: decodeURIComponent(respond[1]), body: req.postDataJSON() })
|
||||
return route.fulfill({ json: { task: { id: decodeURIComponent(respond[1]), status: 'resolved' }, resolved: true } })
|
||||
}
|
||||
if (req.method() !== 'GET') {
|
||||
hits.writes.push(`${req.method()} ${path}`)
|
||||
return route.abort()
|
||||
}
|
||||
// 审批带着待办:卡片不该再按运行去取待办列表
|
||||
if (path === '/api/tasks') {
|
||||
hits.tasks.push(new URL(req.url()).search)
|
||||
return route.fulfill({ json: { items: [] } })
|
||||
}
|
||||
if (path === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } })
|
||||
if (path === '/api/approvals') return route.fulfill({ json: [] })
|
||||
const art = path.match(/^\/api\/artifacts\/([^/]+)$/)
|
||||
if (art) {
|
||||
const id = decodeURIComponent(art[1])
|
||||
@@ -920,6 +934,20 @@ function CSS_ESC(id) {
|
||||
return String(id ?? '').replace(/([^a-zA-Z0-9_-])/g, '\\$1')
|
||||
}
|
||||
|
||||
await section('审批卡的批准、驳回走待办回复(/api/tasks/{id}/respond)', async () => {
|
||||
const { ctx, page, hits, errors } = await open('challenge-approval')
|
||||
const card = page.locator('[data-fixture="challenge-approval"]')
|
||||
await card.locator('[data-task-approve]').waitFor()
|
||||
check('审批带着待办:卡片画出来,没有按运行去取待办列表', hits.tasks.length === 0, hits.tasks.join('、'))
|
||||
await card.locator('[data-task-reject]').click()
|
||||
await page.waitForTimeout(300)
|
||||
check('驳回:只发一次待办回复,按审批对应的待办,决定是驳回', hits.responds.length === 1
|
||||
&& hits.responds[0].task === 'task-ap-chal-1' && hits.responds[0].body?.decision === 'reject', JSON.stringify(hits.responds))
|
||||
check('……不再走老的审批回复、恢复运行接口', !hits.writes.some((w) => /\/decide$|\/resume$/.test(w)), hits.writes.join('、'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
console.log(failed ? `\n✗ 反方审稿:${failed} 项未通过` : '\n✓ 反方审稿:全部通过')
|
||||
process.exit(failed ? 1 : 0)
|
||||
|
||||
@@ -79,7 +79,7 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
|
||||
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' })
|
||||
ctx.setDefaultTimeout(6000)
|
||||
const page = await ctx.newPage()
|
||||
const hits = { lists: [], asks: [], snapshots: [], artifacts: [], segments: [], approvals: [], writes: [], other: [] }
|
||||
const hits = { lists: [], asks: [], snapshots: [], artifacts: [], segments: [], approvals: [], writes: [], responds: [], tasks: [], other: [] }
|
||||
const state = {
|
||||
lists: { [RUNS.approve]: fx.items, [RUNS.formal]: fx.items, [RUNS.explore]: fx.explore_items },
|
||||
reply: (body) => ({ status: 200, json: answerFor(body) }),
|
||||
@@ -108,10 +108,23 @@ async function open(syn, { width = 1100, height = 900, theme = 'dark', setup, ws
|
||||
const out = typeof state.reply === 'function' ? state.reply(body) : state.reply
|
||||
return route.fulfill({ status: out.status, json: out.json })
|
||||
}
|
||||
// 审批卡的内容在待办卡里(应用平台 X4):批准、驳回走 /api/tasks/{id}/respond。预览给合成审批带上了待办
|
||||
// (task-<审批 id>,frontend/src/dev/taskOf.ts),这里只记下请求体、回一条凑齐了的待办,不算「写请求」
|
||||
const respond = path.match(/^\/api\/tasks\/([^/]+)\/respond$/)
|
||||
if (respond && method === 'POST') {
|
||||
hits.responds.push({ task: decodeURIComponent(respond[1]), body: JSON.parse(req.postData() ?? '{}') })
|
||||
return route.fulfill({ json: { task: { id: decodeURIComponent(respond[1]), status: 'resolved' }, resolved: true } })
|
||||
}
|
||||
if (method !== 'GET') {
|
||||
hits.writes.push(`${method} ${path}`)
|
||||
return route.abort()
|
||||
}
|
||||
// 审批带着待办,审批卡不该再按运行去取待办列表
|
||||
if (path === '/api/tasks') {
|
||||
hits.tasks.push(url.search)
|
||||
return route.fulfill({ json: { items: [] } })
|
||||
}
|
||||
if (path === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } })
|
||||
if (inq) {
|
||||
const run = decodeURIComponent(inq[1])
|
||||
hits.lists.push({ run, report: url.searchParams.get('report'), unit: url.searchParams.get('unit') })
|
||||
@@ -1543,6 +1556,22 @@ await section('窄屏与深浅色', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
await section('审批卡的批准走待办回复:/api/tasks/{id}/respond,不按运行取待办列表', async () => {
|
||||
const { ctx, page, hits, errors } = await open('inquiry-approval', {
|
||||
setup: (s) => { s.approvals[RUNS.approve] = [approvalRow(fx.approval_id, RUNS.approve, fx.reports.approve.doc_artifact)] },
|
||||
})
|
||||
await approve(page).waitFor()
|
||||
check('审批带着待办:卡片画出来,没有按运行去取待办列表', hits.tasks.length === 0, hits.tasks.join('、'))
|
||||
await approve(page).click()
|
||||
await page.waitForTimeout(300)
|
||||
const sent = hits.responds
|
||||
check('批准:只发一次待办回复,按审批对应的待办', sent.length === 1 && sent[0].task === `task-${fx.approval_id}`, JSON.stringify(sent))
|
||||
check('……决定是批准', sent[0]?.body?.decision === 'approve', JSON.stringify(sent[0]?.body))
|
||||
check('……不再走老的审批回复接口', !hits.writes.some((w) => /\/api\/approvals\/[^/]+\/decide$|\/resume$/.test(w)), hits.writes.join('、'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
console.log(failed ? `\n✗ 审批质询:${failed} 项未通过` : '\n✓ 审批质询:全部通过')
|
||||
process.exit(failed ? 1 : 0)
|
||||
|
||||
@@ -2285,6 +2285,42 @@ await section('证据页签:没有逐段证据的运行给升级横幅,跳
|
||||
|
||||
// ------------------------------------------------------------------ 应用平台:运行来源、生效记录
|
||||
|
||||
await section('待办页签:审批带着 task_id,新来的审批按 id 补取待办,不整表重取', async () => {
|
||||
// D-r:/api/approvals 每项带 task_id。待审批变了时,记录页的「待办」页签按 task_id 只取新来的那几条
|
||||
check('审批列表的每一项都带着对应待办的 task_id', pending.every((a) => typeof a.task_id === 'string'
|
||||
&& pendingTasks.some((t) => t.id === a.task_id && t.approval_id === a.id)),
|
||||
`${pending.filter((a) => !a.task_id).length} 条没带`)
|
||||
const lists = []
|
||||
const byId = []
|
||||
const onRequest = (req) => {
|
||||
const u = new URL(req.url())
|
||||
if (u.pathname === '/api/tasks') lists.push(u.search)
|
||||
const m = u.pathname.match(/^\/api\/tasks\/([^/]+)$/)
|
||||
if (m && m[1] !== 'count') byId.push(m[1])
|
||||
}
|
||||
fakes.set('GET /api/approvals', () => ({ status: 200, json: pending }))
|
||||
await page.goto(`${WEB}/runs?tab=approvals`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(600)
|
||||
page.on('request', onRequest)
|
||||
const at = new Date().toISOString()
|
||||
const extra = { id: 'fake0appr0byid', run_id: 'fake0run0byid', node_id: 'gate', mode: 'approve', title: '按 id 补取的待审批',
|
||||
payload: { kind: 'human_node' }, status: 'pending', response: {}, created_at: at, workflow_id: null, workflow_name: '订单日报',
|
||||
node_label: '人工审批', run_status: 'interrupted', run_class: 'exploratory', task_id: 'fake0task0byid' }
|
||||
const task = { id: 'fake0task0byid', approval_id: extra.id, run_id: extra.run_id, node_id: 'gate', node_label: '人工审批',
|
||||
run_class: 'exploratory', workflow_id: null, workflow_name: '订单日报', app_id: null, app_version: null, kind: 'approve',
|
||||
title: extra.title, form: null, policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0, payload: {},
|
||||
assignments: [], can_respond: true, outcome: null, resolved_by: null, resolved_at: null, created_at: at }
|
||||
fakes.set('GET /api/approvals', () => ({ status: 200, json: [...pending, extra] }))
|
||||
fakes.set('GET /api/tasks/fake0task0byid', () => ({ status: 200, json: task }))
|
||||
const shown = await page.locator('[data-tasks-list] [data-task-id="fake0task0byid"]').waitFor({ timeout: 9000 }).then(() => true, () => false)
|
||||
check('新来的审批:待办按 task_id 补进列表', shown)
|
||||
check('……按 id 取了那一条', byId.includes('fake0task0byid'), byId.join(','))
|
||||
check('……没有整表重取待办', lists.length === 0, lists.join(' | '))
|
||||
page.off('request', onRequest)
|
||||
fakes.delete('GET /api/approvals')
|
||||
fakes.delete('GET /api/tasks/fake0task0byid')
|
||||
})
|
||||
|
||||
await section('运行来源:标签与筛选', async () => {
|
||||
// X3:运行记着从哪里发起;老运行没有来源,详情写「来源未记录」,列表里不标。筛选按 origin_kind 查服务端
|
||||
const seen = []
|
||||
|
||||
+38
-1
@@ -96,8 +96,10 @@ async function open({ theme = 'dark', state }) {
|
||||
if (url.pathname === '/api/tasks/count') {
|
||||
const other = state.approvals.filter((a) => a.status === 'pending').length
|
||||
const apps = state.appTasks ?? 0
|
||||
return route.fulfill({ json: { mine: other + apps, apps, other } })
|
||||
return route.fulfill({ json: { mine: state.tasks ? state.tasks.length : other + apps, apps, other } })
|
||||
}
|
||||
// 派给我的待办(外壳只在待办数变了时取一次,拿来判断哪些是新来的)
|
||||
if (url.pathname === '/api/tasks' && state.tasks) return route.fulfill({ json: { items: state.tasks } })
|
||||
if (url.pathname === '/api/runs' && state.runs) return route.fulfill({ json: state.runs })
|
||||
if (state.broken === url.pathname) return route.fulfill({ status: 500, json: { detail: '数据库锁住了,稍后重试' } })
|
||||
return route.continue()
|
||||
@@ -533,6 +535,41 @@ await section('未保存的工作流', '\n=== 未保存的工作流:命令面
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
await section('新的待办', '\n=== 派给自己的新待办:后台时发系统通知,已按待审批通知过的不重复 ===', async () => {
|
||||
const at = ago(60_000)
|
||||
const taskOf = (id, extra) => ({ id, approval_id: null, run_id: `run-${id}`, node_id: 'n', node_label: '核实付款', run_class: 'formal',
|
||||
workflow_id: null, workflow_name: '报销流程', app_id: 'app_claim', app_name: '报销审批', app_version: 1, kind: 'reconcile',
|
||||
title: '付款结果未知,请核实', form: null, policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0,
|
||||
payload: {}, assignments: [], can_respond: true, outcome: null, resolved_by: null, resolved_at: null, created_at: at, ...extra })
|
||||
const approval = { ...PENDING[1], id: 'shell-ap-task', run_id: 'run-shell-ap-task', title: '这笔报销可以批吗?', created_at: at }
|
||||
const state = { approvals: [], tasks: [], offline: false, writes: [] }
|
||||
const { ctx, page } = await open({ state })
|
||||
await page.goto(`${WEB}/apps`)
|
||||
await waitNav(page)
|
||||
await page.waitForTimeout(800)
|
||||
await nav(page).getByRole('button', { name: '后台提醒' }).click()
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
// 一条没有审批的核实待办,一条带着待审批的(搭建者已经按待审批收到通知)
|
||||
state.tasks = [taskOf('shell-task-new'), taskOf('shell-task-ap', { approval_id: approval.id, kind: 'approve', title: approval.title })]
|
||||
state.approvals = [approval]
|
||||
const noted = await page.waitForFunction(() => window.__notes.some((n) => n.tag === 'task:shell-task-new'), null, { timeout: 12000 })
|
||||
.then(() => true, () => false)
|
||||
const note = (await page.evaluate(() => window.__notes.find((n) => n.tag === 'task:shell-task-new'))) ?? {}
|
||||
check('后台来了派给自己的新待办:发系统通知', noted && String(note.title).startsWith('新的待办:') && String(note.title).includes('请核实'),
|
||||
JSON.stringify(note))
|
||||
check('……正文写应用名和节点名', String(note.body).includes('报销审批') && String(note.body).includes('核实付款'), String(note.body))
|
||||
await page.waitForTimeout(500)
|
||||
const notes = await page.evaluate(() => window.__notes)
|
||||
check('已按待审批通知过的那一条不再发第二条', notes.some((n) => n.tag === 'approval:shell-ap-task')
|
||||
&& !notes.some((n) => n.tag === 'task:shell-task-ap'), notes.map((n) => n.tag).join(','))
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
await section('快捷键说明', '\n=== ? 快捷键说明 ===', async () => {
|
||||
const state = { approvals: [], offline: false, writes: [] }
|
||||
|
||||
@@ -803,13 +803,14 @@ await section('助手查看了哪些表:画布右栏和问数据页(look 事
|
||||
check('挑表失败:展开看到原因', (await fb.innerText()).includes('原因:挑选数据表超过 20 秒未完成'))
|
||||
await f.page.close()
|
||||
|
||||
// 问数据页的桩数据(dev/preview 的 syn=chat)还是老服务端的 context 操作:照 look 的说法显示
|
||||
// 问数据页的桩数据(dev/preview 的 syn=chat)是一条 look 事件(按需查看了两张表):照 look 的说法显示,不再出老的 context 行
|
||||
const q = await open('syn=chat', { w: 1100, h: 800, name: 'chat-look' })
|
||||
check('问数据页:执行开始后这一行随「规划」收起', !(await q.page.locator('body').innerText()).includes('查看了 2 张表'))
|
||||
await q.page.locator('button', { hasText: '规划' }).first().click()
|
||||
await q.page.waitForTimeout(200)
|
||||
const qrow = q.page.locator('[data-step-code="copilot_context"], [data-step-code="copilot_look"]')
|
||||
check('问数据页:点开「规划」看到查看了几张表', await qrow.count() === 1 && (await qrow.innerText()).includes('查看了 2 张表'))
|
||||
const qrow = q.page.locator('[data-step-code="copilot_look"]')
|
||||
check('问数据页:点开「规划」看到查看了几张表(look 行)', await qrow.count() === 1 && (await qrow.innerText()).includes('查看了 2 张表'))
|
||||
check('问数据页:没有老的 context 行', await q.page.locator('[data-step-code="copilot_context"]').count() === 0)
|
||||
await qrow.locator('button').first().click()
|
||||
await q.page.waitForTimeout(150)
|
||||
check('问数据页:展开看到表名', (await qrow.innerText()).includes('v_device_kpi、dim_device'))
|
||||
|
||||
@@ -1313,6 +1313,13 @@ await section('入口:?run=&focus=、选择器、发布弹窗', async () => {
|
||||
await page.keyboard.press('Escape')
|
||||
await ctx.close()
|
||||
|
||||
// ?publish=1:问数据回答上点「做成应用」提取出工作流后跳过来,图打开之后直接打开发布弹窗;读完从地址里摘掉
|
||||
const viaApp = await open({ path: '/studio/st-main?publish=1' })
|
||||
const dialog = viaApp.page.locator('[role="dialog"]').filter({ has: viaApp.page.getByRole('button', { name: '发布', exact: true }) })
|
||||
check('?publish=1:打开发布弹窗', await dialog.waitFor({ timeout: 5000 }).then(() => true, () => false))
|
||||
check('……地址里的 publish 摘掉了(刷新、后退不再弹)', !new URL(viaApp.page.url()).searchParams.has('publish'), viaApp.page.url())
|
||||
await viaApp.ctx.close()
|
||||
|
||||
// 发布只换状态和发布人,图没动:撤销栈、镜头都留着
|
||||
const pub = await open()
|
||||
let published = false
|
||||
|
||||
+20
-12
@@ -53,6 +53,10 @@ const root = new URL('..', import.meta.url).pathname
|
||||
const fx = JSON.parse(readFileSync(`${root}frontend/src/dev/fixtures/dual_check.json`, 'utf8'))
|
||||
const AP = fx.approval
|
||||
const CARD_ID = AP.payload.card_artifact
|
||||
// 审批卡的内容在待办卡里(应用平台 X4):回复走 /api/tasks/{id}/respond。预览给合成审批带上的待办 id 是 task-<审批 id>
|
||||
// (frontend/src/dev/taskOf.ts)
|
||||
const TASK_ID = `task-${AP.id}`
|
||||
const RESPOND = `/api/tasks/${TASK_ID}/respond`
|
||||
|
||||
let failed = 0
|
||||
const check = (name, cond, detail = '') => {
|
||||
@@ -168,7 +172,7 @@ const browser = await chromium.launch({ executablePath: CHROME })
|
||||
|
||||
/**
|
||||
* 开一个预览用例。完整对照的工件按 artifact 答(默认夹具里的那份;给函数可以延迟、出错),这次运行的图快照按 graph
|
||||
* 答(默认夹具里的图:双盲复算节点没写「未采用原流程时」,按缺省的计入缺口),审批回复记下请求体、回一个伪造的运行;
|
||||
* 答(默认夹具里的图:双盲复算节点没写「未采用原流程时」,按缺省的计入缺口),待办回复记下请求体、回一个凑齐了的待办;
|
||||
* 审批列表回空;别的非 GET 一律拦掉,别的 GET 放行。query 接在预览页地址后面(用例认的参数见 dual_check.tsx)
|
||||
*/
|
||||
async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, graph = null, extra = null, query = '' } = {}) {
|
||||
@@ -187,10 +191,11 @@ async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifa
|
||||
const out = await extra(req, url)
|
||||
if (out) return route.fulfill({ status: out.status ?? 200, json: out.json })
|
||||
}
|
||||
if (req.method() === 'POST' && url.pathname === `/api/approvals/${AP.id}/decide`) {
|
||||
if (req.method() === 'POST' && url.pathname === RESPOND) {
|
||||
sent.push({ path: url.pathname, body: req.postDataJSON() })
|
||||
return route.fulfill({ json: { id: AP.run_id, status: 'running', run_class: AP.run_class, input: {}, output: {}, usage: {} } })
|
||||
return route.fulfill({ json: { task: { id: TASK_ID, approval_id: AP.id, run_id: AP.run_id, status: 'resolved' }, resolved: true } })
|
||||
}
|
||||
if (url.pathname === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } })
|
||||
if (req.method() !== 'GET') {
|
||||
sent.push({ path: url.pathname, blocked: true, method: req.method() })
|
||||
return route.abort()
|
||||
@@ -1087,8 +1092,10 @@ await section('审批卡:认不出任何指标时留一个出口', async () =>
|
||||
await page.locator('[data-dual-note]').fill('审批内容缺失,重新运行')
|
||||
await reject.click()
|
||||
await page.waitForFunction(() => window.__dualResolved != null)
|
||||
const body = sent.find((s) => s.path === `/api/approvals/${AP.id}/decide`)?.body
|
||||
check('……驳回:approved 为假、带上备注,不带 value', body?.approved === false && body.note === '审批内容缺失,重新运行' && !('value' in body), JSON.stringify(body))
|
||||
const body = sent.find((s) => s.path === RESPOND)?.body
|
||||
check('……驳回:走待办回复,决定是驳回、带上备注,不带裁决', body?.decision === 'reject' && body.note === '审批内容缺失,重新运行'
|
||||
&& !('values' in body), JSON.stringify(body))
|
||||
check('……不再走老的审批回复接口', !sent.some((s) => /\/api\/approvals\/[^/]+\/decide$/.test(s.path)), JSON.stringify(sent))
|
||||
check('……提示已驳回、运行终止', (await text(page.locator('body'))).includes('已驳回,本次运行终止'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
@@ -1115,12 +1122,13 @@ await section('审批卡:不选全不能提交,提交的回复是 {choices,
|
||||
await page.locator('[data-dual-note]').fill(note)
|
||||
await submit.click()
|
||||
await page.waitForFunction(() => window.__dualResolved != null)
|
||||
const posts = sent.filter((s) => s.path === `/api/approvals/${AP.id}/decide`)
|
||||
check('走审批回复接口,只发一次', posts.length === 1, String(posts.length))
|
||||
const posts = sent.filter((s) => s.path === RESPOND)
|
||||
check('走待办回复接口(/api/tasks/{id}/respond),只发一次', posts.length === 1, String(posts.length))
|
||||
const body = posts[0]?.body ?? {}
|
||||
check('请求体:value 是 {choices, note}', JSON.stringify(body.value) === JSON.stringify({ choices: { gmv: 'a', refund_rate: 'neither' }, note }),
|
||||
JSON.stringify(body.value))
|
||||
check('……照 input 模式的写法:approved 为真、备注同时放在外层', body.approved === true && body.note === note, JSON.stringify(body))
|
||||
check('请求体:决定是提交,values.value 是 {choices, note}', body.decision === 'submit'
|
||||
&& JSON.stringify(body.values?.value) === JSON.stringify({ choices: { gmv: 'a', refund_rate: 'neither' }, note }),
|
||||
JSON.stringify(body.values))
|
||||
check('……备注同时放在外层', body.note === note, JSON.stringify(body))
|
||||
check('提交后交给调用方重新接上运行', await page.evaluate(() => window.__dualResolved) === AP.run_id)
|
||||
check('……提示已提交、运行继续', (await text(page.locator('body'))).includes('已提交裁决,运行继续'))
|
||||
check('没有别的写请求', !sent.some((s) => s.blocked), JSON.stringify(sent.filter((s) => s.blocked)))
|
||||
@@ -1139,8 +1147,8 @@ await section('审批卡:完整对照取不到时,按摘要照样能裁决',
|
||||
for (const id of ['gmv', 'refund_rate']) await metric(page, id).locator('label', { hasText: '采用盲算的结果' }).click()
|
||||
await page.locator('[data-dual-submit]').click()
|
||||
await page.waitForFunction(() => window.__dualResolved != null)
|
||||
const body = sent.find((s) => s.path === `/api/approvals/${AP.id}/decide`)?.body
|
||||
check('……照样能提交', JSON.stringify(body?.value?.choices) === JSON.stringify({ gmv: 'b', refund_rate: 'b' }), JSON.stringify(body))
|
||||
const body = sent.find((s) => s.path === RESPOND)?.body
|
||||
check('……照样能提交', JSON.stringify(body?.values?.value?.choices) === JSON.stringify({ gmv: 'b', refund_rate: 'b' }), JSON.stringify(body))
|
||||
})
|
||||
|
||||
await section('审批卡:探索运行不提出具', async () => {
|
||||
|
||||
Reference in New Issue
Block a user