Files
AgentLab/scripts/check-app-editor.mjs
T

477 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 应用编辑(搭建面,/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 } } } },
{ 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 } } } },
{ name: 'notice', label: '提示', category: 'display', version: 1, children: false, bindings: [],
props: { type: 'object', properties: { text: { type: 'string', maxLength: 4000 }, tone: { enum: ['info', 'warn', 'error'] } } } },
{ 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 } = {}) {
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: 'active', published_version: 2, level: 'published',
needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: '2026-10-10T02:00:00Z', draft: clone(spec),
draft_bindings: { slots: {} }, created_by: '检查脚本', ...(backfill ? { backfill } : {}) }
const state = { app, patches: [], lints: 0, publishes: [], derives: [], creates: [], streams: [], pages: [],
hooks: clone(hooks), rotates: [], hookPatches: [], hookDeletes: [], backfills: [], turns: [], turnPatches: [], conversations: 0 }
await page.route(/\/api\//, (route) => {
const req = route.request()
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 === '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)
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 } = await open({ path: '/apps/new/edit' })
check('有派生说明', (await page.locator('[data-app-editor="new"]').innerText()).includes('确定性地派生'))
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 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)
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'")
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))
})
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')
check('点「定位」:跳到页面一节、选中那个组件(点号加下标的 path)', await page.locator('[data-app-inspector="gmv"]').count() === 1)
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 page.locator('[data-app-inspector="warn"]').count() === 1)
})
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'))
await page.locator('[data-app-hook-delete="old"]').click()
await page.locator('[role="dialog"]').getByRole('button', { name: '删除密钥' }).click()
check('删除密钥按名字发', await waitFor(page, async () => state.hookDeletes.length === 1) && state.hookDeletes[0] === 'old')
await page.locator('[data-app-nav="pages"]').click()
await page.locator('[data-app-nav="hooks"]').click()
check('离开这一节再回来:密钥不再显示', await page.locator('[data-app-hook-secret]').count() === 0)
})
await section('入口与表单:JSON 编辑、格式校验、检查的 path 定位到那一行', async () => {
const spec = { ...clone(SPEC),
entries: [{ id: 'apply', label: '发起', pin: 'weekly', form: { $ref: '#/forms/apply' }, map: { amount: 'form.amount' } }],
forms: { apply: { format: 'agentlab.form/1', type: 'object', required: ['amount'],
properties: { amount: { type: 'number', title: '金额', minimum: 0 }, note: { type: 'string', title: '说明' } } } } }
const lint = [{ level: 'error', code: 'app.entry_form_mismatch', message: '入口表单和固定版本的入口字段对不上',
path: 'forms.apply.properties.note' }]
const { page, state } = await open({ path: '/apps/app-ae/edit/forms', spec, lint })
const entries = page.locator('[data-app-json="entries"]')
await entries.locator('textarea').fill('[{"id": "apply",')
check('JSON 格式不对:照实说,不能应用', await entries.locator('[data-app-json-error]').count() === 1
&& await entries.locator('[data-app-json-apply]').isDisabled())
await entries.locator('textarea').fill(JSON.stringify([{ id: 'apply', label: '发起', pin: 'nope', form: { $ref: '#/forms/apply' }, map: {} }]))
check('形状检查:固定版本的别名不存在', (await entries.locator('[data-app-json-issues]').innerText()).includes('固定版本「nope」不存在'))
await entries.locator('[data-app-json-reset]').click()
const forms = page.locator('[data-app-json="forms"]')
const next = clone(spec.forms)
next.apply.properties.amount.maximum = 10000
await forms.locator('textarea').fill(JSON.stringify(next, null, 2))
await forms.locator('[data-app-json-apply]').click()
await page.locator('[data-app-save]').click()
check('应用到草稿后保存:表单的数据约束跟着存', await waitFor(page, async () => state.patches.length === 1)
&& state.patches[0].draft?.forms?.apply?.properties?.amount?.maximum === 10000, JSON.stringify(state.patches[0]?.draft?.forms))
await page.locator('[data-app-nav="check"]').click()
await page.locator('[data-app-issue="app.entry_form_mismatch"] [data-app-issue-locate]').click()
await page.waitForURL('**/edit/forms')
const located = await waitFor(page, async () => Number(await page.locator('[data-app-json="forms"]').getAttribute('data-app-json-located')) > 0)
check('检查的 path 落在表单上:跳到入口与表单,在 JSON 里定位到那个字段', located,
await page.locator('[data-app-json="forms"]').getAttribute('data-app-json-located'))
})
await section('按新口径补算:服务端给了能补算的视图,确认框写明会发起几次、跳过几次', async () => {
const spec = { ...clone(SPEC), pins: [{ ...SPEC.pins[0], version: 6 }], pages: [{ id: 'home', title: '本周概览', body: [
{ id: 'trend', type: 'metric', props: {}, bind: { view: 'wk', series: 'kpi.gmv', last: 8 } }] }] }
// 形状同服务端 apps/backfill.Plan.out:应用详情的 backfill 列出能补算的视图,GET …/backfill 是最新的计划
const planned = { view: 'wk', alias: 'weekly', source: 'formal', from_version: 5, to_version: 6, periods: 8, will_launch: 6, will_skip: 2 }
const { page, state } = await open({ spec, backfill: [planned], plan: planned })
const btn = page.locator('[data-app-backfill="wk"]')
check('视图来源下面给「按新口径补算历史」', await waitFor(page, async () => await btn.count() === 1))
check('次数照服务端算好的', await btn.getAttribute('data-app-backfill-max') === '6')
await btn.click()
const dlg = page.locator('[role="dialog"]')
await dlg.waitFor({ timeout: 4000 })
check('确认框写明会发起几次、跳过几次、从哪一版到哪一版', (await dlg.innerText()).includes('将发起 6 次正式运行')
&& (await dlg.innerText()).includes('2 次的输入已经补算过') && (await dlg.innerText()).includes('v5') && (await dlg.innerText()).includes('v6'))
await dlg.getByRole('button', { name: '发起 6 次运行' }).click()
check('按视图发起补算', await waitFor(page, async () => state.backfills.length === 1) && state.backfills[0] === 'wk', JSON.stringify(state.backfills))
check('提示发起了几次、跳过几次', await waitFor(page, async () => (await page.locator('body').innerText()).includes('已发起 7 次补算,跳过 1 次')))
await page.goto(`${WEB}/apps/app-ae`, { waitUntil: 'networkidle' })
check('应用页(管理)也给补算入口', await waitFor(page, async () => await page.locator('[data-app-backfill-panel] [data-app-backfill="wk"]').count() === 1))
})
await section('按新口径补算:老服务端没给计划时按版本列表推,写明最多几次', async () => {
const spec = { ...clone(SPEC), pins: [{ ...SPEC.pins[0], version: 6 }], pages: [{ id: 'home', title: '本周概览', body: [
{ id: 'trend', type: 'metric', props: {}, bind: { view: 'wk', series: 'kpi.gmv', last: 8 } }] }] }
const versions = [
{ version: 2, spec_hash: 'h2', level: 'published', pins: [{ ...SPEC.pins[0], version: 6 }], dispositions: { weekly: 'recompute' },
note: '', published_by: '检查脚本', published_at: '2026-10-12T02:00:00Z' },
{ version: 1, spec_hash: 'h1', level: 'published', pins: [{ ...SPEC.pins[0], version: 5 }], dispositions: {},
note: '', published_by: '检查脚本', published_at: '2026-10-01T02:00:00Z' },
]
const { page, state } = await open({ spec, versions })
const btn = page.locator('[data-app-backfill="wk"]')
check('视图来源下面给「按新口径补算历史」', await waitFor(page, async () => await btn.count() === 1))
check('上限按趋势取的最近几次', await btn.getAttribute('data-app-backfill-max') === '8')
await btn.click()
const dlg = page.locator('[role="dialog"]')
await dlg.waitFor({ timeout: 4000 })
check('确认框写明最多发起几次运行', (await dlg.innerText()).includes('最多发起 8 次正式运行'))
await dlg.getByRole('button', { name: '发起最多 8 次运行' }).click()
check('按视图发起补算', await waitFor(page, async () => state.backfills.length === 1))
})
await section('按新口径补算:处置不是按新口径重算时不给按钮', async () => {
const versions = [
{ version: 2, spec_hash: 'h2', level: 'published', pins: [{ ...SPEC.pins[0], version: 6 }], dispositions: { weekly: 'dual' },
note: '', published_by: null, published_at: '2026-10-12T02:00:00Z' },
{ version: 1, spec_hash: 'h1', level: 'published', pins: [{ ...SPEC.pins[0], version: 5 }], dispositions: {},
note: '', published_by: null, published_at: '2026-10-01T02:00:00Z' },
]
const { page } = await open({ versions })
await page.waitForTimeout(500)
check('两版并列:没有补算按钮', await page.locator('[data-app-backfill]').count() === 0)
})
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)