mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:11:59 +00:00
端到端验收修复与指标出具:
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人 - 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效 - 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释 - 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标 - 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
This commit is contained in:
@@ -59,10 +59,13 @@ const CATALOG = {
|
||||
components: [
|
||||
{ name: 'columns', label: '分栏', category: 'layout', version: 1, children: true, bindings: [],
|
||||
props: { type: 'object', properties: { count: { type: 'integer', minimum: 1, maximum: 4 } } } },
|
||||
// 属性的 title、description、x-enum-labels 照服务端目录(apps/catalog.py)的写法
|
||||
{ name: 'metric', label: '指标块', category: 'display', version: 1, children: false, bindings: ['metric', 'cell'],
|
||||
props: { type: 'object', properties: { label: { type: 'string', maxLength: 200 }, unit: { type: 'string', maxLength: 200 } } } },
|
||||
props: { type: 'object', properties: { label: { type: 'string', maxLength: 200, title: '名称' },
|
||||
unit: { type: 'string', maxLength: 200, title: '单位', description: '绑定单元格时写在数下面;绑定指标时数里已经带着口径卡的单位' } } } },
|
||||
{ name: 'notice', label: '提示', category: 'display', version: 1, children: false, bindings: [],
|
||||
props: { type: 'object', properties: { text: { type: 'string', maxLength: 4000 }, tone: { enum: ['info', 'warn', 'error'] } } } },
|
||||
props: { type: 'object', properties: { text: { type: 'string', maxLength: 4000, title: '提示文字' },
|
||||
tone: { enum: ['info', 'warn', 'error'], 'x-enum-labels': ['说明', '提醒', '错误'], title: '样式' } } } },
|
||||
{ name: 'report', label: '报告', category: 'display', version: 1, children: false, bindings: ['report'],
|
||||
props: { type: 'object', properties: { title: { type: 'string', maxLength: 200 } } } },
|
||||
{ name: 'entry_button', label: '发起按钮', category: 'process', version: 1, children: false, bindings: [],
|
||||
@@ -234,12 +237,28 @@ await section('新建:从发布弹窗带着工作流和版本进来,直接
|
||||
})
|
||||
|
||||
await section('新建:不带参数时先选工作流和版本,只列已发布且未退役的', async () => {
|
||||
const { page } = await open({ path: '/apps/new/edit' })
|
||||
const { page, state, errors } = await open({ path: '/apps/new/edit' })
|
||||
// 应用首页的「新建应用」到这里:新建向导的起点,现在只有「从已有的工作流开始」一种
|
||||
const start = page.locator('[data-app-editor="new"] [data-app-new-start="workflow"]')
|
||||
check('写明从已有的工作流开始', await start.count() === 1 && (await start.locator('h2').innerText()) === '从已有的工作流开始')
|
||||
check('有派生说明', (await page.locator('[data-app-editor="new"]').innerText()).includes('确定性地派生'))
|
||||
check('没选之前不派生、不能点', state.derives.length === 0 && await page.locator('[data-app-derive]').isDisabled())
|
||||
await page.locator('[data-app-editor="new"] select').first().selectOption('ae-wf')
|
||||
const opts = await page.locator('[data-app-editor="new"] select').nth(1).locator('option').allInnerTexts()
|
||||
check('版本只列已发布且未退役的(v5 退役、v4 没发布不列)', opts.some((t) => t.startsWith('v7')) && opts.some((t) => t.startsWith('v6'))
|
||||
&& !opts.some((t) => t.startsWith('v5')) && !opts.some((t) => t.startsWith('v4')), opts.join(' | '))
|
||||
// 选好版本、起个名字:按这一版派生、建应用、进编辑器,和「做成应用」走同一条路
|
||||
await page.locator('[data-app-editor="new"] select').nth(1).selectOption('6')
|
||||
await page.locator('[data-app-editor="new"] input').fill('门店周报看板')
|
||||
await page.locator('[data-app-derive]').click()
|
||||
check('按选的版本派生,带上起的名字', await waitFor(page, async () => state.derives.length === 1)
|
||||
&& state.derives[0].workflow_id === 'ae-wf' && state.derives[0].version === 6 && state.derives[0].name === '门店周报看板',
|
||||
JSON.stringify(state.derives))
|
||||
check('派生的 spec 原样建成应用', await waitFor(page, async () => state.creates.length === 1)
|
||||
&& state.creates[0].spec?.pins?.[0]?.version === 6, JSON.stringify(state.creates[0]?.spec?.pins))
|
||||
await page.waitForURL('**/apps/app-new/edit/pages')
|
||||
check('建好跳进编辑器的页面一节', new URL(page.url()).pathname === '/apps/app-new/edit/pages')
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
})
|
||||
|
||||
await section('固定版本:别名、用途、改版本取新的哈希', async () => {
|
||||
@@ -262,12 +281,20 @@ await section('页面树和属性面板:目录驱动、绑定、可见性', as
|
||||
const insp = page.locator('[data-app-inspector="gmv"]')
|
||||
check('属性面板按目录的 props 画:标签、单位', await insp.locator('[data-app-prop="label"]').count() === 1
|
||||
&& await insp.locator('[data-app-prop="unit"]').count() === 1)
|
||||
check('属性名写目录里的中文名,不露键名', await insp.locator('[data-app-prop="label"] label').innerText() === '名称'
|
||||
&& await insp.locator('[data-app-prop="unit"] label').innerText() === '单位',
|
||||
`${await insp.locator('[data-app-prop="label"] label').innerText()} | ${await insp.locator('[data-app-prop="unit"] label').innerText()}`)
|
||||
const unitHint = insp.locator('[data-app-prop="unit"] p')
|
||||
check('取值不直观的属性下面写目录给的说明,接到读屏', (await unitHint.innerText()).includes('绑定指标时数里已经带着')
|
||||
&& await insp.locator('[data-app-prop="unit"] input').getAttribute('aria-describedby') === await unitHint.getAttribute('id'))
|
||||
const kinds = await insp.locator('[data-app-bind-kind] option').allInnerTexts()
|
||||
check('绑定种类只给目录登记的(指标、单元格)', kinds.includes('指标') && kinds.includes('单元格') && !kinds.includes('报告'), kinds.join(' | '))
|
||||
await insp.locator('[data-app-prop="unit"] input').fill('元')
|
||||
await page.locator('[data-app-component="warn"]').click()
|
||||
const vis = page.locator('[data-app-inspector="warn"] [data-app-visible]')
|
||||
check('可见性表达式照原样显示,可编辑', await vis.inputValue() === "run.tier != 'formal'")
|
||||
const tones = await page.locator('[data-app-inspector="warn"] [data-app-prop="tone"] option').allInnerTexts()
|
||||
check('枚举的选项写目录给的中文(x-enum-labels),不写 info / warn / error', tones.join(',') === '—,说明,提醒,错误', tones.join(' | '))
|
||||
await page.locator('[data-app-palette]').click()
|
||||
await page.locator('[data-app-palette-item="report"]').click()
|
||||
check('从目录添加组件,新组件选中', await page.locator('[data-app-inspector^="report"]').count() === 1)
|
||||
@@ -278,6 +305,68 @@ await section('页面树和属性面板:目录驱动、绑定、可见性', as
|
||||
JSON.stringify(body).slice(0, 200))
|
||||
})
|
||||
|
||||
await section('视图来源:每种来源写一句解释;新增时起名,删除先确认、写明用到它的组件、可以撤销', async () => {
|
||||
const { page, state } = await open()
|
||||
const panel = page.locator('[data-app-views]')
|
||||
await panel.locator('summary').click()
|
||||
const wk = panel.locator('[data-app-view="wk"]')
|
||||
check('最近封存写一句解释(不只放在悬停里)', (await wk.locator('[data-app-view-kind-hint="latest_sealed"]').innerText()).includes('不发起运行'))
|
||||
await wk.locator('select').first().selectOption('on_demand')
|
||||
check('换成按需:解释跟着换', await waitFor(page, async () => (await wk.locator('[data-app-view-kind-hint]').getAttribute('data-app-view-kind-hint')) === 'on_demand'
|
||||
&& (await wk.locator('[data-app-view-kind-hint]').innerText()).includes('缓存时效')))
|
||||
await wk.locator('select').first().selectOption('latest_sealed')
|
||||
|
||||
// 新增:起名;重名不让加(会盖掉已有的视图),不起名叫 vN
|
||||
const name = panel.locator('[data-app-view-name]')
|
||||
await name.fill('wk')
|
||||
check('重名:不让加,写明已有同名视图', await panel.locator('[data-app-view-add]').isDisabled()
|
||||
&& (await panel.locator('[data-app-view-name-taken]').innerText()).includes('已有名为「wk」的视图'))
|
||||
await name.fill(' detail ')
|
||||
await panel.locator('[data-app-view-add]').click()
|
||||
check('起了名:按名字新增(去掉首尾空白),输入框清空', await panel.locator('[data-app-view="detail"]').count() === 1 && await name.inputValue() === '')
|
||||
await panel.locator('[data-app-view-add]').click()
|
||||
check('不起名:照旧叫 vN', await panel.locator('[data-app-view="v3"]').count() === 1,
|
||||
(await panel.locator('[data-app-view]').evaluateAll((els) => els.map((e) => e.getAttribute('data-app-view')))).join(','))
|
||||
|
||||
// 删除:先确认,写明用到它的组件;取消不删
|
||||
await wk.getByRole('button', { name: '删除视图「wk」' }).click()
|
||||
const dlg = page.locator('[role="dialog"], [role="alertdialog"]').filter({ hasText: '删除视图「wk」?' }).first()
|
||||
await dlg.waitFor({ timeout: 4000 })
|
||||
const said = await dlg.innerText()
|
||||
check('确认框写明用到它的组件(绑定的、可见条件指它的)和后果', said.includes('指标块「销售额」') && said.includes('提示「warn」')
|
||||
&& said.includes('失去数据来源'), said.replace(/\s+/g, ' '))
|
||||
await dlg.getByRole('button', { name: '取消' }).click()
|
||||
check('取消:视图还在', await panel.locator('[data-app-view="wk"]').count() === 1)
|
||||
await wk.getByRole('button', { name: '删除视图「wk」' }).click()
|
||||
await dlg.waitFor({ timeout: 4000 })
|
||||
await dlg.getByRole('button', { name: '删除视图' }).click()
|
||||
check('确认后删掉', await waitFor(page, async () => await panel.locator('[data-app-view="wk"]').count() === 0))
|
||||
const undo = page.locator('[data-toast]').filter({ hasText: '已删除视图「wk」' }).getByRole('button', { name: '撤销' })
|
||||
check('删完给一次撤销', await waitFor(page, async () => await undo.count() === 1))
|
||||
await undo.click()
|
||||
check('撤销:视图回来了,来源照旧', await waitFor(page, async () => await panel.locator('[data-app-view="wk"]').count() === 1)
|
||||
&& await panel.locator('[data-app-view="wk"] select').first().inputValue() === 'latest_sealed')
|
||||
// 没人用的视图:确认框照实说没有组件用到
|
||||
await panel.locator('[data-app-view="detail"]').getByRole('button', { name: '删除视图「detail」' }).click()
|
||||
const idle = page.locator('[role="dialog"], [role="alertdialog"]').filter({ hasText: '删除视图「detail」?' }).first()
|
||||
await idle.waitFor({ timeout: 4000 })
|
||||
check('没人用的视图:写明没有组件用到', (await idle.innerText()).includes('没有组件用到这个视图'))
|
||||
await idle.getByRole('button', { name: '删除视图' }).click()
|
||||
await page.locator('[data-app-save]').click()
|
||||
await waitFor(page, async () => state.patches.length === 1)
|
||||
const views = Object.keys(state.patches[0]?.draft?.views ?? {}).sort()
|
||||
check('保存的草稿:wk 撤销回来了、detail 删了、v3 新增了', views.join(',') === 'v3,wk', views.join(','))
|
||||
})
|
||||
|
||||
await section('固定版本:「固定版本」「处置」各写一句解释', async () => {
|
||||
const { page } = await open({ path: '/apps/app-ae/edit/pins' })
|
||||
const terms = page.locator('[data-app-terms]')
|
||||
check('固定版本:写明应用只按这一版发起、工作流发新版不自动换', (await terms.locator('[data-app-term="pin"]').innerText()).includes('不会自动换'))
|
||||
const disposition = await terms.locator('[data-app-term="disposition"]').innerText()
|
||||
check('处置:写明是升版前后怎么放在一起看、三种选法', ['升版前后', '按新口径重算', '两版并列', '前后不可比'].every((w) => disposition.includes(w)),
|
||||
disposition)
|
||||
})
|
||||
|
||||
await section('检查:和发布同一套规则,点一条定位到组件', async () => {
|
||||
const { page, state } = await open({ path: '/apps/app-ae/edit/check' })
|
||||
check('打开就检查一次', await waitFor(page, async () => state.lints === 1))
|
||||
@@ -472,6 +561,47 @@ await section('按新口径补算:处置不是按新口径重算时不给按
|
||||
check('两版并列:没有补算按钮', await page.locator('[data-app-backfill]').count() === 0)
|
||||
})
|
||||
|
||||
// 授权三行长得一样,不写标题分不出哪一行是哪一档;搭建者读不到 /users、/groups(只给管理员),选人走只读的名录
|
||||
// (/api/directory),填错的名字当场标出来,不静默存进授权
|
||||
await section('岗位与授权:三档各写标题和说明;搭建者从名录选人,名录里没有的不让加、已选的标出来', async () => {
|
||||
const { page } = await open({ path: '/apps/app-ae/edit/access' })
|
||||
const asked = []
|
||||
await page.route(/\/api\/auth\/me$/, (route) => route.fulfill({ json: {
|
||||
mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true } }))
|
||||
await page.route(/\/api\/directory$/, (route) => {
|
||||
asked.push(1)
|
||||
return route.fulfill({ json: { users: [{ username: 'zhangsan', display_name: '张三', disabled: false },
|
||||
{ username: 'wangwu', display_name: '王五', disabled: true }], groups: [{ name: 'fin', label: '财务' }] } })
|
||||
})
|
||||
await page.route(/\/api\/(users|groups)$/, (route) => route.fulfill({ status: 403, json: { detail: '没有权限' } }))
|
||||
await page.route(/\/api\/apps\/app-ae\/grants$/, (route) => (route.request().method() === 'GET'
|
||||
? route.fulfill({ json: { items: [{ principal_kind: 'user', principal: 'ghost', role: 'use' }] } }) : route.fallback()))
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
const rows = page.locator('[data-app-grant-role]')
|
||||
await rows.first().waitFor({ timeout: 8000 })
|
||||
const titles = await page.locator('[data-app-grant-title]').allInnerTexts()
|
||||
check('三行写明使用 / 查看全部实例 / 管理', titles.join(',') === '使用,查看全部实例,管理', titles.join(','))
|
||||
check('每行带一句管什么的说明', (await rows.nth(0).innerText()).includes('派给自己的待办')
|
||||
&& (await rows.nth(1).innerText()).includes('全部实例') && (await rows.nth(2).innerText()).includes('编辑、发布'))
|
||||
const use = rows.first()
|
||||
check('已选的名录里没有:标签上标出来', await waitFor(page, async () => await use.locator('[data-principal-problem="missing"]').count() === 1))
|
||||
const input = use.getByRole('textbox')
|
||||
const chips = () => use.locator('li[data-principal]').count()
|
||||
await input.fill('nobody')
|
||||
await input.press('Enter')
|
||||
check('名录里没有的人不让加,说清是谁', (await use.locator('[data-principal-refused="hard"]').innerText().catch(() => '')).includes('nobody')
|
||||
&& await chips() === 1)
|
||||
await input.fill('wangwu')
|
||||
await input.press('Enter')
|
||||
check('停用的人不让加', (await use.locator('[data-principal-refused]').innerText().catch(() => '')).includes('已停用') && await chips() === 1)
|
||||
const options = await use.locator('datalist option').evaluateAll((els) => els.map((e) => e.value))
|
||||
check('候选来自名录,停用的不列', options.join(',') === 'zhangsan', options.join(','))
|
||||
await input.fill('zhangsan')
|
||||
await input.press('Enter')
|
||||
check('名录里有的照常加上', await chips() === 2 && await use.locator('[data-principal-refused]').count() === 0)
|
||||
check('名录只取一次,几个选择器共用', asked.length === 1, String(asked.length))
|
||||
})
|
||||
|
||||
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
|
||||
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
|
||||
await browser.close()
|
||||
|
||||
+161
-16
@@ -10,7 +10,9 @@
|
||||
// 有视图在生成时自动重取;
|
||||
// - 入口页:必填没填本地拦下、错误挂到字段下面;服务端 422 的 {errors:[{path, code, message}]} 按 path 挂回字段,
|
||||
// 对不上字段的列在表单顶上;⌘/Ctrl+Enter 发起;成功后进实例页;需升级时不让发起;
|
||||
// - 实例页:状态、印章、进度、本实例的待办卡(就地处理)、动作状态(哈希前 8 位、环境);
|
||||
// - 实例页:状态(服务端给的说法:结果未知写「等待核实写入结果」)、印章、进度(结果未知的那一步不写「完成」)、
|
||||
// 本实例的待办卡(就地处理);不是处理人的待办表单只画这一步的状态、不去取待办;动作状态(哈希前 8 位、环境;
|
||||
// 结果未知的经过记一笔,不当失败原因);
|
||||
// - 窄屏(390px)不出横向滚动。
|
||||
//
|
||||
// 页面是真页面(走真的应用外壳),/api/apps/**、/api/tasks/**、指标链接口在浏览器层答掉,数据是这里虚构的
|
||||
@@ -77,9 +79,16 @@ const HOME = {
|
||||
tree: [
|
||||
{ id: 'stamp', type: 'stamp', props: {}, data: { view: 'wk', state: 'ok', stamp: STAMP } },
|
||||
{ id: 'kpis', type: 'columns', props: { count: 2 }, children: [
|
||||
{ id: 'gmv', type: 'metric', props: { label: '销售额' },
|
||||
data: { state: 'ok', text: '45.68 万元', name: '销售额', unit: '', evidence: metricEv('gmv') } },
|
||||
// 绑定指标:服务端交来的字已经按口径卡带着单位(render_metric),data.unit 说明是哪个单位;派生时属性里也写了单位
|
||||
{ id: 'gmv', type: 'metric', props: { label: '销售额', unit: '万元' },
|
||||
data: { state: 'ok', text: '45.68 万元', name: '销售额', unit: '万元', evidence: metricEv('gmv') } },
|
||||
{ id: 'orders', type: 'metric', props: { label: '订单数' }, data: { state: 'ok', text: '1,284 单', name: '订单数' } },
|
||||
// 绑定单元格:字不带单位(render_cell),单位是搭建者在属性里填的
|
||||
{ id: 'shops', type: 'metric', props: { label: '门店数', unit: '家' },
|
||||
data: { state: 'ok', text: '128', evidence: { run_id: RUN, kind: 'cell', node: 'q1', artifact: 'art_q1', eid: 'ev:cell:shops' } } },
|
||||
// 绑定指标、口径卡没写单位:服务端照样带 unit(空串),字里没有单位,写搭建者在属性里填的
|
||||
{ id: 'aov', type: 'metric', props: { label: '客单价', unit: '元' },
|
||||
data: { state: 'ok', text: '356.2', name: '客单价', unit: '', evidence: metricEv('aov') } },
|
||||
] },
|
||||
{ id: 'trend', type: 'series', props: { label: '销售额趋势' }, data: {
|
||||
state: 'ok', mode: 'incomparable', unit: '万元',
|
||||
@@ -126,7 +135,8 @@ const CLAIM_HOME = {
|
||||
|
||||
const TASK = {
|
||||
id: 'task_fin', approval_id: 'ap_fin', run_id: 'run_claim_1', node_id: 'finance', app_id: 'app_claim', app_name: '报销审批',
|
||||
app_version: 2, workflow_name: '报销流程', kind: 'approve', title: '财务审批', form: null, policy: { kind: 'all' },
|
||||
app_version: 2, workflow_name: '报销流程', instance_title: '张三 的报销', kind: 'approve', title: '财务审批', form: null,
|
||||
policy: { kind: 'all' },
|
||||
status: 'pending', due_at: new Date(Date.now() + 3600_000).toISOString(), escalation_level: 0,
|
||||
payload: { kind: 'human_node', message: '张三报销差旅 **1,200 元**,请审批' },
|
||||
assignments: [
|
||||
@@ -141,6 +151,8 @@ const TASK = {
|
||||
const INSTANCE = {
|
||||
run_id: 'run_claim_1', title: '张三 的报销', entry_id: 'apply', status: 'interrupted', started_by: '张三',
|
||||
created_at: ago(1800_000), finished_at: null, pending_tasks: 1, tier: null,
|
||||
// 给人看的状态服务端说(apps/resolve.instance_state):结果未知时是在等核实,不是「等待审批」
|
||||
status_code: 'waiting', status_text: '等待核实写入结果',
|
||||
view: {
|
||||
format: 'agentlab.view/1',
|
||||
app: { id: 'app_claim', name: '报销审批', version: 2, level: 'published', needs_upgrade: false },
|
||||
@@ -148,30 +160,65 @@ const INSTANCE = {
|
||||
sources: { inst: { state: 'running', run_id: 'run_claim_1', stamp: null, message: '正在运行,完成后刷新页面' } },
|
||||
tree: [
|
||||
{ id: 'inst_header', type: 'instance_header', props: {}, data: {
|
||||
state: 'ok', run_id: 'run_claim_1', title: '张三 的报销', status: 'interrupted', status_text: '等待处理',
|
||||
started_by: '张三', created_at: ago(1800_000), finished_at: null, pending_tasks: 1, stamp: null } },
|
||||
state: 'ok', run_id: 'run_claim_1', title: '张三 的报销', status: 'interrupted', status_code: 'waiting',
|
||||
status_text: '等待核实写入结果', started_by: '张三', created_at: ago(1800_000), finished_at: null, pending_tasks: 1,
|
||||
stamp: null } },
|
||||
{ id: 'inst_timeline', type: 'instance_timeline', props: {}, data: {
|
||||
state: 'ok', status: 'interrupted', status_text: '等待处理', steps: [
|
||||
{ node: 'input', title: '报销单', state: 'done', at: ago(1790_000) },
|
||||
{ node: 'finance', title: '财务审批', state: 'waiting', at: ago(1700_000) },
|
||||
state: 'ok', status: 'interrupted', status_code: 'waiting', status_text: '等待核实写入结果', steps: [
|
||||
{ node: 'input', title: '报销单', state: 'done', text: '完成', at: ago(1790_000) },
|
||||
{ node: 'finance', title: '财务审批', state: 'waiting', text: '等待审批', at: ago(1700_000) },
|
||||
{ node: 'pay', title: '付款', state: 'unknown', text: '结果未知,等待核实', at: ago(600_000) },
|
||||
] } },
|
||||
// 发起人不是这条待办的处理人:服务端解析时判定(handler 为假),只给这一步的状态,界面不去取待办
|
||||
{ id: 'review', type: 'task_form', props: {}, bind: { task: 'review' }, data: {
|
||||
state: 'ok', handler: false, task_ui: {},
|
||||
task: { id: 'task_other', run_id: 'run_claim_1', node_id: 'review', kind: 'approve', title: '复核', status: 'pending',
|
||||
due_at: new Date(Date.now() + 3600_000).toISOString(), policy: { kind: 'all' }, escalation_level: 1,
|
||||
created_at: ago(1700_000), instance_title: '张三 的报销', form: null },
|
||||
assignments: [
|
||||
{ id: 'r1', principal_kind: 'user', principal: 'lisi', source: 'slot:finance', status: 'responded', responded_by: 'lisi',
|
||||
responded_at: ago(900_000), decision: 'approve', verified: true },
|
||||
{ id: 'r2', principal_kind: 'user', principal: 'wangwu', source: 'slot:finance', status: 'pending', responded_by: null,
|
||||
responded_at: null, decision: null, verified: false },
|
||||
{ id: 'r3', principal_kind: 'user', principal: 'zhaoliu', source: 'escalation', status: 'pending', responded_by: null,
|
||||
responded_at: null, decision: null, verified: false },
|
||||
],
|
||||
progress: { policy: 'all', voters: 2, need: 2, agreed: 1, rejected: 0,
|
||||
escalation: { slot: 'director', assignees: [{ kind: 'user', name: 'zhaoliu' }], decided: null, by: null } },
|
||||
my_response: null } },
|
||||
{ id: 'inst_tasks', type: 'task_list', props: { scope: 'instance' }, data: { scope: 'instance', items: [] } },
|
||||
{ id: 'pay', type: 'action_status', props: { label: '付款' }, data: { state: 'ok', node: 'pay', records: [
|
||||
{ id: 'act1', state: 'unknown', environment: 'prod', attempt: 1, payload_hash: '9c1e44ab', error: '请求超时' },
|
||||
{ id: 'act1', state: 'unknown', environment: 'prod', attempt: 1, payload_hash: '9c1e44ab',
|
||||
note: '发送时没有拿到确定的结果(请求超时),等待核实:在外部系统里可能已经生效,不会自动重发' },
|
||||
] } },
|
||||
],
|
||||
forms: {}, problems: [],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 指标链照服务端的形状(api/evidence 的 _metric_chain)。销售额按位置取数据整形的结果(vars.q.rows[0][0]),整形节点不是
|
||||
* 原样解析,出处追到它为止:untraced 写明停在哪个节点。复算照样一致——「有出处」和「追到原始数据」是两件事
|
||||
*/
|
||||
const UNTRACED_INPUT = { path: 'vars.q.rows[0][0]', value: 456800, node_id: 'parse', via: 'transform', field: 'rows[0][0]', status: 'ok' }
|
||||
const METRIC_CHAIN = {
|
||||
chain: [
|
||||
{ step: 'metric', node_id: 'kpi', metric: 'gmv', name: '销售额', value: 456800, formula: 'sum(amount)',
|
||||
substituted: 'sum(…) = 456800', inputs: [] },
|
||||
{ step: 'metric', node_id: 'kpi', metric: 'gmv', name: '销售额', value: 456800, expression: 'vars.q.rows[0][0]',
|
||||
substituted: '456800', recompute_ok: true, inputs: [UNTRACED_INPUT],
|
||||
untraced: [{ path: 'vars.q.rows[0][0]', node_id: 'parse', label: '解析查询结果', type: 'transform' }] },
|
||||
{ step: 'input', ...UNTRACED_INPUT },
|
||||
],
|
||||
seal: { sealed: true, ok: true, covered: true },
|
||||
redacted: { columns: [] },
|
||||
}
|
||||
/** 同一个指标,整形节点原样解析查询结果:按位置取的格卡上记成单元格取数,穿过整形节点接上查询快照 */
|
||||
const TRACED_INPUT = { path: 'vars.q.rows[0][0]', value: 456800, node_id: 'fetch', via: 'tool_cell', through: 'parse',
|
||||
through_label: '解析查询结果', artifact: 'art_q1', locator: { row: 0, column: 'gmv' }, eid: 'ev:cell:gmv', status: 'verified',
|
||||
query: 'Q1', cell: 'Q1.r0.gmv' }
|
||||
const METRIC_CHAIN_TRACED = {
|
||||
...METRIC_CHAIN,
|
||||
chain: [{ ...METRIC_CHAIN.chain[0], inputs: [TRACED_INPUT], untraced: [] }, { step: 'input', ...TRACED_INPUT }],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 浏览器
|
||||
|
||||
@@ -187,6 +234,7 @@ async function open({ width = 1280, height = 900, state }) {
|
||||
const r = route.request()
|
||||
const url = new URL(r.url())
|
||||
const p = url.pathname
|
||||
if (r.method() === 'GET' && p.startsWith('/api/tasks/')) (state.taskReads ??= []).push(p)
|
||||
if (r.method() !== 'GET') {
|
||||
state.writes.push({ method: r.method(), path: p, body: r.postData() })
|
||||
if (p === '/api/apps/app_claim/entries/apply') {
|
||||
@@ -204,7 +252,7 @@ async function open({ width = 1280, height = 900, state }) {
|
||||
}
|
||||
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
|
||||
}
|
||||
if (p === '/api/auth/me') return route.fulfill({ json: { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
|
||||
if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
|
||||
if (p === '/api/apps') return route.fulfill({ json: { items: APPS } })
|
||||
if (p === '/api/apps/app_weekly/pages/home') {
|
||||
state.pageHits = (state.pageHits ?? 0) + 1
|
||||
@@ -216,7 +264,7 @@ async function open({ width = 1280, height = 900, state }) {
|
||||
if (p === '/api/apps/app_claim/instances/run_claim_1') return route.fulfill({ json: INSTANCE })
|
||||
if (p === `/api/runs/${RUN}/evidence/metrics/kpi/gmv`) {
|
||||
state.chainHits = (state.chainHits ?? 0) + 1
|
||||
return route.fulfill({ json: METRIC_CHAIN })
|
||||
return route.fulfill({ json: state.traced ? METRIC_CHAIN_TRACED : METRIC_CHAIN })
|
||||
}
|
||||
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 1, apps: 1, other: 0 } })
|
||||
if (p === '/api/tasks') return route.fulfill({ json: { items: url.searchParams.get('status') === 'resolved' ? [] : [TASK] } })
|
||||
@@ -241,6 +289,9 @@ await section('首页:三个页签、关登录说明、需升级', async () =>
|
||||
check('关登录时挂一行说明(自报署名)', /未开启登录/.test(await page.locator('[data-auth-off]').innerText()))
|
||||
check('待办按应用分组', (await page.locator('[data-inbox-group="app_claim"]').count()) === 1)
|
||||
check('会签的待办写进度', /1\/2 已同意/.test(await page.locator('[data-inbox-signoffs]').innerText()))
|
||||
check('待办一行写所属实例和开出的时间(同标题的分得开)',
|
||||
/张三 的报销 · .*发起/.test(await page.locator('[data-inbox-meta]').first().innerText()),
|
||||
await page.locator('[data-inbox-meta]').first().innerText())
|
||||
await tabs.getByRole('tab', { name: /应用/ }).click()
|
||||
check('页签记在地址上', /tab=apps/.test(page.url()), page.url())
|
||||
await page.locator('[data-app]').first().waitFor({ timeout: 5000 })
|
||||
@@ -249,11 +300,24 @@ await section('首页:三个页签、关登录说明、需升级', async () =>
|
||||
check('能管理的应用有「编辑」,只能用的没有',
|
||||
(await page.locator('[data-app="app_weekly"] a[href$="/edit"]').count()) === 1
|
||||
&& (await page.locator('[data-app="app_claim"] a[href$="/edit"]').count()) === 0)
|
||||
// 新建应用是新建向导的起点:搭建者及以上在页头看得到(关登录时所有人等同管理员),点进去先选工作流和版本
|
||||
const create = page.locator('header [data-app-create]')
|
||||
check('页头有「新建应用」,链到新建页(不带工作流、版本:先选)', await create.count() === 1
|
||||
&& (await create.innerText()).includes('新建应用') && await create.getAttribute('href') === '/apps/new/edit',
|
||||
`${await create.count()} | ${await create.getAttribute('href').catch(() => '')}`)
|
||||
await page.getByRole('tab', { name: '我发起的' }).click()
|
||||
await page.locator('[data-mine] [data-instance]').first().waitFor({ timeout: 5000 })
|
||||
check('我发起的:合各应用的实例,链到实例页',
|
||||
(await page.locator('[data-mine] a[href="/apps/app_claim/i/run_claim_1"]').count()) === 1)
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
|
||||
// 开了登录的使用者:没有「新建应用」(搭建是搭建者及以上的事)
|
||||
const user = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
|
||||
const u = await open({ state: user })
|
||||
await u.page.goto(`${WEB}/apps?tab=apps`)
|
||||
await u.page.locator('[data-app]').first().waitFor({ timeout: 15000 })
|
||||
check('使用者的应用首页没有「新建应用」', await u.page.locator('[data-app-create], [data-app-create-empty]').count() === 0)
|
||||
check('……没有未捕获的运行时错误', u.page.errors.length === 0, u.page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 应用页
|
||||
@@ -272,11 +336,25 @@ await section('应用页:印章、指标、出处、趋势、表格、未知
|
||||
|
||||
const gmv = page.locator('[data-node="gmv"] button[data-evidence="metric"]')
|
||||
check('指标块的数是按钮', (await gmv.innerText()).includes('45.68 万元'))
|
||||
const gmvText = await page.locator('[data-node="gmv"] [data-component="metric"]').innerText()
|
||||
check('绑定指标:单位只写在数里,下面不再重复一行', gmvText.split('万元').length === 2, gmvText.replace(/\s+/g, ' '))
|
||||
const shops = page.locator('[data-node="shops"] [data-component="metric"]')
|
||||
check('绑定单元格:数不带单位,单位写在下面', (await shops.innerText()).replace(/\s+/g, ' ').trim() === '门店数 128 家',
|
||||
(await shops.innerText()).replace(/\s+/g, ' '))
|
||||
const aov = (await page.locator('[data-node="aov"] [data-component="metric"]').innerText()).replace(/\s+/g, ' ').trim()
|
||||
check('绑定指标、口径卡没写单位:写搭建者在属性里填的单位,不吞掉', aov === '客单价 356.2 元', aov)
|
||||
check('没有出处的数照实标「无证据」', (await page.locator('[data-node="orders"] [data-evidence="none"]').innerText()).includes('无证据'))
|
||||
await gmv.click()
|
||||
const panel = page.locator('[data-evidence-panel]')
|
||||
check('点数字打开出处面板', await panel.waitFor({ timeout: 5000 }).then(() => true, () => false))
|
||||
check('走的是指标链接口', state.chainHits === 1, `${state.chainHits ?? 0} 次`)
|
||||
// 有出处(口径卡确定性复算一致)和追到原始数据分开说:追不到的不能只写「有出处」
|
||||
const origin = panel.locator('[data-ev-origin]')
|
||||
check('复算一致照写', (await panel.locator('[data-ev-recompute]').getAttribute('data-ev-recompute')) === 'ok')
|
||||
check('输入追不到原始数据:照实说追到哪个节点为止', await origin.getAttribute('data-ev-origin') === 'untraced'
|
||||
&& (await origin.innerText()).includes('vars.q.rows[0][0] 的出处追到「解析查询结果」(数据整形)为止,没追到原始数据'),
|
||||
await origin.innerText().catch(() => ''))
|
||||
check('……指标那一行另挂「未追到原始数据」', (await panel.locator('[data-ev-untraced-chip]').innerText()) === '未追到原始数据')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const series = page.locator('[data-component="series"]')
|
||||
@@ -300,6 +378,21 @@ await section('应用页:印章、指标、出处、趋势、表格、未知
|
||||
check('服务端解析出错的组件列在顶上', (await page.locator('[data-view-problems]').innerText()).includes('解析出错'))
|
||||
check('出错的组件就地说明', (await page.locator('[data-node="broken"] [data-block-state="error"]').count()) === 1)
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
|
||||
// 整形节点原样解析查询结果:按位置取的格穿过它接上查询快照,面板写明经过哪个节点、输入都追到了原始数据
|
||||
const traced = { writes: [], traced: true }
|
||||
const t = await open({ state: traced })
|
||||
await t.page.goto(`${WEB}/apps/app_weekly`)
|
||||
await t.page.locator('[data-node="gmv"] button[data-evidence="metric"]').click()
|
||||
const tp = t.page.locator('[data-evidence-panel]')
|
||||
await tp.locator('[data-ev-origin]').waitFor({ timeout: 5000 })
|
||||
check('穿过原样解析的整形节点:输入都追到了原始数据,不挂「未追到」', await tp.locator('[data-ev-origin]').getAttribute('data-ev-origin') === 'traced'
|
||||
&& (await tp.locator('[data-ev-origin]').innerText()).includes('输入都追到了原始数据')
|
||||
&& await tp.locator('[data-ev-untraced-chip]').count() === 0)
|
||||
const source = tp.locator('[data-ev-source="vars.q.rows[0][0]"]')
|
||||
check('……输入来源写明经过哪个整形节点、落在哪一格', (await source.innerText()).includes('经「解析查询结果」原样解析')
|
||||
&& (await source.innerText()).includes('与快照一致'), (await source.innerText().catch(() => '')).replace(/\s+/g, ' '))
|
||||
check('……没有未捕获的运行时错误', t.page.errors.length === 0, t.page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('应用页:视图生成中自动重取、需升级提示', async () => {
|
||||
@@ -336,8 +429,13 @@ await section('入口:本地校验、422 挂回字段、发起后进实例页'
|
||||
check('错误挂在字段下面', (await form.locator('[data-field="amount"] [data-field-error="required"]').count()) === 1)
|
||||
await form.locator('[data-field="amount"] input').fill('1.')
|
||||
check('数字框能打出小数点', (await form.locator('[data-field="amount"] input').inputValue()) === '1.')
|
||||
await form.locator('[data-field="amount"] input').fill('9000')
|
||||
await form.locator('[data-field="amount"] input').fill('-5')
|
||||
await form.locator('[data-field="reason"] textarea').fill('出差深圳')
|
||||
await page.locator('[data-entry-submit]').click()
|
||||
check('金额低于下限(minimum)本地拦下,不发请求',
|
||||
(await form.locator('[data-field="amount"] [data-field-error="minimum"]').count()) === 1
|
||||
&& !state.writes.some((w) => w.path.endsWith('/entries/apply')))
|
||||
await form.locator('[data-field="amount"] input').fill('9000')
|
||||
state.reject422 = true
|
||||
await page.locator('[data-entry-submit]').click()
|
||||
await form.locator('[data-field="amount"] [data-field-error="maximum"]').waitFor({ timeout: 5000 }).catch(() => {})
|
||||
@@ -369,11 +467,25 @@ await section('实例页:状态、进度、待办卡、动作状态', async ()
|
||||
await page.goto(`${WEB}/apps/app_claim/i/run_claim_1`)
|
||||
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
|
||||
check('写出发起人', (await page.locator('[data-instance-page]').innerText()).includes('由「张三」发起'))
|
||||
check('进度按节点列出', (await page.locator('[data-timeline-step]').count()) === 2)
|
||||
check('实例状态按服务端的说法写(结果未知:等待核实写入结果,不是「等待审批」)',
|
||||
(await page.locator('[data-component="instance_header"] [data-status]').innerText()).includes('等待核实写入结果')
|
||||
&& !(await page.locator('[data-component="instance_header"]').innerText()).includes('等待审批'))
|
||||
check('进度按节点列出', (await page.locator('[data-timeline-step]').count()) === 3)
|
||||
check('结果未知的那一步写「结果未知」,不写「完成」',
|
||||
(await page.locator('[data-timeline-step="unknown"]').innerText()).includes('结果未知'))
|
||||
const other = page.locator('[data-component="task_form"][data-task-handler="no"]')
|
||||
check('不是处理人:只画这一步的状态(在等谁、会签进度、升级后的规则),不出红框',
|
||||
(await other.locator('[data-task-state="pending"]').count()) === 1 && (await other.locator('[role="alert"]').count()) === 0
|
||||
&& (await other.innerText()).includes('等待 wangwu、zhaoliu 处理') && (await other.innerText()).includes('1/2 已同意')
|
||||
&& (await other.innerText()).includes('已升级给岗位「director」'), (await other.innerText()).replace(/\n/g, ' '))
|
||||
check('……也不去取那条待办', !(state.taskReads ?? []).includes('/api/tasks/task_other'), JSON.stringify(state.taskReads))
|
||||
await page.locator('[data-component="task_list"] [data-task]').waitFor({ timeout: 5000 })
|
||||
check('本实例的待办就地处理(完整的待办卡)', (await page.locator('[data-component="task_list"] [data-task-approve]').count()) === 1)
|
||||
check('动作状态写结果未知和环境', /结果未知/.test(await page.locator('[data-action="unknown"]').innerText())
|
||||
&& /生产环境/.test(await page.locator('[data-action="unknown"]').innerText()))
|
||||
check('结果未知的经过记一笔,不当「失败原因」',
|
||||
(await page.locator('[data-action="unknown"] [data-action-error]').count()) === 0
|
||||
&& /不会自动重发/.test(await page.locator('[data-action="unknown"] [data-action-note]').innerText()))
|
||||
check('关登录(等同管理员)时接运行流', (await page.locator('[data-instance-follow]').getAttribute('data-instance-follow')) === 'stream')
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
@@ -388,6 +500,39 @@ await section('窄屏:390px 不出横向滚动', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 推演、请模型判断只给搭建者及以上(接口同样只给):使用者的出处面板里不挂这些按钮,不让人点了再被拒(V1-93)。
|
||||
// 同一个面板、关登录时照常有「推演」,两头对照
|
||||
await section('使用者的出处面板:不给推演,遮罩说明按使用者说', async () => {
|
||||
// 指标链带一列遮罩:面板底下的遮罩说明对谁说什么
|
||||
const masked = (page) => page.route(`**/api/runs/${RUN}/evidence/metrics/kpi/gmv`,
|
||||
(route) => route.fulfill({ json: { ...METRIC_CHAIN, redacted: { columns: ['phone'] } } }))
|
||||
const off = await open({ state: { writes: [] } })
|
||||
await masked(off.page)
|
||||
await off.page.goto(`${WEB}/apps/app_weekly`)
|
||||
await off.page.locator('[data-node="gmv"] button[data-evidence="metric"]').click()
|
||||
const offPanel = off.page.locator('[data-evidence-panel]')
|
||||
await offPanel.locator('[data-ev-metric]').waitFor({ timeout: 8000 })
|
||||
check('关登录(所有人等同管理员):指标上有「推演」', await offPanel.locator('[data-whatif-toggle]').count() === 1)
|
||||
check('关登录:遮罩只减少暴露,不是安全边界', (await offPanel.locator('[data-ev-mask-note]').innerText().catch(() => ''))
|
||||
.includes('遮罩只减少暴露,不是安全边界'))
|
||||
|
||||
const state = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
|
||||
const { page } = await open({ state })
|
||||
await masked(page)
|
||||
await page.goto(`${WEB}/apps/app_weekly`)
|
||||
await page.locator('[data-node="gmv"] button[data-evidence="metric"]').click()
|
||||
const panel = page.locator('[data-evidence-panel]')
|
||||
await panel.locator('[data-ev-metric]').waitFor({ timeout: 8000 })
|
||||
check('使用者:出处照常看,没有「推演」', await panel.locator('[data-whatif-toggle]').count() === 0)
|
||||
check('使用者:没有「请模型判断」', await panel.locator('[data-ev-judge-ask]').count() === 0)
|
||||
const note = panel.locator('[data-ev-mask-note]')
|
||||
check('使用者:遮罩对他是边界,不说「完整快照中仍为原值」', await note.getAttribute('data-ev-mask-note') === 'user'
|
||||
&& (await note.innerText()).includes('原值不对使用者开放') && !(await note.innerText()).includes('仍为原值'), await note.innerText().catch(() => ''))
|
||||
check('没有发出只给搭建者的请求', !state.writes.some((w) => /whatif|evidence\/judge|inquiries/.test(w.path)),
|
||||
state.writes.map((w) => w.path).join(','))
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
|
||||
process.exit(failed ? 1 : 0)
|
||||
|
||||
+54
-1
@@ -3,6 +3,9 @@
|
||||
// 守的是这些「看着没坏、其实不对」的地方:
|
||||
// - 开了登录、没登录:任何页面都送到 /login?next=原处,登录成功后回到原处;登录页按服务端说的登录方式给账号密码表单
|
||||
// 或单点登录按钮(单点登录时本地账号收在下面);密码不对写人话;单点登录回调失败带回的原因照写;next 只认站内路径;
|
||||
// 进门只认一次人(/auth/me 只请求一次:认人接口的 401 不整页跳,开发模式下 effect 执行两次也共用一个请求);
|
||||
// - 账号密码登录的新部署(/auth/me 的 401 带 can_bootstrap):登录页给「建第一个管理员」,确认密码对不上不让提交,
|
||||
// 建好即登录、回到原处;已有账号的人可以展开登录表单;
|
||||
// - 会话中途失效:任何接口回 401 都整页跳登录页,带上原处;
|
||||
// - 非 GET 请求都带 X-AgentLab-Request 头(服务端开登录时据此防跨站);
|
||||
// - 使用者(user 角色):导航只有「应用」和「设置」,别的地址送回应用首页,⌥ 数字切不到看不见的页;没有署名框,
|
||||
@@ -45,6 +48,7 @@ async function section(name, fn) {
|
||||
const PEOPLE = {
|
||||
user: { username: 'zhangsan', display_name: '张三', role: 'user', groups: ['财务'] },
|
||||
builder: { username: 'lisi', display_name: '李四', role: 'builder', groups: [] },
|
||||
admin: { username: 'root', display_name: '管理员甲', role: 'admin', groups: [] },
|
||||
}
|
||||
|
||||
const TASK = {
|
||||
@@ -80,13 +84,25 @@ async function open(state) {
|
||||
return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true } })
|
||||
}
|
||||
if (p === '/api/auth/logout') { state.who = null; return route.fulfill({ status: 204, body: '' }) }
|
||||
if (p === '/api/auth/bootstrap') {
|
||||
if (!state.bootstrap) return route.fulfill({ status: 409, json: { detail: '已经有管理员了,请用管理员账号登录' } })
|
||||
const body = JSON.parse(r.postData() || '{}')
|
||||
state.bootstrap = false
|
||||
state.who = 'admin'
|
||||
return route.fulfill({ json: { mode: state.mode, ...PEOPLE.admin, username: body.username,
|
||||
display_name: body.display_name || body.username, verified: true } })
|
||||
}
|
||||
return route.fulfill({ json: {} })
|
||||
}
|
||||
if (p === '/api/health') return route.continue()
|
||||
if (p === '/api/auth/me') {
|
||||
state.meHits = (state.meHits ?? 0) + 1
|
||||
if (state.mode === 'off') return route.fulfill({ json: { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
|
||||
// 会话过期时认人接口也回 401(同服务端):不然登录页认得出人、又把人送回原处,来回跳
|
||||
if (!state.who || state.expire) return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode } })
|
||||
if (!state.who || state.expire) {
|
||||
return route.fulfill({ status: 401, json: { detail: '请先登录', code: 'auth_required', mode: state.mode,
|
||||
can_bootstrap: !!state.bootstrap } })
|
||||
}
|
||||
return route.fulfill({ json: { mode: state.mode, ...PEOPLE[state.who], verified: true, user_can_chat: !!state.chat } })
|
||||
}
|
||||
state.reads?.push(p)
|
||||
@@ -122,7 +138,10 @@ await section('没登录:送到登录页,登录后回原处', async () => {
|
||||
await page.goto(`${WEB}/apps?tab=inbox`)
|
||||
check('送到 /login 并带上原处', await page.waitForURL(/\/login\?next=%2Fapps%3Ftab%3Dinbox/, { timeout: 15000 }).then(() => true, () => false), page.url())
|
||||
await page.locator('[data-login-form]').waitFor({ timeout: 8000 })
|
||||
await page.waitForTimeout(500)
|
||||
check('进门只认一次人(/auth/me 只请求一次)', state.meHits === 1, `${state.meHits} 次`)
|
||||
check('账号密码登录:有表单、没有单点登录按钮', (await page.locator('[data-login-oidc]').count()) === 0)
|
||||
check('已有管理员:不给建管理员的表单', (await page.locator('[data-login-bootstrap]').count()) === 0)
|
||||
check('登录页不在外壳里(没有导航)', (await nav(page).count()) === 0)
|
||||
await page.locator('#login-username').fill('zhangsan')
|
||||
await page.locator('#login-password').fill('wrong')
|
||||
@@ -137,6 +156,40 @@ await section('没登录:送到登录页,登录后回原处', async () => {
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('新部署:登录页建第一个管理员,建好即登录、回原处', async () => {
|
||||
const state = { mode: 'local', who: null, bootstrap: true, writes: [] }
|
||||
const page = await open(state)
|
||||
await page.goto(`${WEB}/apps`)
|
||||
check('送到 /login 并带上原处', await page.waitForURL(/\/login\?next=%2Fapps/, { timeout: 15000 }).then(() => true, () => false), page.url())
|
||||
const form = page.locator('[data-login-bootstrap]')
|
||||
check('给「建第一个管理员」的表单', await form.waitFor({ timeout: 8000 }).then(() => true, () => false))
|
||||
check('不先给登录表单', (await page.locator('[data-login-form]').count()) === 0)
|
||||
check('写明这是新部署、建好直接登录', (await form.innerText()).includes('还没有能登录的管理员'))
|
||||
const submit = form.getByRole('button', { name: '建立并登录' })
|
||||
await page.locator('#boot-username').fill('root')
|
||||
await page.locator('#boot-display').fill('管理员甲')
|
||||
await page.locator('#boot-password').fill('password-123')
|
||||
await page.locator('#boot-confirm').fill('password-124')
|
||||
check('两次密码不一致:当场说、不让提交', (await form.locator('[data-login-mismatch]').count()) === 1 && await submit.isDisabled())
|
||||
// 已有别的账号的人:展开登录表单,再收回来
|
||||
await page.locator('[data-login-have-account]').click()
|
||||
check('「已有账号」展开登录表单', await page.locator('[data-login-form]').waitFor({ timeout: 3000 }).then(() => true, () => false)
|
||||
&& (await page.locator('[data-login-bootstrap]').count()) === 0)
|
||||
await page.reload()
|
||||
await form.waitFor({ timeout: 8000 })
|
||||
await page.locator('#boot-username').fill('root')
|
||||
await page.locator('#boot-display').fill('管理员甲')
|
||||
await page.locator('#boot-password').fill('password-123')
|
||||
await page.locator('#boot-confirm').fill('password-123')
|
||||
await submit.click()
|
||||
check('建好即登录、回到原处', await page.waitForURL(/\/apps(\?|$)/, { timeout: 15000 }).then(() => true, () => false), page.url())
|
||||
const sent = state.writes.find((w) => w.path === '/api/auth/bootstrap')
|
||||
const body = JSON.parse(sent?.body || '{}')
|
||||
check('请求带用户名、显示名、密码,带 X-AgentLab-Request 头', body.username === 'root' && body.display_name === '管理员甲'
|
||||
&& body.password === 'password-123' && !('confirm' in body) && sent?.csrf === '1', sent?.body)
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('使用者:导航只有应用和设置、账户菜单、没有署名框', async () => {
|
||||
const state = { mode: 'local', who: 'user', writes: [] }
|
||||
const page = await open(state)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//
|
||||
// 守的是:
|
||||
// - 能力列表标出读写效果,同步得出和治理后不一致时标出来;治理带 version 保存(乐观锁),409 时载入最新的、不覆盖;
|
||||
// - 端点的认证头只显示掩码,提交掩码值表示保持原值;允许访问内网只有管理员能开;测试连接按环境发;
|
||||
// - 端点的认证头只显示掩码,提交掩码值表示保持原值;名称空着、不合法、重名的那一行标红、不让保存;允许访问内网只有
|
||||
// 管理员能开;测试连接按环境发,照服务端的话写(2xx 才是成功,能连上但 404 是提醒);
|
||||
// - 关登录(默认)时所有人等同管理员:治理、退役、身份设置都能改;
|
||||
// - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名;
|
||||
// - 能力字段的待审阅(D-i)复用数据目录的审阅队列:成批确认逐项带能力的治理版本;
|
||||
@@ -155,7 +156,8 @@ async function open(path, { conflict = false, me = OFF } = {}) {
|
||||
const parts = url.pathname.split('/')
|
||||
if (parts[4] === 'test') {
|
||||
state.tests.push({ id: parts[3], env: url.searchParams.get('env') })
|
||||
return json(route, { ok: true, status: 200, message: '' })
|
||||
// 服务端按状态码照实说(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 })
|
||||
@@ -221,6 +223,20 @@ await section('能力治理:带 version 保存;别人刚改过时载入最
|
||||
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"]')
|
||||
@@ -229,13 +245,37 @@ await section('端点:两个环境、认证头只显示掩码、测试连接',
|
||||
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))
|
||||
})
|
||||
|
||||
|
||||
+41
-1
@@ -8,7 +8,9 @@
|
||||
// - 核实待办:三个选项(已生效 + 外部单号 / 未生效重新发送 / 未生效放弃),没选不提交,选了已生效不填单号不提交;
|
||||
// 提交的结论和单号照约定交给待办接口;
|
||||
// - 等外部系统(signal):只有说明和关联键,没有批准、驳回;
|
||||
// - 不是派给我的(can_respond 为假):只读,写明原因;
|
||||
// - 不是派给我的(can_respond 为假):只读,写明原因;同意过、还在等别人的写「你已处理:同意」,不写「没有派给你」;
|
||||
// - 升级之后:升级加派的那一份不算进会签人数,另写升级后的规则(升级给谁、其中一人说了算),名单里标「升级」;
|
||||
// 待办箱一行写所属实例,同标题的分得开;
|
||||
// - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段;
|
||||
// - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」。
|
||||
//
|
||||
@@ -91,6 +93,23 @@ const TASKS = {
|
||||
...base, id: 'readonly', approval_id: 'ap4', kind: 'approve', title: '总监审批', can_respond: false,
|
||||
payload: { kind: 'human_node', message: '金额超过一万,需要总监批准' },
|
||||
},
|
||||
// 同意过、还在等别人:不能再回复,写「你已处理:同意」,不写「没有派给你」
|
||||
voted: {
|
||||
...base, id: 'voted', approval_id: 'ap6', kind: 'approve', title: '财务会签(已同意)', policy: { kind: 'all' },
|
||||
can_respond: false, my_response: { decision: 'approve', at: ago(600_000), verified: true },
|
||||
assignments: [assignment('v1', 'zhangsan', 'responded', 'approve', { verified: true }), assignment('v2', 'wangwu', 'pending')],
|
||||
payload: { kind: 'human_node', message: '钱七报销 1,280.5 元' },
|
||||
},
|
||||
// 超时升级之后:升级加派的那一份不算进会签人数(还是 0/2),另写升级给了谁、其中一人说了算
|
||||
escalated: {
|
||||
...base, id: 'escalated', approval_id: 'ap7', kind: 'approve', title: '报销审批:钱七 · 300 元', policy: { kind: 'all' },
|
||||
due_at: ago(600_000), escalation_level: 1, instance_title: '报销审批·钱七·第二笔', node_label: '财务会签',
|
||||
assignments: [assignment('e1', 'zhangsan', 'pending'), assignment('e2', 'wangwu', 'pending'),
|
||||
assignment('e3', 'zhaoliu', 'pending', null, { source: 'escalation' })],
|
||||
progress: { policy: 'all', voters: 2, need: 2, agreed: 0, rejected: 0,
|
||||
escalation: { slot: 'director', assignees: [{ kind: 'user', name: 'zhaoliu' }], decided: null, by: null } },
|
||||
payload: { kind: 'human_node', message: '钱七报销 300 元' },
|
||||
},
|
||||
input: {
|
||||
...base, id: 'input', approval_id: 'ap5', kind: 'input', title: '补填发票信息',
|
||||
form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: {
|
||||
@@ -219,6 +238,27 @@ await section('等外部系统、只读', async () => {
|
||||
await page.locator('[data-task-readonly]').waitFor({ timeout: 15000 })
|
||||
check('不是派给我的:只读并写明', (await page.locator('[data-task-readonly]').innerText()).includes('没有派给你'))
|
||||
check('没有批准、驳回', (await page.locator('[data-task-approve], [data-task-reject]').count()) === 0)
|
||||
await page.goto(`${WEB}/apps/inbox/voted`)
|
||||
await page.locator('[data-task-readonly="responded"]').waitFor({ timeout: 15000 })
|
||||
const voted = await page.locator('[data-task-readonly]').innerText()
|
||||
check('同意过的人再打开:写「你已处理:同意」,不写「没有派给你」', voted.includes('你已处理:同意') && !voted.includes('没有派给你'), voted)
|
||||
})
|
||||
|
||||
await section('升级之后的会签进度', async () => {
|
||||
const page = await open({ writes: [] })
|
||||
await page.goto(`${WEB}/apps/inbox/escalated`)
|
||||
const s = page.locator('[data-signoffs]')
|
||||
await s.waitFor({ timeout: 15000 })
|
||||
const text = await s.innerText()
|
||||
check('升级加派的不算进会签人数:0/2,不是 0/3', text.includes('0/2 已同意') && (await s.getAttribute('data-signoffs-need')) === '2', text)
|
||||
check('另写升级后的规则:升级给谁、其中一人说了算',
|
||||
(await s.locator('[data-signoffs-escalation="open"]').innerText()).includes('已升级给岗位「director」:其中任一人同意即通过'))
|
||||
check('升级加派的那一份在名单里标「升级」', (await s.locator('li', { hasText: 'zhaoliu' }).innerText()).includes('升级'))
|
||||
await page.goto(`${WEB}/apps?tab=inbox`)
|
||||
const row = page.locator('[data-inbox-task="escalated"]')
|
||||
await row.waitFor({ timeout: 15000 })
|
||||
check('待办箱里也是 0/2', (await row.locator('[data-inbox-signoffs]').innerText()).includes('0/2 已同意'))
|
||||
check('待办一行写所属实例(同标题的分得开)', (await row.locator('[data-inbox-meta]').innerText()).includes('报销审批·钱七·第二笔'))
|
||||
})
|
||||
|
||||
await section('补充输入:表单校验、422 挂回字段', async () => {
|
||||
|
||||
@@ -1357,6 +1357,9 @@ await section('数据 · 遮罩列(证据面板的 mask_columns)', async ()
|
||||
&& await field.locator('[data-mask-chip="phone"]').count() === 1)
|
||||
const boundary = await field.locator('[data-mask-boundary]').innerText().catch(() => '')
|
||||
check('写明遮罩只减少暴露、不是安全边界', boundary.includes('遮罩只减少暴露,不是安全边界'), boundary)
|
||||
// 口径同 README「哪些角色看得到原值」:未开启登录时对谁都只减少暴露,开了登录对使用者是边界——两头都说清
|
||||
check('……并写明开启登录后对使用者是边界', await field.locator('[data-mask-boundary]').getAttribute('data-mask-boundary') === 'off'
|
||||
&& boundary.includes('开启登录后,遮罩对使用者是边界'), boundary)
|
||||
check('高级连接参数里不再另摆一个遮罩的文本框(只有这一处)', await ed.locator('#ds-adv-mask_columns').count() === 0)
|
||||
const before = sent.filter((r) => r.key === 'GET /datasources/check-manage-mask/schema').length
|
||||
await ed.locator('#ds-mask-columns').focus()
|
||||
@@ -3986,7 +3989,12 @@ await section('工具', async () => {
|
||||
: json({ detail: '将在工作目录中写入「x.txt」,同名文件会被覆盖。在工具库中执行不经过审批,确认后才会执行。' }, 409)(route)
|
||||
}],
|
||||
[/^POST \/mcp\/refresh$/, (route) => route.abort()],
|
||||
[/^POST \/custom-tools\/test$/, (route, { body }) => json(body?.config?.url?.includes('fail')
|
||||
// 写入能力(服务端 tools/custom.held_write):不发送,交回将要发送的请求,标 rehearsal
|
||||
[/^POST \/custom-tools\/test$/, (route, { body }) => json(body?.config?.url?.includes('pay') ? { ok: true, rehearsal: true, duration_ms: 3,
|
||||
result: { rehearsal: true, message: '演练:写入能力不在这里真的发送,下面是将要发送的请求', endpoint: 'erp', environment: 'test',
|
||||
request: { method: 'POST', url: 'https://erp.example.com/test/payments', query: {}, body: { amount: 3 },
|
||||
headers: ['Authorization', 'Idempotency-Key'] } } }
|
||||
: body?.config?.url?.includes('fail')
|
||||
? { ok: false, error: '工具执行失败:HTTP 404', hint: '核对接口地址', detail: '', duration_ms: 12 }
|
||||
: { ok: true, result: { echo: body?.args ?? {} }, duration_ms: 34, note: '参数名 qurey 不存在,已按唯一候选 query 执行' })(route)],
|
||||
// 沙箱里没有 MCP 服务:两台假的,一台后端记着 5 分钟前连上过,一台老数据只有 status、没有测连接记录
|
||||
@@ -4075,6 +4083,15 @@ await section('工具', async () => {
|
||||
await tryBtn.click()
|
||||
await page.waitForTimeout(400)
|
||||
check('……失败:原因和怎么办写在试运行区', (await trialBox.innerText()).includes('HTTP 404') && (await trialBox.innerText()).includes('核对接口地址'))
|
||||
// 写入能力:工具页试运行只演练(对外写入只经调用工具节点:要确认、带幂等键、留生效记录),不说「成功」(V2-13)
|
||||
await ed.getByLabel(/^URL/).fill('https://api.example.com/pay')
|
||||
await tryBtn.click()
|
||||
const rehearsed = trialBox.locator('[data-tool-result="rehearsal"]')
|
||||
check('……写入能力:写明演练、不在这里真的发送,不说成功', await rehearsed.waitFor({ timeout: 3000 }).then(() => true, () => false)
|
||||
&& (await rehearsed.innerText()).includes('演练:写入能力不在这里真的发送') && !(await rehearsed.innerText()).includes('成功'))
|
||||
const plan = await rehearsed.locator('[data-tool-rehearsal]').innerText().catch(() => '')
|
||||
check('……列出将要发送的方法和地址、环境、请求头的名字、请求体', plan.includes('POST https://erp.example.com/test/payments')
|
||||
&& plan.includes('测试环境') && plan.includes('Authorization、Idempotency-Key') && plan.includes('"amount": 3'), plan.replace(/\s+/g, ' '))
|
||||
await ed.getByRole('button', { name: '取消' }).click()
|
||||
await page.waitForTimeout(200)
|
||||
const discard = dialog(page).getByRole('button', { name: '放弃修改' })
|
||||
@@ -4221,7 +4238,11 @@ await section('工具 · 信任三档', async () => {
|
||||
(await detail.getByRole('radio').allInnerTexts()).join('|') === '需审批|始终允许 · 门控把关|始终允许',
|
||||
(await detail.getByRole('radio').allInnerTexts().catch(() => [])).join('|'))
|
||||
check('……现在选中的是后端给的「需审批」', await checkedIn(detail) === '需审批', await checkedIn(detail))
|
||||
check('……旁边说清三档的差别,写明正式运行不看这个设置', (await page.locator('[data-trust-block]').innerText()).includes('正式运行不受此设置影响'))
|
||||
// 和能力治理同一种说法(P5):三档只用于探索运行;正式运行一律问人,能力登记里「正式运行免审批」的只读能力除外
|
||||
const trustText = await page.locator('[data-trust-block]').innerText()
|
||||
check('……旁边说清三档的差别,写明正式运行不看这个设置、和能力治理的免审批一致',
|
||||
trustText.includes('这里的设置只用于探索运行') && trustText.includes('「正式运行免审批」的只读能力除外')
|
||||
&& !trustText.includes('一律需人工审批。'), trustText.replace(/\s+/g, ' '))
|
||||
check('徽标按信任档:需审批挂「运行时需审批」', (await listItem('crm_lookup').innerText()).includes('运行时需审批'))
|
||||
check('……门控把关挂「门控把关」', (await listItem('search').innerText()).includes('门控把关')
|
||||
&& !(await listItem('search').innerText()).includes('运行时需审批'))
|
||||
|
||||
@@ -1981,7 +1981,8 @@ await section('应用平台:发布成功做成应用;已发布版本的使
|
||||
const { page } = await open({ id: 'pf-lib' })
|
||||
const retires = []
|
||||
const versions = [4, 3, 2].map((n) => ({ id: `v${n}`, version: n, note: '', created_at: '2026-09-26T02:00:00Z',
|
||||
published: n === 4, level: 'published', retired_at: n === 2 ? '2026-10-01T02:00:00Z' : null, retired_by: n === 2 ? '张工' : null }))
|
||||
published: n === 4, level: 'published', retired_at: n === 2 ? '2026-10-01T02:00:00Z' : null, retired_by: n === 2 ? '张工' : null,
|
||||
retire_note: n === 2 ? '改按支付日统计' : null }))
|
||||
await page.route(/\/api\/workflows\/pf-lib\/versions(\/\d+)?(\/retire)?$/, (route) => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
const v = Number(path.split('/')[5])
|
||||
@@ -2000,6 +2001,15 @@ await section('应用平台:发布成功做成应用;已发布版本的使
|
||||
const sheet = page.locator('[role="dialog"][aria-label="版本历史"]')
|
||||
await sheet.waitFor()
|
||||
check('版本列表标出已退役的版本', await sheet.locator('[data-version-retired-chip]').count() === 1)
|
||||
const retiredRow = sheet.locator('ol button', { hasText: 'v2' }).first()
|
||||
check('……不用点开,列表那一行就写谁退的和退役说明', await retiredRow.locator('[data-version-retired]').count() === 1
|
||||
&& (await retiredRow.innerText()).includes('张工') && (await retiredRow.innerText()).includes('改按支付日统计'),
|
||||
(await retiredRow.innerText()).replace(/\s+/g, ' '))
|
||||
await retiredRow.click()
|
||||
const gone = sheet.locator('[data-version-lifecycle="2"]')
|
||||
await gone.waitFor()
|
||||
check('退役的版本:展开后不给「做成应用」和退役按钮,也不重复写退役说明', await gone.locator('[data-version-make-app]').count() === 0
|
||||
&& await gone.locator('[data-version-retire]').count() === 0 && await gone.locator('[data-version-retired]').count() === 0)
|
||||
await sheet.locator('ol button', { hasText: 'v4' }).first().click()
|
||||
const cur = sheet.locator('[data-version-lifecycle="4"]')
|
||||
// 使用情况是展开之后另取的(账本接口):等它回来再读,别读到占位的「…」
|
||||
@@ -2018,6 +2028,92 @@ await section('应用平台:发布成功做成应用;已发布版本的使
|
||||
await note.getByRole('button', { name: '退役这一版' }).click().catch(() => {})
|
||||
check('确认之后才发出退役请求', await waitFor(page, async () => retires.length === 1) && retires[0].v === 3, JSON.stringify(retires))
|
||||
}
|
||||
{
|
||||
// 版本历史里每个已发布、未退役的版本都能做成应用:关掉发布弹窗之后不用为此重发一次
|
||||
const { page } = await open({ id: 'pf-lib' })
|
||||
const versions = [4, 3, 2].map((n) => ({ id: `v${n}`, version: n, note: '', created_at: '2026-09-26T02:00:00Z',
|
||||
published: n === 4, level: n === 2 ? null : 'published', retired_at: null, retired_by: null }))
|
||||
const derived = []
|
||||
await page.route(/\/api\/workflows\/pf-lib\/versions(\/\d+)?$/, (route) => {
|
||||
const v = Number(new URL(route.request().url()).pathname.split('/')[5])
|
||||
const body = v ? { ...versions.find((x) => x.version === v), workflow_id: 'pf-lib', graph: FAKES['pf-lib'].graph, graph_hash: 'x' } : versions
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
await page.route(/\/api\/ledger\/workflow-versions\/.+$/, (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(usage) }))
|
||||
await page.route(/\/api\/apps\/derive$/, (route) => {
|
||||
derived.push(route.request().postDataJSON())
|
||||
return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ detail: '检查脚本不建应用' }) })
|
||||
})
|
||||
await page.evaluate(() => document.activeElement?.blur?.())
|
||||
await page.keyboard.press('Alt+KeyH')
|
||||
const sheet = page.locator('[role="dialog"][aria-label="版本历史"]')
|
||||
await sheet.waitFor()
|
||||
await sheet.locator('ol button', { hasText: 'v2' }).first().click()
|
||||
await sheet.locator('ol li').filter({ hasText: 'v2' }).locator('.fade-up').first().waitFor()
|
||||
check('没发布过的版本不给「做成应用」', await sheet.locator('[data-version-make-app]').count() === 0)
|
||||
await sheet.locator('ol button', { hasText: 'v3' }).first().click()
|
||||
const make = sheet.locator('[data-version-make-app="3"]')
|
||||
check('展开一个已发布、不是当前发布版的版本:给「做成应用」', await waitFor(page, async () => await make.count() === 1)
|
||||
&& (await make.innerText()).includes('做成应用'))
|
||||
await make.click()
|
||||
await page.waitForURL('**/apps/new/edit**')
|
||||
const u = new URL(page.url())
|
||||
check('……跳到新建应用,带着工作流和这一版', u.searchParams.get('workflow') === 'pf-lib' && u.searchParams.get('version') === '3',
|
||||
u.pathname + u.search)
|
||||
check('……进来就按这一版派生', await waitFor(page, async () => derived.length === 1)
|
||||
&& derived[0]?.workflow_id === 'pf-lib' && derived[0]?.version === 3, JSON.stringify(derived))
|
||||
}
|
||||
})
|
||||
|
||||
// 开了登录:发布记的是登录的人(服务端写 published_by,不看署名),不给署名框、不说「未署名」;受管级只给管理员,
|
||||
// 搭建者那一档当场置灰、写明原因,不等点了发布才被拒(V1-21、V1-22)。受管工作流的新版本不论哪一档都只有管理员能发:
|
||||
// 降成「已发布」会让此后的正式运行不再过治理门禁,和以受管级发布同一级(Q8,服务端同样拦)——搭建者两档都锁住
|
||||
await section('开了登录的搭建者:受管级置灰写原因、受管工作流两档都锁住、以登录身份发布、没有署名框', async () => {
|
||||
const { page, state } = await open({ id: 'pf-hard' })
|
||||
await page.route(/\/api\/auth\/me$/, (route) => route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true }) }))
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
await page.waitForFunction(() => window.__studio?.getState().workflow?.id === 'pf-hard', null, { timeout: 15000 })
|
||||
await openDialog(page)
|
||||
const governed = dialog(page).locator('[data-publish-level="governed"]')
|
||||
const published = dialog(page).locator('[data-publish-level="published"]')
|
||||
check('受管级置灰', await governed.isDisabled())
|
||||
check('……卡上写明要管理员', (await governed.innerText()).includes('需要管理员'), (await governed.innerText()).replace(/\s+/g, ' '))
|
||||
check('受管工作流:「已发布」也置灰,卡上写明降级要管理员', await published.isDisabled()
|
||||
&& (await published.innerText()).includes('降为「已发布」'), (await published.innerText()).replace(/\s+/g, ' '))
|
||||
check('……两档都没选中', await dialog(page).locator('[role="radio"][aria-checked="true"]').count() === 0)
|
||||
check('……发布按钮不可用,悬停写只有管理员能发布', await submit(page).isDisabled()
|
||||
&& await submit(page).getAttribute('title') === '这是受管工作流,新版本只有管理员能发布', `${await submit(page).getAttribute('title')}`)
|
||||
check('……不出降级提示(降级不是他能选的)', await dialog(page).locator('[data-publish-downgrade]').count() === 0)
|
||||
check('……按「已发布」先查一遍问题', state.checks[0]?.level === 'published', JSON.stringify(state.checks.map((c) => c.level)))
|
||||
const signer = dialog(page).locator('[data-publish-signer]')
|
||||
check('以登录身份发布:写显示名,不用本机署名', await signer.getAttribute('data-publish-signer') === 'verified'
|
||||
&& (await signer.innerText()).includes('以登录身份发布:李四(lisi)') && !(await signer.innerText()).includes('检查脚本'),
|
||||
await signer.innerText())
|
||||
check('没有署名框,也不说「未署名」', await dialog(page).getByRole('textbox', { name: '署名' }).count() === 0
|
||||
&& !(await dialog(page).innerText()).includes('未署名'))
|
||||
check('没有点发布,不发请求', state.publishes.length === 0)
|
||||
})
|
||||
|
||||
// 管理员手动把受管工作流降成「已发布」:提示将降级,点发布先确认一次,确认后才发
|
||||
await section('管理员降级受管工作流:提示、确认之后才发', async () => {
|
||||
const { page, state } = await open({ id: 'pf-hard' })
|
||||
await openDialog(page)
|
||||
await dialog(page).locator('[data-publish-level="published"]').click()
|
||||
check('点了「已发布」:提示将降级,发布按钮可用', await dialog(page).locator('[data-publish-downgrade]').count() === 1
|
||||
&& (await dialog(page).locator('[data-publish-downgrade]').innerText()).includes('将降级') && await submit(page).isEnabled())
|
||||
const confirm = page.locator('[role="dialog"]').filter({ hasText: '降级并发布' })
|
||||
await submit(page).click()
|
||||
check('点发布先确认降级,确认之前不发请求', await waitFor(page, async () => await confirm.count() === 1)
|
||||
&& (await confirm.innerText()).includes('不再受治理门禁约束') && state.publishes.length === 0)
|
||||
await confirm.getByRole('button', { name: '取消' }).click()
|
||||
check('……取消:不发请求,发布弹窗还在', await waitFor(page, async () => await confirm.count() === 0)
|
||||
&& state.publishes.length === 0 && await submit(page).count() === 1)
|
||||
await submit(page).click()
|
||||
await confirm.getByRole('button', { name: '降级并发布' }).click()
|
||||
check('……确认之后才发出一次发布,等级是「已发布」', await waitFor(page, async () => state.publishes.length === 1)
|
||||
&& state.publishes[0]?.level === 'published', JSON.stringify(state.publishes))
|
||||
})
|
||||
|
||||
await section('360px 下不横向滚动', async () => {
|
||||
|
||||
@@ -209,6 +209,12 @@ await section('断网打开', async () => {
|
||||
check('指标步骤带口径、计算方式(原式、代入式)、输入和复算',
|
||||
/口径卡「/.test(text) && /原式(计算方式)/.test(text) && /代入式/.test(text)
|
||||
&& (/输入/.test(text) || /缺少输入/.test(text)) && /复算/.test(text), text.slice(0, 300))
|
||||
// 有出处(确定性复算一致)和追到原始数据分开说:链里带了 untraced 的,面板写明追没追到、追到哪个节点为止
|
||||
const untraced = chain.find((s) => s.step === 'metric')?.untraced
|
||||
if (Array.isArray(untraced)) {
|
||||
check('指标步骤写明输入追没追到原始数据', /原始数据/.test(text)
|
||||
&& untraced.every((u) => text.includes(String(u.path))), text.slice(0, 300))
|
||||
}
|
||||
}
|
||||
if (kinds.includes('query')) {
|
||||
check('查询步骤带 SQL 和被引用的行', /SQL/.test(text) && /被引用的行/.test(text), text.slice(0, 300))
|
||||
|
||||
@@ -364,10 +364,15 @@ const VERSION3 = {
|
||||
id: 'v3', version: 3, note: '发布', workflow_id: 'wf-weekly', published: true, graph: WORKFLOWS[0].graph,
|
||||
input_fields: [{ name: 'date', required: true, description: '统计日期' }, { name: 'region', default: '华东' }],
|
||||
}
|
||||
/** ISO 周的补充说明照服务端(scheduler/service.ISO_WEEK_NOTE):几个周变量同一句 */
|
||||
const ISO_NOTE = 'ISO 周从周一开始,含当年 1 月 4 日的那一周是第 1 周;跨年那几天按所在的周算,例如 2027-01-01(周五)属于 2026-W53'
|
||||
const VARIABLES = [
|
||||
{ name: 'schedule.yesterday', label: '昨天(YYYY-MM-DD)', example: '2026-10-04' },
|
||||
{ name: 'schedule.today', label: '今天(YYYY-MM-DD)', example: '2026-10-05' },
|
||||
{ name: 'schedule.last_week_start', label: '上周一(YYYY-MM-DD)', example: '2026-09-28' },
|
||||
{ name: 'schedule.yesterday', label: '昨天(YYYY-MM-DD)', example: '2026-10-04', note: '' },
|
||||
{ name: 'schedule.today', label: '今天(YYYY-MM-DD)', example: '2026-10-05', note: '' },
|
||||
{ name: 'schedule.this_week', label: '本周(ISO 周,YYYY-Www)', example: '2026-W41', note: ISO_NOTE },
|
||||
{ name: 'schedule.last_week', label: '上周(ISO 周,YYYY-Www)', example: '2026-W40', note: ISO_NOTE },
|
||||
{ name: 'schedule.last_week_start', label: '上周一(YYYY-MM-DD)', example: '2026-09-28', note: '' },
|
||||
{ name: 'schedule.last_month', label: '上月(YYYY-MM)', example: '2026-09', note: '' },
|
||||
]
|
||||
const FEISHU_URL = 'https://open.feishu.cn/open-apis/bot/v2/hook/0f3c9a7e-demo-ab12'
|
||||
const urlHint = (url) => {
|
||||
@@ -954,6 +959,15 @@ await section('新建:每周一 09:00 的请求体', async () => {
|
||||
await page.locator('[data-input-field="date"] [data-required][aria-label="必填"]').count() === 1
|
||||
&& await page.locator('[data-input-field="region"] [data-required]').count() === 0)
|
||||
await page.locator('[data-input-vars="date"]').click()
|
||||
const varLabels = await page.locator('[data-input-field="date"] [data-var]').allInnerTexts()
|
||||
check('变量清单有 ISO 周、月份(按周、按月统计的报告跟着触发日走)',
|
||||
varLabels.includes('本周(ISO 周,YYYY-Www)') && varLabels.includes('上周(ISO 周,YYYY-Www)') && varLabels.includes('上月(YYYY-MM)'),
|
||||
oneLine(varLabels))
|
||||
check('ISO 周的悬停写变量名和示例', (await page.locator('[data-input-field="date"] [data-var="schedule.last_week"]').getAttribute('title'))
|
||||
=== 'schedule.last_week,例如 2026-W40')
|
||||
const notes = await page.locator('[data-input-field="date"] [data-var-note]').allInnerTexts()
|
||||
check('ISO 周的跨年规则写在变量下面,几个周变量同一句只写一遍(触屏没有悬停也看得到)',
|
||||
notes.length === 1 && notes[0] === ISO_NOTE, oneLine(notes))
|
||||
await page.locator('[data-input-field="date"] [data-var="schedule.yesterday"]').click()
|
||||
check('点变量插入模板', await page.locator('[data-input-field="date"] input').inputValue() === '{{ schedule.yesterday }}',
|
||||
await page.locator('[data-input-field="date"] input').inputValue())
|
||||
@@ -1382,6 +1396,82 @@ await section('详情:晚到的刷新与先开触发历史', async () => {
|
||||
check('没有页面报错', !h.errors.length, h.errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('详情:重新载入碰上原地刷新,照样换成服务端的设置', async () => {
|
||||
const state = freshState()
|
||||
const { page, sent, errors } = await open('/schedules/s-weekly', { state })
|
||||
await page.locator('[data-schedule-form]').waitFor()
|
||||
state.schedules = state.schedules.map((s) => (s.id === 's-weekly' ? { ...s, version: 9, name: '别处改过的名字' } : s))
|
||||
await page.locator('[data-field="name"]').fill('又改一次')
|
||||
await page.locator('[data-schedule-save]').click()
|
||||
await page.locator('[data-schedule-reload]').waitFor()
|
||||
// 读晚 1.5 秒才回:「重新载入」的读还在路上,立即运行之后的原地刷新又发出去一次
|
||||
state.override = slowGet(state, '/schedules/s-weekly', (st) => st.schedules.find((s) => s.id === 's-weekly'))
|
||||
const gets = () => sent.filter((s) => s.key === 'GET /schedules/s-weekly').length
|
||||
const before = gets()
|
||||
await page.locator('[data-schedule-reload]').click()
|
||||
await page.locator('[role="dialog"]').getByRole('button', { name: '放弃修改并重新载入' }).click()
|
||||
await until(() => gets() > before)
|
||||
await page.locator('header [data-schedule-run]').click()
|
||||
check('重新载入的读在路上时又原地刷新了一次', await until(() => gets() >= before + 2), `${gets() - before} 次`)
|
||||
check('……重新载入不被刷新作废:表单换成服务端的设置、冲突提示收起', await until(async () =>
|
||||
(await page.locator('[data-field="name"]').inputValue()) === '别处改过的名字'
|
||||
&& await page.locator('[data-schedule-conflict]').count() === 0, 5000),
|
||||
await page.locator('[data-field="name"]').inputValue())
|
||||
state.override = null
|
||||
await page.locator('[data-misfire]').selectOption('86400')
|
||||
await page.locator('[data-schedule-save]').click()
|
||||
await until(() => writes(sent, /^PATCH /).length === 2)
|
||||
await saveDone(page)
|
||||
const p = writes(sent, /^PATCH /)[1]?.body
|
||||
check('……再保存带重新载入的那一版(9),不再撞 409', JSON.stringify(p) === JSON.stringify({ misfire_grace_s: 86400, version: 9 }),
|
||||
JSON.stringify(p))
|
||||
check('没有页面报错', !errors.length, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('详情:运行结束后页头的「上次触发」跟着变', async () => {
|
||||
// 上次触发发起的运行还在跑:页头和触发历史第一条是同一次触发(服务端同一个口径)
|
||||
const running = (st) => {
|
||||
const f = { ...st.fires[0], run_status: 'running', compare: null }
|
||||
st.fires = [f, ...st.fires.slice(1)]
|
||||
st.schedules = st.schedules.map((x) => (x.id === 's-weekly' ? { ...x, last_fire: structuredClone(f) } : x))
|
||||
}
|
||||
const finish = (st) => {
|
||||
st.fires = [{ ...st.fires[0], run_status: 'succeeded' }, ...st.fires.slice(1)]
|
||||
st.schedules = st.schedules.map((x) => (x.id === 's-weekly' ? { ...x, last_fire: structuredClone(st.fires[0]) } : x))
|
||||
}
|
||||
const head = (page) => page.locator('[data-schedule-summary] [data-status]').first().getAttribute('data-status').catch(() => null)
|
||||
const row = (page) => page.locator('[data-fire-id="f00"] [data-status]').first().getAttribute('data-status').catch(() => null)
|
||||
|
||||
// 点触发历史的「刷新」:页头一起变,不等下一次原地刷新
|
||||
const st = freshState()
|
||||
running(st)
|
||||
const a = await open('/schedules/s-weekly?tab=history', { state: st })
|
||||
await a.page.locator('[data-fire-id="f00"]').waitFor()
|
||||
check('开始时页头和触发历史都是「运行中」', await until(async () => await head(a.page) === 'running' && await row(a.page) === 'running'),
|
||||
`${await head(a.page)} | ${await row(a.page)}`)
|
||||
finish(st)
|
||||
await a.page.locator('[data-fire-refresh]').click()
|
||||
check('点触发历史的刷新:页头的上次触发跟着变成已完成', await until(async () => await head(a.page) === 'succeeded', 3000),
|
||||
`${await head(a.page)}`)
|
||||
check('……触发历史那一行也是已完成', await row(a.page) === 'succeeded', `${await row(a.page)}`)
|
||||
check('没有页面报错', !a.errors.length, a.errors.join(' | '))
|
||||
|
||||
// 什么都不点:运行还在跑时页头原地刷新得勤,运行一结束页头和触发历史都跟着变
|
||||
const st2 = freshState()
|
||||
running(st2)
|
||||
const b = await open('/schedules/s-weekly?tab=history', { state: st2 })
|
||||
await b.page.locator('[data-fire-id="f00"]').waitFor()
|
||||
await until(async () => await head(b.page) === 'running')
|
||||
const fires = () => b.sent.filter((x) => x.key === 'GET /schedules/s-weekly/fires').length
|
||||
const firesBefore = fires()
|
||||
finish(st2)
|
||||
check('运行结束:不点刷新,几秒内页头的上次触发变成已完成', await until(async () => await head(b.page) === 'succeeded', 8000, 250),
|
||||
`${await head(b.page)}`)
|
||||
check('……触发历史那一行原地换成已完成,不从头重取(翻过的页不丢)', await until(async () => await row(b.page) === 'succeeded', 2000)
|
||||
&& fires() === firesBefore, `${await row(b.page)} | 多取了 ${fires() - firesBefore} 次`)
|
||||
check('没有页面报错', !b.errors.length, b.errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('详情:触发历史分页', async () => {
|
||||
const { page, sent, errors } = await open('/schedules/s-weekly?tab=history')
|
||||
await page.locator('[data-fire-id]').first().waitFor()
|
||||
|
||||
@@ -2944,6 +2944,15 @@ await section('应用平台:人工节点的流程配置、入口字段的类
|
||||
check('入口字段可以选类型', await types.first().waitFor({ timeout: 4000 }).then(() => true, () => false))
|
||||
await page.locator('[data-sub="type"] select').first().selectOption('number')
|
||||
check('选了数字:写进字段的 type', (await cfg('in')).fields[0].type === 'number', JSON.stringify((await cfg('in')).fields[0]))
|
||||
// 数字字段的上下限(表单子集只有闭区间:「大于 0」的金额写 0.01):框里的字离开时才写回,「0.」打得出来
|
||||
const min = page.locator('[data-input-field-range] [data-sub="minimum"] input').first()
|
||||
check('数字字段有最小值、最大值两栏', await min.waitFor({ timeout: 3000 }).then(() => true, () => false)
|
||||
&& await page.locator('[data-input-field-range] [data-sub="maximum"] input').count() === 1)
|
||||
await min.fill('0.')
|
||||
check('最小值框能打出小数点', (await min.inputValue()) === '0.')
|
||||
await min.fill('0.01')
|
||||
await min.blur()
|
||||
check('最小值写进字段的 minimum', (await cfg('in')).fields[0].minimum === 0.01, JSON.stringify((await cfg('in')).fields[0]))
|
||||
await page.locator('[data-sub="enum"] textarea').nth(1).fill('差旅\n办公')
|
||||
await page.locator('[data-sub="enum"] textarea').nth(1).blur()
|
||||
check('可选值一行一个', JSON.stringify((await cfg('in')).fields[1].enum) === JSON.stringify(['差旅', '办公']), JSON.stringify((await cfg('in')).fields[1]))
|
||||
@@ -2959,6 +2968,7 @@ await section('应用平台:人工节点的流程配置、入口字段的类
|
||||
const form = page.locator('[data-launch-form]')
|
||||
check('发起表单用 SchemaForm 画', await form.waitFor({ timeout: 3000 }).then(() => true, () => false))
|
||||
check('有可选值的字段是下拉', await form.locator('select').count() >= 1)
|
||||
check('数字字段的下限带进发起表单', await form.locator('[data-field="amount"] input').getAttribute('aria-valuemin') === '0.01')
|
||||
await page.locator('.sf-pop button.btn-primary').last().click()
|
||||
check('试运行带上来源 canvas', await (async () => { for (let i = 0; i < 30 && !starts.length; i++) await page.waitForTimeout(100); return starts[0]?.origin === 'canvas' })(),
|
||||
JSON.stringify(starts[0]?.origin))
|
||||
|
||||
@@ -214,6 +214,16 @@ await section('lib:格式、状态、快捷键、术语、报错', async () =>
|
||||
ok('中文人话里的「超时」不套「操作超时」', zh.title !== '操作超时' && !(zh.reason ?? '').includes('操作超时')
|
||||
&& !(zh.reason && zh.reason.includes(zh.title)), JSON.stringify(zh))
|
||||
eq('英文 timeout 原文照样翻成「操作超时」', e.humanizeError('ReadTimeout: timed out').title, '操作超时')
|
||||
// 403 的「怎么办」按开没开登录说:关登录时是署名和权限设置的事;开了登录按角色和授权定,找管理员,不提署名
|
||||
const denied = () => e.humanizeError(new ApiError(403, '没有权限:这项操作需要搭建者及以上的角色'))
|
||||
eq('关登录的 403:检查署名和权限', denied().action, '请检查设置中的署名和权限。')
|
||||
const { useAuth } = window.__ui
|
||||
const was = useAuth.getState()
|
||||
useAuth.setState({ me: { mode: 'local', username: 'u1', display_name: 'u1', role: 'user', groups: [], verified: true }, ready: true })
|
||||
const on = denied()
|
||||
useAuth.setState({ me: was.me, ready: was.ready })
|
||||
ok('开了登录的 403:找管理员调整角色或授权,不提署名', on.action.includes('管理员') && !on.action.includes('署名')
|
||||
&& on.title.includes('搭建者'), JSON.stringify(on))
|
||||
ok('……原因里不带异常类名', !/ReadTimeout/.test(e.humanizeError('ReadTimeout: timed out').reason ?? ''))
|
||||
|
||||
// 失败说明(lib/explain):记录页、助手流、问数据页共用一份
|
||||
|
||||
Reference in New Issue
Block a user