// 应用编辑(搭建面,/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)