mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 18:11:59 +00:00
0b825f40ee
- 设置 → 访问密钥:管理员看个人访问密钥的开关、服务账号(建、停用前确认)和全部密钥;别人只看自己的个人访问密钥,管理员 打开开关之后才有这一页(认人接口多给一项 personal_keys:关着时建不了、已有的也不生效,给一页空的没有用)。新建时缺省只勾读 的权限,服务账号勾不了回复待办,有效期必填最长 365 天;明文只显示这一次,点「我已保存」就没了;轮换、撤销先确认;关登录时 照实说访问密钥不生效、不给建的入口 - 「最近的请求」做成共用组件(components/RecentRequests.tsx):每把密钥的请求记录、钩子的收件记录都用它,接口写路由模板, 成功、被拒、处理中分开上色 - 接口说明:照服务端门禁那张表列出方法、路径、权限、说明、错误码,标出要幂等键的 - 应用编辑 → 钩子:生成过密钥的钩子能展开收件记录(状态、原因、发起的实例),写明近 1 小时验签失败几次 - 名录里的服务账号:选人控件缺省不收(岗位绑定、改派不能派给它),应用授权照收;身份页那一行标出服务账号,不给角色和密码的 控件;审计按「访问密钥」筛 - 检查:新建 check-access-keys(不叫 check-tokens,那个名字查的是设计令牌),check-app-editor 的钩子一段加收件记录;登记进 check-all
675 lines
49 KiB
JavaScript
675 lines
49 KiB
JavaScript
// 应用编辑(搭建面,/apps/:appId/edit)的检查:固定版本、派生、页面树和属性面板、检查(按 path 定位)、
|
||
// 发布时为升版的固定版本选处置、助手流(应用操作实时落到草稿上、look 和 ask 照 decode 显示)。
|
||
//
|
||
// 守的是 docs/design/app-platform-and-look.md 的几条:
|
||
// - 只能固定已发布且未退役的版本(A1);新建应用从版本确定性派生,不靠模型(A8、P3);
|
||
// - 属性面板由组件目录的 props schema 驱动;绑定种类只能从目录登记的里挑;可见性表达式只编辑、不在浏览器求值(A2、P7);
|
||
// - 固定版本升版必须选处置,不给默认值,写清后果(口径升版是必须处置的事件);
|
||
// - 助手只润色:应用操作到一条落一条,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 并记账,检查结束时必须是空的。
|
||
//
|
||
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例跑时带上地址:
|
||
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-app-editor.mjs
|
||
// APP_EDITOR_ONLY=发布,助手 只跑段名里含这些字的段
|
||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
const ONLY = (process.env.APP_EDITOR_ONLY ?? '').split(',').filter(Boolean)
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
const opened = new Set()
|
||
async function section(name, fn) {
|
||
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
|
||
console.log(`=== ${name} ===`)
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
} finally {
|
||
for (const c of opened) await c.close().catch(() => {})
|
||
opened.clear()
|
||
}
|
||
}
|
||
const clone = (v) => JSON.parse(JSON.stringify(v))
|
||
const waitFor = async (page, fn, ms = 5000) => {
|
||
for (let t = 0; t < ms; t += 100) {
|
||
if (await fn()) return true
|
||
await page.waitForTimeout(100)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 夹具(通用示例名)
|
||
|
||
const CATALOG = {
|
||
version: 1,
|
||
categories: { layout: '布局', display: '展示', input: '输入', process: '流程' },
|
||
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, 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, 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: [],
|
||
props: { type: 'object', properties: { entry: { type: 'string' }, label: { type: 'string', maxLength: 200 } } } },
|
||
],
|
||
}
|
||
|
||
const WF = { id: 'ae-wf', name: '__app_editor_wf__', status: 'published', published_version: 7, version: 7, graph: {
|
||
nodes: [{ id: 'kpi', type: 'metrics', position: { x: 0, y: 0 }, data: { label: '口径', config: {} } },
|
||
{ id: 'rep', type: 'report', position: { x: 200, y: 0 }, data: { label: '周报', config: {} } }],
|
||
edges: [] }, tags: [], is_template: false, description: '' }
|
||
const VERSIONS = [7, 6, 5, 4].map((n) => ({ id: `v${n}`, version: n, note: '', created_at: '2026-10-01T02:00:00Z',
|
||
published: n === 7, level: n === 4 ? null : 'published', retired_at: n === 5 ? '2026-10-02T02:00:00Z' : null }))
|
||
|
||
const SPEC = {
|
||
format: 'agentlab.app/1', name: '周报看板', description: '', icon: 'app',
|
||
pins: [{ alias: 'weekly', workflow_id: 'ae-wf', version: 6, graph_hash: 'hash6', level: 'published', use: 'view' }],
|
||
slots: {}, entries: [], forms: {}, task_ui: {},
|
||
views: { wk: { source: { kind: 'latest_sealed', pin: 'weekly', from: 'formal' } } },
|
||
pages: [{ id: 'home', title: '本周概览', body: [
|
||
{ id: 'cols', type: 'columns', props: {}, children: [
|
||
{ id: 'gmv', type: 'metric', props: { label: '销售额' }, bind: { view: 'wk', metric: 'kpi.gmv' } }] },
|
||
{ id: 'warn', type: 'notice', props: { text: '本期为降级出具' }, visible: "run.tier != 'formal'", scope: 'wk' },
|
||
] }],
|
||
hooks: [], notify: [],
|
||
}
|
||
|
||
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, status = 'active' } = {}) {
|
||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||
opened.add(ctx)
|
||
await ctx.addInitScript(() => { try { localStorage.setItem('agentlab_actor', '检查脚本') } catch { /* noop */ } })
|
||
const page = await ctx.newPage()
|
||
page.setDefaultTimeout(8000)
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push(e.message))
|
||
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
|
||
const app = { id: 'app-ae', name: SPEC.name, description: '', icon: 'app', status, 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: '检查脚本', ...(backfill ? { backfill } : {}) }
|
||
const state = { app, patches: [], lints: 0, publishes: [], derives: [], creates: [], streams: [], pages: [],
|
||
hooks: clone(hooks), rotates: [], hookPatches: [], hookDeletes: [], receipts: [], backfills: [], turns: [], turnPatches: [],
|
||
conversations: 0,
|
||
archives: 0 }
|
||
|
||
await page.route(/\/api\//, (route) => {
|
||
const req = route.request()
|
||
if (req.method() === 'GET') return route.continue()
|
||
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
|
||
return json(route, { detail: '检查脚本不写库' }, 409)
|
||
})
|
||
await page.route(/\/api\/workflows(\?.*)?$/, (route) => (route.request().method() === 'GET' ? json(route, [WF]) : route.fallback()))
|
||
await page.route(/\/api\/workflows\/ae-wf\/versions(\/\d+)?$/, (route) => {
|
||
const v = Number(new URL(route.request().url()).pathname.split('/')[5])
|
||
return json(route, v ? { ...VERSIONS.find((x) => x.version === v), workflow_id: 'ae-wf', graph: WF.graph, graph_hash: `hash${v}` } : VERSIONS)
|
||
})
|
||
await page.route(/\/api\/apps\/catalog$/, (route) => json(route, CATALOG))
|
||
await page.route(/\/api\/apps\/derive$/, (route) => {
|
||
const body = route.request().postDataJSON()
|
||
state.derives.push(body)
|
||
return json(route, { spec: { ...clone(SPEC), name: body.name || WF.name,
|
||
pins: [{ alias: 'p1', workflow_id: body.workflow_id, version: body.version, graph_hash: `hash${body.version}`, level: 'published', use: 'view' }] } })
|
||
})
|
||
await page.route(/\/api\/apps$/, (route) => {
|
||
if (route.request().method() !== 'POST') return json(route, { items: [] })
|
||
const body = route.request().postDataJSON()
|
||
state.creates.push(body)
|
||
return json(route, { ...app, id: 'app-new', name: body.name, draft: body.spec })
|
||
})
|
||
await page.route(/\/api\/apps\/app-[a-z]+(\/.*)?(\?.*)?$/, (route) => {
|
||
const req = route.request()
|
||
const url = new URL(req.url())
|
||
const [, , , , sub, rest] = url.pathname.split('/')
|
||
if (!sub && req.method() === 'GET') return json(route, state.app)
|
||
if (!sub && req.method() === 'PATCH') {
|
||
const body = req.postDataJSON()
|
||
state.patches.push(body)
|
||
state.app = { ...state.app, ...(body.draft ? { draft: body.draft } : {}), ...(body.draft_bindings ? { draft_bindings: body.draft_bindings } : {}) }
|
||
return json(route, state.app)
|
||
}
|
||
if (sub === 'archive') {
|
||
state.archives += 1
|
||
state.app = { ...state.app, status: 'archived' }
|
||
return json(route, state.app)
|
||
}
|
||
if (sub === 'lint') {
|
||
state.lints += 1
|
||
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)
|
||
// 收件记录(04 细案 A6):形状同服务端 process/hooks.recent_receipts
|
||
if (action === 'receipts') {
|
||
state.receipts.push(hook)
|
||
return json(route, { window_s: 3600, signature_failures: 2, items: [
|
||
{ idem_key: 'pay-2', status: 'rejected', received_at: '2026-10-14T03:00:00Z', run_id: null, task_id: null,
|
||
detail: '没有在等这个关联键的待办(可能已处理过,或还没走到这一步)' },
|
||
{ idem_key: 'pay-1', status: 'accepted', received_at: '2026-10-14T02:00:00Z', run_id: 'run-hook-1', task_id: 'task-1',
|
||
detail: null }] })
|
||
}
|
||
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()
|
||
state.publishes.push(body)
|
||
if (publish === 'disposition' && !body.dispositions) {
|
||
return json(route, { ok: false, issues: [{ level: 'error', code: 'app.upgrade_policy', message: '固定版本升了版,没有选处置' }],
|
||
needs_disposition: [{ alias: 'weekly', from: 5, to: 6 }] })
|
||
}
|
||
return json(route, { ok: true, version: 3, issues: [] })
|
||
}
|
||
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)
|
||
return json(route, { format: 'agentlab.view/1', app: { id: 'app-ae', name: SPEC.name, version: 2, level: 'published', needs_upgrade: false },
|
||
page: { id: rest, title: '本周概览' }, pages: [{ id: 'home', title: '本周概览' }], sources: {}, tree: [], forms: {}, problems: [] })
|
||
}
|
||
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('') })
|
||
})
|
||
|
||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(300)
|
||
return { page, state, errors }
|
||
}
|
||
|
||
const writes = []
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
|
||
// ================================================================
|
||
await section('新建:从发布弹窗带着工作流和版本进来,直接派生、新建、进编辑器', async () => {
|
||
const { page, state, errors } = await open({ path: '/apps/new/edit?workflow=ae-wf&version=7' })
|
||
check('派生不靠模型:调 /apps/derive,带工作流和版本', await waitFor(page, async () => state.derives.length === 1)
|
||
&& state.derives[0].workflow_id === 'ae-wf' && state.derives[0].version === 7, JSON.stringify(state.derives))
|
||
check('派生的 spec 原样建成应用', await waitFor(page, async () => state.creates.length === 1)
|
||
&& state.creates[0].spec?.pins?.[0]?.version === 7, 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 () => {
|
||
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 () => {
|
||
const { page, state } = await open({ path: '/apps/app-ae/edit/pins' })
|
||
const pin = page.locator('[data-pin="weekly"]')
|
||
check('列出固定的版本', await pin.count() === 1 && (await pin.innerText()).includes('hash6'.slice(0, 8)))
|
||
await pin.locator('select').first().selectOption('7')
|
||
await page.waitForTimeout(300)
|
||
await page.locator('[data-app-save]').click()
|
||
await waitFor(page, async () => state.patches.length === 1)
|
||
const saved = state.patches[0]?.draft?.pins?.[0]
|
||
check('改版本:版本和哈希一起换(哈希取自版本详情)', saved?.version === 7 && saved?.graph_hash === 'hash7', JSON.stringify(saved))
|
||
check('界面写「固定版本」,不写钉住', !(await page.locator('main').innerText()).includes('钉'))
|
||
})
|
||
|
||
await section('页面树和属性面板:目录驱动、绑定、可见性', async () => {
|
||
const { page, state } = await open()
|
||
check('页面树列出组件', await page.locator('[data-app-component]').count() === 3)
|
||
await page.locator('[data-app-component="gmv"]').click()
|
||
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('元')
|
||
// 隐藏(normal-user-apps N6):和可见性条件分开的一个开关,树上标「已隐藏」
|
||
const hide = insp.locator('[data-app-hidden-switch] input[type="checkbox"]')
|
||
check('属性面板有「隐藏这个组件」的开关', await hide.count() === 1 && (await insp.locator('[data-app-hidden-switch]').innerText()).includes('隐藏这个组件'))
|
||
await hide.check()
|
||
check('……打开后树上标「已隐藏」', await page.locator('[data-app-component="gmv"] [data-app-component-hidden]').count() === 1)
|
||
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)
|
||
await page.locator('[data-app-save]').click()
|
||
await waitFor(page, async () => state.patches.length === 1)
|
||
const body = state.patches[0]?.draft?.pages?.[0]?.body ?? []
|
||
check('保存的草稿里有改过的属性和新组件', JSON.stringify(body).includes('"unit":"元"') && JSON.stringify(body).includes('"type":"report"'),
|
||
JSON.stringify(body).slice(0, 200))
|
||
const gmv = body.flatMap((c) => [c, ...(c.children ?? [])]).find((c) => c.id === 'gmv')
|
||
check('……隐藏的组件 hidden 为真', gmv?.hidden === true, JSON.stringify(gmv ?? {}).slice(0, 120))
|
||
})
|
||
|
||
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))
|
||
const issues = page.locator('[data-app-issue]')
|
||
check('列出错误和提醒', await waitFor(page, async () => await issues.count() === 3))
|
||
await page.locator('[data-app-issue="app.binding_target_missing"] [data-app-issue-locate]').click()
|
||
await page.waitForURL('**/edit/pages')
|
||
// 页面一节要等组件目录到了才画页面树和属性面板:等它出来再判,别读到骨架屏
|
||
const shows = (sel) => page.locator(sel).waitFor({ timeout: 4000 }).then(async () => await page.locator(sel).count() === 1, () => false)
|
||
check('点「定位」:跳到页面一节、选中那个组件(点号加下标的 path)', await shows('[data-app-inspector="gmv"]'))
|
||
await page.goto(`${WEB}/apps/app-ae/edit/check`, { waitUntil: 'networkidle' })
|
||
await page.locator('[data-app-issue="app.expr_arith"] [data-app-issue-locate]').click()
|
||
await page.waitForURL('**/edit/pages')
|
||
check('JSON 指针的 path 也认得', await shows('[data-app-inspector="warn"]'))
|
||
})
|
||
|
||
await section('发布:固定版本升了版,必须为每一项选处置', async () => {
|
||
const { page, state } = await open({ path: '/apps/app-ae/edit/publish' })
|
||
await page.locator('[data-app-publish-submit]').click()
|
||
const dlg = page.locator('[role="dialog"]').filter({ hasText: '请为每一项选处置' })
|
||
check('服务端要处置时弹出选择', await dlg.waitFor({ timeout: 4000 }).then(() => true, () => false))
|
||
check('写清每种处置的后果', (await dlg.innerText()).includes('补算') && (await dlg.innerText()).includes('两条线')
|
||
&& (await dlg.innerText()).includes('断开'))
|
||
check('不给默认值:没选之前不能发布', await dlg.locator('[data-app-disposition-submit]').isDisabled())
|
||
await dlg.locator('[data-app-disposition="weekly"] [data-app-disposition-option="incomparable"]').check()
|
||
await dlg.locator('[data-app-disposition-submit]').click()
|
||
check('带着处置重发', await waitFor(page, async () => state.publishes.length === 2)
|
||
&& state.publishes[1].dispositions?.weekly === 'incomparable', JSON.stringify(state.publishes))
|
||
check('发布成功提示应用版本', await waitFor(page, async () => (await page.locator('body').innerText()).includes('已发布应用版本 v3')))
|
||
})
|
||
|
||
await section('预览:按草稿解析、不发起运行', async () => {
|
||
const { page, state } = await open({ path: '/apps/app-ae/edit/preview' })
|
||
check('按草稿取页面(draft=1)', await waitFor(page, async () => state.pages.some((q) => q.includes('draft=1'))), state.pages.join(','))
|
||
})
|
||
|
||
await section('助手:应用操作实时落到草稿上,look 和 ask 照 decode 显示', async () => {
|
||
const stream = [
|
||
{ op: 'look', id: 'L1', by: 'system', need: '固定的工作流版本', status: 'ok', elapsed_ms: 80,
|
||
objects: [{ kind: 'workflow_version', named: ['周销售 v7'], missing: [] }] },
|
||
{ op: 'app_add', parent: 'home', index: 0, component: { id: 'hello', type: 'notice', props: { text: '欢迎' } } },
|
||
{ op: 'ask', question: '报告放在指标下面还是单独一页?' },
|
||
]
|
||
const { page, state, errors } = await open({ stream })
|
||
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].spec?.pages?.[0]?.id === 'home' && state.streams[0].app_id === 'app-ae' && !!state.streams[0].bindings)
|
||
check('应用操作落到页面树上', await waitFor(page, async () => await page.locator('[data-app-component="hello"]').count() === 1))
|
||
const steps = page.locator('[data-app-assistant]')
|
||
check('系统查看固定版本那一行照 decode 的说法', await waitFor(page, async () => (await steps.innerText()).includes('按固定版本查看了 1 个工作流版本')))
|
||
check('ask 结束这一轮,问题照实显示', (await steps.innerText()).includes('助手需要你确认'))
|
||
check('助手改的草稿算未保存的修改', await page.locator('[data-app-save]').isEnabled())
|
||
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'))
|
||
// 收件记录(04 细案 A6):生成过密钥的钩子能展开,列出状态、原因和发起的实例,另写近 1 小时验签失败几次
|
||
await page.locator('[data-app-hook-receipts-toggle="old"]').click()
|
||
const receipts = page.locator('[data-app-hook-receipts="old"]')
|
||
check('收件记录按名字取', await waitFor(page, async () => state.receipts.includes('old')), JSON.stringify(state.receipts))
|
||
check('……被拒的标红、写原因,接受的带实例链接', await waitFor(page, async () => await receipts.locator('[data-recent-request]').count() === 2)
|
||
&& await receipts.locator('[data-recent-request="bad"] [data-recent-request-detail]').innerText().then((t) => t.includes('没有在等'))
|
||
&& await receipts.locator('[data-recent-request="ok"] a[href$="/instances/run-hook-1"]').count() === 1)
|
||
check('……近 1 小时验签失败的次数照实写', (await receipts.locator('[data-recent-requests-summary]').innerText()).includes('验签失败 2 次'))
|
||
// 伪造的列表在生成之后不变:paid 那一行仍是没生成过密钥
|
||
check('没生成过密钥的钩子没有收件记录可看', await paid.locator('[data-app-hook-state="none"]').count() === 1
|
||
&& await paid.locator('[data-app-hook-receipts-toggle]').count() === 0)
|
||
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)
|
||
})
|
||
|
||
// 授权三行长得一样,不写标题分不出哪一行是哪一档;搭建者读不到 /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))
|
||
// 挂了候选列表(list)的输入框角色是 combobox,不是 textbox
|
||
const input = use.locator('input').first()
|
||
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))
|
||
})
|
||
|
||
// 归档(api/apps.py 的 archive_app):后果照服务端的实际行为写;没有恢复的接口,界面上也不给「恢复」
|
||
await section('归档:确认框写明后果;归档后顶部写明已归档,保存和发布置灰', async () => {
|
||
const { page, state, errors } = await open({ path: '/apps/app-ae/edit/publish' })
|
||
const archive = page.locator('[data-app-archive]')
|
||
check('编辑器给「归档」', await archive.count() === 1)
|
||
await archive.click()
|
||
const dlg = page.getByRole('dialog')
|
||
await dlg.waitFor({ timeout: 3000 }).catch(() => {})
|
||
const text = (await dlg.innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
check('确认框写明后果:列表不再列出、入口不再接受新实例、钩子拒收、进行中的照常走完、不能再改和发布、没有恢复',
|
||
['应用列表里不再列出', '入口不再接受新实例', '入站钩子一律拒收', '进行中的实例照常走完', '不能再修改草稿、发布新版本', '没有恢复']
|
||
.every((k) => text.includes(k)), text)
|
||
await dlg.getByRole('button', { name: '取消' }).click()
|
||
await page.waitForTimeout(200)
|
||
check('……取消就不归档', state.archives === 0 && await page.locator('[data-app-archived]').count() === 0)
|
||
await archive.click()
|
||
await page.getByRole('dialog').getByRole('button', { name: '归档应用' }).click()
|
||
check('确认后发归档请求', await waitFor(page, async () => state.archives === 1))
|
||
const banner = page.locator('[data-app-archived]')
|
||
check('归档后顶部写明已归档', await waitFor(page, async () => await banner.count() === 1)
|
||
&& (await banner.innerText()).includes('已归档'))
|
||
check('……不再给「归档」', await page.locator('[data-app-archive]').count() === 0)
|
||
check('……保存和发布置灰', await page.locator('[data-app-save]').isDisabled() && await page.locator('[data-app-publish-submit]').isDisabled()
|
||
&& ((await page.locator('[data-app-publish-submit]').getAttribute('title')) ?? '').includes('已归档'))
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||
|
||
const again = await open({ path: '/apps/app-ae/edit/pages', status: 'archived' })
|
||
check('打开已归档的应用:一进来就写明已归档,没有「归档」,保存置灰', await again.page.locator('[data-app-archived]').count() === 1
|
||
&& await again.page.locator('[data-app-archive]').count() === 0 && await again.page.locator('[data-app-save]').isDisabled())
|
||
})
|
||
|
||
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
|
||
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
|
||
await browser.close()
|
||
process.exit(failed ? 1 : 0)
|