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

293 lines
19 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 结束这一轮。
//
// 不写库:/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: [],
}
async function open({ path = '/apps/app-ae/edit/pages', publish = 'disposition', stream = [] } = {}) {
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: '检查脚本' }
const state = { app, patches: [], lints: 0, publishes: [], derives: [], creates: [], streams: [], pages: [] }
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: [
{ 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' },
] })
}
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, { items: [] })
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\/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(' | '))
})
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)