普通用户建应用(前端):新建应用的三个起点(说需求、场景模板、从已有的开始);蓝图页(对话加卡片、反问选项、关节逐组进度、试运行、发布摘要按你来定 / 助手可以修 / 要找管理员分栏、逐步打勾、接着做);问数据「做成应用」改走蓝图;问数据和画布的反问收尾;页面上直接改(选中、改字、隐藏、挪动、删除、加数据卡片、自动存草稿);文件控件;我的记录列表;个人应用的界面(我的应用、刷新与剩余次数、提交审核、审核待办卡);设置页的个人应用开关;四个新检查脚本并加进完整关口

This commit is contained in:
ThearchyHelios
2026-10-15 12:09:29 +08:00
parent 277982ead3
commit a702a5c14b
49 changed files with 6953 additions and 133 deletions
+7
View File
@@ -97,6 +97,11 @@ const ORDER = [
'inbox', // 待办箱:会签进度、超时横幅、动作确认的哈希卡、核实待办
'app-editor', // 应用编辑:固定版本、派生、页面树和属性面板、检查定位、发布时选处置、助手流
'capabilities', // 能力登记、端点、密钥掩码;设置页的身份、通知、审计
// 普通用户建应用(docs/design/normal-user-apps.md §4.8):全是伪造接口、不连真数据
'blueprint', // 新建应用的起点、蓝图卡片、反问、试运行、发布摘要
'personal', // 个人应用:建、刷新、提交审核
'records', // 记录列表:行级可见、更正历史、附件
'files', // 文件控件:上传、绑定、下载
'ui', // 各页面端到端,连真数据
]
// 各项的耗时(秒)。只决定排队顺序:长的先开跑,短的填空,结果不受影响。新加的检查不在表里就按 60 秒算;
@@ -109,6 +114,8 @@ const ESTIMATE = {
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
// 应用平台:还没实测,按同类页面估(全是伪造接口、不连真数据)
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30,
// 普通用户建应用:还没实测,按同类页面估
blueprint: 90, personal: 45, records: 30, files: 30,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+7
View File
@@ -296,6 +296,11 @@ await section('页面树和属性面板:目录驱动、绑定、可见性', as
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'")
@@ -309,6 +314,8 @@ await section('页面树和属性面板:目录驱动、绑定、可见性', as
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 () => {
+214 -5
View File
@@ -13,7 +13,11 @@
// - 实例页:状态(服务端给的说法:结果未知写「等待核实写入结果」)、印章、进度(结果未知的那一步不写「完成」)、
// 本实例的待办卡(就地处理);不是处理人的待办表单只画这一步的状态、不去取待办;动作状态(哈希前 8 位、环境;
// 结果未知的经过记一笔,不当失败原因);
// - 窄屏(390px)不出横向滚动。
// - 窄屏(390px)不出横向滚动;
// - 页面上直接改(normal-user-apps N6):有 manage 的人点「编辑」进编辑状态(地址记 edit=1),按草稿解析;点组件是选中、
// 不打开出处;小工具条改文字只给目录标了 x-text 的属性;隐藏、上移、删除都自动存草稿(PATCH 带整份草稿)后按草稿
// 重新解析;草稿里隐藏的组件画成半透明,服务端没画出来的列在页底能取消隐藏;「加一张数据卡片」按类别分组、候选 id
// 撞了换新的;顶部写「有未发布的改动」,「发布」走应用发布;应用出自蓝图时给「改取数」,链到蓝图页。
//
// 页面是真页面(走真的应用外壳),/api/apps/**、/api/tasks/**、指标链接口在浏览器层答掉,数据是这里虚构的
// (「门店周报」「报销审批」);其余 GET 放行到服务端(外壳要它),写请求一律拦下、记在探针里,不碰真实写接口。
@@ -232,6 +236,67 @@ const METRIC_CHAIN_TRACED = {
chain: [{ ...METRIC_CHAIN.chain[0], inputs: [TRACED_INPUT], untraced: [] }, { step: 'input', ...TRACED_INPUT }],
}
// ---------------------------------------------------------------- 页面上直接改(N6)的假数据
/** 门店周报的草稿:和 HOME 的组件一一对应;stores 被隐藏(服务端按草稿解析照样下发、带 hidden),old_note 也被隐藏
* 但服务端没画出来(编辑层列在页底) */
const DRAFT = {
format: 'agentlab.app/1', name: '门店周报', description: '每周一看各门店的销售概况',
pins: [{ alias: 'wk', workflow_id: 'wf_week', version: 7, graph_hash: '3fa2c1d0', level: 'governed', use: 'view' }],
entries: [], views: { wk: { source: { kind: 'latest_sealed', pin: 'wk', from: 'formal' } } }, forms: {}, slots: {}, task_ui: {},
pages: [{ id: 'home', title: '本周概览', body: [
{ id: 'stamp', type: 'stamp', props: {}, bind: { view: 'wk', run: 'status' } },
{ id: 'kpis', type: 'columns', props: { count: 2 }, children: [
{ id: 'gmv', type: 'metric', props: { label: '销售额', unit: '万元', conv: '万' }, bind: { view: 'wk', metric: 'kpi.gmv' } },
{ id: 'orders', type: 'metric', props: { label: '订单数' }, bind: { view: 'wk', metric: 'kpi.orders' } },
{ id: 'shops', type: 'metric', props: { label: '门店数', unit: '家' }, bind: { view: 'wk', cell: { node: 'q1', row: 0, col: 'n' } } },
{ id: 'aov', type: 'metric', props: { label: '客单价', unit: '元' }, bind: { view: 'wk', metric: 'kpi.aov' } },
] },
{ id: 'trend', type: 'series', props: { label: '销售额趋势' }, bind: { view: 'wk', series: 'kpi.gmv', last: 12 } },
{ id: 'stores', type: 'table', props: { title: '门店排行' }, hidden: true, bind: { view: 'wk', query: { node: 'q1' } } },
{ id: 'old_note', type: 'notice', props: { text: '旧的说明' }, hidden: true },
{ id: 'future', type: 'gauge3d', props: {} },
{ id: 'broken', type: 'metric', props: { label: '毛利率' }, bind: { view: 'wk', metric: 'kpi.margin' } },
] }, { id: 'detail', title: '门店明细', body: [] }],
}
const DETAIL = { ...APPS[0], blueprint_id: 'bp_week', draft: DRAFT, draft_bindings: { slots: {} },
published: { version: 3, pages: [{ id: 'home', title: '本周概览' }, { id: 'detail', title: '门店明细' }], entries: [] } }
/** 按草稿解析:stores 带 hidden 下发,old_note 不下发 */
const DRAFT_HOME = { ...HOME, tree: HOME.tree.map((n) => (n.id === 'stores' ? { ...n, hidden: true } : n)) }
const CATALOG = { version: 1, categories: { layout: '布局', display: '展示' }, components: [
{ name: 'columns', label: '分栏', category: 'layout', version: 1, children: true, bindings: [],
props: { type: 'object', properties: { count: { type: 'integer', title: '列数' } } } },
{ name: 'stamp', label: '印章', category: 'display', version: 1, children: false, bindings: ['run'],
props: { type: 'object', properties: { compact: { type: 'boolean', title: '紧凑显示' } } } },
{ name: 'metric', label: '指标块', category: 'display', version: 1, children: false, bindings: ['metric', 'cell'],
props: { type: 'object', properties: {
label: { type: 'string', maxLength: 200, title: '名称', 'x-text': true },
unit: { type: 'string', maxLength: 200, title: '单位', 'x-text': true },
conv: { type: 'string', maxLength: 200, title: '换算' } } } },
{ name: 'series', label: '趋势', category: 'display', version: 1, children: false, bindings: ['series'],
props: { type: 'object', properties: { label: { type: 'string', maxLength: 200, title: '名称', 'x-text': true } } } },
{ name: 'table', label: '表格', category: 'display', version: 1, children: false, bindings: ['query'],
props: { type: 'object', properties: { title: { type: 'string', maxLength: 200, title: '标题', 'x-text': true } } } },
{ name: 'notice', label: '提示', category: 'display', version: 1, children: false, bindings: [],
props: { type: 'object', properties: { text: { type: 'string', maxLength: 4000, title: '提示文字', 'x-text': true } } } },
] }
/** 「加一张数据卡片」的候选:口径卡指标两个(其中一个 id 和页面上的 gmv 撞了)、趋势一个 */
const CANDIDATES = { items: [
{ kind: 'metric', label: '退款率', group: '口径卡「周销售」', component: { id: 'refund', type: 'metric', props: { label: '退款率' },
bind: { view: 'wk', metric: 'kpi.refund' } } },
{ kind: 'metric', label: '销售额', group: '口径卡「周销售」', component: { id: 'gmv', type: 'metric', props: { label: '销售额' },
bind: { view: 'wk', metric: 'kpi.gmv' } } },
{ kind: 'series', label: '订单数趋势', group: '口径卡「周销售」', component: { id: 'orders_trend', type: 'series',
props: { label: '订单数趋势' }, bind: { view: 'wk', series: 'kpi.orders', last: 12 } } },
] }
/** 草稿里按 id 找组件 */
const findIn = (spec, id) => {
const walk = (list) => { for (const c of list ?? []) { if (c.id === id) return c; const hit = walk(c.children); if (hit) return hit } return null }
for (const pg of spec?.pages ?? []) { const hit = walk(pg.body); if (hit) return hit }
return null
}
// ---------------------------------------------------------------- 浏览器
const browser = await chromium.launch({ executablePath: CHROME })
@@ -262,10 +327,32 @@ async function open({ width = 1280, height = 900, state }) {
if (p.startsWith('/api/tasks/') && p.endsWith('/respond')) {
return route.fulfill({ json: { task: { ...TASK, status: 'resolved', can_respond: false }, resolved: true } })
}
// 页面上直接改:存草稿回详情(带上存进去的草稿),发布回新版本
if (state.detail && r.method() === 'PATCH' && p === '/api/apps/app_weekly') {
const body = JSON.parse(r.postData() || '{}')
state.patches = [...(state.patches ?? []), body]
state.detail = { ...state.detail, ...(body.draft ? { draft: body.draft } : {}) }
return route.fulfill({ json: state.detail })
}
if (state.detail && p === '/api/apps/app_weekly/publish') {
return route.fulfill({ json: { ok: true, version: 4, level: 'governed', spec_hash: 'h4', issues: [] } })
}
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
}
if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
if (p === '/api/apps') return route.fulfill({ json: { items: APPS } })
// 应用详情:默认不给草稿(不进编辑状态);页面上直接改的段落给 manage 的详情
if (p === '/api/apps/app_weekly') return route.fulfill({ json: state.detail ?? { ...APPS[0], published: DETAIL.published } })
if (p === '/api/apps/app_claim') return route.fulfill({ json: { ...APPS[1], published: { version: 2, pages: [{ id: 'home', title: '报销' }], entries: [] } } })
if (p === '/api/apps/catalog') return route.fulfill({ json: CATALOG })
if (p === '/api/apps/app_weekly/candidates') {
state.candidateHits = (state.candidateHits ?? 0) + 1
return route.fulfill({ json: CANDIDATES })
}
if (p === '/api/apps/app_weekly/pages/home' && url.searchParams.get('draft') === '1') {
state.draftHits = (state.draftHits ?? 0) + 1
return route.fulfill({ json: state.draftHome ?? DRAFT_HOME })
}
if (p === '/api/apps/app_weekly/pages/home') {
state.pageHits = (state.pageHits ?? 0) + 1
return route.fulfill({ json: state.home ?? HOME })
@@ -312,10 +399,12 @@ await section('首页:三个页签、关登录说明、需升级', async () =>
check('能管理的应用有「编辑」,只能用的没有',
(await page.locator('[data-app="app_weekly"] a[href$="/edit"]').count()) === 1
&& (await page.locator('[data-app="app_claim"] a[href$="/edit"]').count()) === 0)
// 新建应用是新建向导的起点:搭建者及以上在页头看得到(关登录时所有人等同管理员),点进去先选工作流和版本
// 新建应用(normal-user-apps §4.1):搭建者及以上在页头看得到(关登录时所有人等同管理员),点进去是三个起点的新建页,
// 建的是共享蓝图。起点和蓝图页本身在 check-blueprint 里查
const create = page.locator('header [data-app-create]')
check('页头有「新建应用」,链到新建页(不带工作流、版本:先选)', await create.count() === 1
&& (await create.innerText()).includes('新建应用') && await create.getAttribute('href') === '/apps/new/edit',
check('页头有「新建应用」,链到新建页(三个起点)', await create.count() === 1
&& (await create.innerText()).includes('新建应用') && await create.getAttribute('href') === '/apps/create'
&& await create.getAttribute('data-app-create') === 'shared',
`${await create.count()} | ${await create.getAttribute('href').catch(() => '')}`)
await page.getByRole('tab', { name: '我发起的' }).click()
await page.locator('[data-mine] [data-instance]').first().waitFor({ timeout: 5000 })
@@ -323,7 +412,7 @@ await section('首页:三个页签、关登录说明、需升级', async () =>
(await page.locator('[data-mine] a[href="/apps/app_claim/i/run_claim_1"]').count()) === 1)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
// 开了登录的使用者:没有「新建应用」(搭建是搭建者及以上的事)
// 开了登录的使用者、管理员没开个人应用:没有「新建应用」(开了的情形在 check-blueprint 里查)
const user = { writes: [], me: { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true } }
const u = await open({ state: user })
await u.page.goto(`${WEB}/apps?tab=apps`)
@@ -513,6 +602,126 @@ await section('实例页:状态、进度、待办卡、动作状态', async ()
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 页面上直接改(N6)
await section('应用页:编辑状态、改文字、隐藏、上下挪、删除、加一张数据卡片、发布', async () => {
const state = { writes: [], detail: structuredClone(DETAIL) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_weekly`)
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
const start = page.locator('[data-page-edit-start]')
check('有 manage 的人有「编辑」', await start.waitFor({ timeout: 5000 }).then(() => true, () => false)
&& (await start.innerText()).includes('编辑'))
await start.click()
await page.locator('[data-page-edit-layer]').waitFor({ timeout: 8000 })
check('编辑状态记在地址上(edit=1)', /[?&]edit=1/.test(page.url()), page.url())
check('按草稿解析(draft=1)', (state.draftHits ?? 0) >= 1, `${state.draftHits ?? 0} 次`)
check('顶部的编辑条:写「编辑中」,给「发布」', /编辑中/.test(await page.locator('[data-page-edit-bar]').innerText())
&& await page.locator('[data-page-edit-publish]').count() === 1)
const changeData = page.locator('[data-page-edit-change-data]')
check('应用出自蓝图:给「改取数」,链到蓝图页', await changeData.count() === 1
&& await changeData.getAttribute('href') === '/blueprints/bp_week', await changeData.getAttribute('href').catch(() => ''))
// 隐藏:服务端带 hidden 下发的画成半透明、标「已隐藏」;没下发的列在页底
const stores = page.locator('[data-node="stores"]')
check('隐藏的组件画成半透明', await stores.getAttribute('data-node-hidden') === ''
&& /opacity-45/.test(await stores.getAttribute('class') ?? ''), await stores.getAttribute('class').catch(() => ''))
check('……旁边标「已隐藏」', await page.locator('[data-hidden-tag]').count() >= 1)
const hiddenList = page.locator('[data-page-edit-hidden-list]')
check('服务端没画出来的隐藏组件列在页底,能取消隐藏', await hiddenList.getAttribute('data-page-edit-hidden-list') === '1'
&& await hiddenList.locator('[data-page-edit-unhide="old_note"]').count() === 1)
// 点组件是选中,不打开出处
await page.locator('[data-node="gmv"] button[data-evidence="metric"]').click()
const toolbar = page.locator('[data-page-edit-toolbar="gmv"]')
check('点组件出小工具条(选中)', await toolbar.waitFor({ timeout: 3000 }).then(() => true, () => false))
check('编辑状态下点数字不打开出处', await page.locator('[data-evidence-panel]').count() === 0 && !state.chainHits)
const ops = await toolbar.locator('[data-page-edit-op]').evaluateAll((els) => els.map((e) => e.getAttribute('data-page-edit-op')))
check('小工具条五样:改文字、隐藏、上移、下移、删除', ops.join(',') === 'text,hide,up,down,remove', ops.join(','))
// 改文字:只给标了 x-text 的属性(名称、单位),不给换算
await toolbar.locator('[data-page-edit-op="text"]').click()
const dlg = page.getByRole('dialog')
await dlg.locator('[data-page-edit-text]').waitFor({ timeout: 3000 })
const fields = await dlg.locator('[data-page-edit-text-field]').evaluateAll((els) => els.map((e) => e.getAttribute('data-page-edit-text-field')))
check('改文字只列给人看的文字(名称、单位),不列换算', fields.join(',') === 'label,unit', fields.join(','))
await dlg.locator('[data-page-edit-text-field="label"] input').fill('本周销售额')
const before = state.draftHits ?? 0
await dlg.locator('[data-page-edit-text-save]').click()
await page.waitForFunction(() => document.querySelector('[data-page-edit-save]')?.getAttribute('data-page-edit-save') === 'saved', null, { timeout: 5000 }).catch(() => {})
check('改文字自动存草稿(PATCH 带整份草稿)', findIn(state.patches?.at(-1)?.draft, 'gmv')?.props?.label === '本周销售额',
JSON.stringify(findIn(state.patches?.at(-1)?.draft, 'gmv')?.props ?? {}))
check('……存完按草稿重新解析', (state.draftHits ?? 0) > before, `${before} → ${state.draftHits ?? 0}`)
check('……顶部写「有未发布的改动」', (await page.locator('[data-page-edit-status]').innerText()).includes('有未发布的改动'))
// 隐藏
await page.locator('[data-node="gmv"]').click()
await page.locator('[data-page-edit-toolbar="gmv"] [data-page-edit-op="hide"]').click()
await page.waitForTimeout(400)
check('隐藏:草稿里这个组件 hidden 为真', findIn(state.patches?.at(-1)?.draft, 'gmv')?.hidden === true)
// 同一层里下移一格
await page.locator('[data-node="orders"]').click()
await page.locator('[data-page-edit-toolbar="orders"] [data-page-edit-op="down"]').click()
await page.waitForTimeout(400)
const order = findIn(state.patches?.at(-1)?.draft, 'kpis')?.children?.map((c) => c.id).join(',')
check('下移:同一层里和下一个换位置', order === 'gmv,shops,orders,aov', order)
check('第一个组件不能上移', await page.locator('[data-node="stamp"]').click().then(async () =>
page.locator('[data-page-edit-toolbar="stamp"] [data-page-edit-op="up"]').isDisabled()))
// 删除要确认
await page.locator('[data-node="broken"]').click()
await page.locator('[data-page-edit-toolbar="broken"] [data-page-edit-op="remove"]').click()
const confirm = page.locator('[role="dialog"], [role="alertdialog"]').filter({ hasText: '删除「毛利率」?' }).first()
check('删除先确认、写明后果', await confirm.waitFor({ timeout: 3000 }).then(() => true, () => false))
await confirm.getByRole('button', { name: '删除组件' }).click()
await page.waitForTimeout(400)
check('删除:草稿里没有这个组件了', !!state.patches?.at(-1)?.draft && findIn(state.patches.at(-1).draft, 'broken') === null)
// 取消隐藏页底列出的
await page.locator('[data-page-edit-unhide="old_note"]').click()
await page.waitForTimeout(400)
check('页底的「取消隐藏」:草稿里去掉 hidden', findIn(state.patches?.at(-1)?.draft, 'old_note') && !('hidden' in findIn(state.patches.at(-1).draft, 'old_note')))
// 加一张数据卡片:候选按类别分组,id 撞了换新的
await page.locator('[data-page-edit-add-card]').click()
const cand = page.getByRole('dialog')
await cand.locator('[data-page-edit-candidates]').waitFor({ timeout: 3000 })
const groups = await cand.locator('[data-candidate-group]').evaluateAll((els) => els.map((e) => e.getAttribute('data-candidate-group')))
check('候选按类别分组(口径卡指标、趋势)', groups.join(',') === 'metric,series', groups.join(','))
check('……组名写中文', (await cand.locator('[data-candidate-group="metric"] h3').innerText()) === '口径卡指标')
await cand.locator('[data-candidate="gmv"] [data-candidate-add]').click()
await page.waitForTimeout(400)
const home = state.patches?.at(-1)?.draft?.pages?.[0]?.body ?? []
check('加上的卡片落在页尾,id 和已有的撞了换成新的', home.at(-1)?.id === 'gmv_2' && home.at(-1)?.bind?.metric === 'kpi.gmv',
JSON.stringify(home.at(-1) ?? {}))
// 发布
const writesBefore = state.writes.length
await page.locator('[data-page-edit-publish]').click()
const pub = page.locator('[role="dialog"], [role="alertdialog"]').filter({ hasText: '发布这些改动?' }).first()
await pub.waitFor({ timeout: 3000 })
await pub.getByRole('button', { name: '发布', exact: true }).click()
await page.waitForTimeout(500)
check('「发布」走应用发布', state.writes.slice(writesBefore).some((w) => w.method === 'POST' && w.path === '/api/apps/app_weekly/publish'))
check('……发布后不再写「有未发布的改动」', !(await page.locator('[data-page-edit-status]').innerText().catch(() => '')).includes('有未发布的改动'))
await page.locator('[data-page-edit-done]').click()
await page.locator('[data-page-edit-start]').waitFor({ timeout: 5000 })
check('完成编辑:回到使用者看到的样子(地址上去掉 edit)', !/[?&]edit=1/.test(page.url()), page.url())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('应用页:只能用的人没有「编辑」,编辑地址也不进编辑状态', async () => {
const state = { writes: [] }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_claim?edit=1`)
await page.locator('[data-view-renderer]').waitFor({ timeout: 15000 })
await page.waitForTimeout(500)
check('使用者没有「编辑」', await page.locator('[data-page-edit-start]').count() === 0)
check('带着 edit=1 也不进编辑状态', await page.locator('[data-page-edit-layer]').count() === 0)
})
await section('窄屏:390px 不出横向滚动', async () => {
const state = { writes: [] }
const { page } = await open({ width: 390, height: 844, state })
+661
View File
@@ -0,0 +1,661 @@
// 普通用户建应用的创建链路(docs/design/normal-user-apps.md 第四部分 §4.1–§4.4、N11):/apps/create、/blueprints/:id、
// /blueprints/:id/trial、发布摘要。
//
// 守的是这些「看着没坏、其实不对」的地方:
// - 新建应用三个起点:说说你要什么(例句填进输入框;开始后建对话起点的蓝图、这句话作为第一轮发给蓝图助手)、从场景模板开始
// (模板卡写适用场景、会产出什么、要准备什么)、从已有的开始(最近的问数据回答;已发布的工作流只给搭建者)。身份决定范围:
// 搭建者建共享蓝图;使用者在管理员开了个人应用时建个人蓝图,个人应用用不了的模板不给选;开关没开照实说没开放;
// 应用首页的「新建应用」链到 /apps/create;
// - 卡片:卡头标状态(缺按服务端检查、待确认、已确认),对到数据的项写表、中文名、粒度、业务日期、为什么;点开改发 bp_set
// (纯值),确认只改状态,加一项发 bp_add(纯值);缺项时「用真数试一次」置灰并写清缺什么;
// - 反问:助手的 bp_ask 画成可点的选项,「这一轮在等你回答」常显,输入框提示回答;点选项就是把它作为下一轮发出去(回答记在
// 问题上、这一轮落进蓝图对话都是服务端做的,前端不另记);
// - 关节:只列生成关节,按卡片填的汇总一句;「让助手填」走 fill-joints-stream,逐组写进度,组之间并行;
// - 试运行:横幅「试运行:数据是真的,但这不是正式结果」、这次的输入、「换一期再试」;流程类右侧列待办,按钮写「以试运行
// 身份…」;出错时写清为什么,给「交给助手修」「回到卡片改」;
// - 发布摘要:每一步一句白话;问题分三栏(你来定、助手可以修、要找管理员),有 error 不能确认;确认按钮写清结果
// (「发布并每周一 09:00 自动出数」);执行时逐步打勾;停下时写停在哪一步、为什么,「接着做」;受管级只给管理员;
// - 个人蓝图试过之后「存成我的应用」;
// - 窄屏(390px)不出横向滚动。
//
// 页面是真页面(走真的应用外壳),蓝图、模板、发布单、蓝图助手的流、对话、待办在浏览器层答掉,数据是这里虚构的(「门店周报」);
// 其余 GET 放行到服务端(外壳要它),别的写请求一律拦下、记在探针里。
//
// node scripts/check-blueprint.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-blueprint.mjs
// CHECK_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'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${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 ago = (ms) => new Date(Date.now() - ms).toISOString()
const clone = (x) => JSON.parse(JSON.stringify(x))
const sse = (ops) => ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('')
const until = async (cond, ms = 5000) => {
const end = Date.now() + ms
while (!cond() && Date.now() < end) await new Promise((r) => setTimeout(r, 50))
return cond()
}
// ---------------------------------------------------------------- 假数据
const ADMIN_OFF = { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false }
const BUILDER = { mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true }
const USER_ON = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: [], verified: true,
user_can_chat: true, user_personal_apps: true }
const USER_OFF = { ...USER_ON, user_personal_apps: false }
const TEMPLATES = [
{ id: 'periodic_board', version: 1, label: '周期看板', description: '每天、每周或每月看一组固定的数', applies_when: '', tags: [],
personal: '全部可用', produces: '一页看板:印章、指标、趋势、明细',
cards: [{ card: 'purpose', note: '必填' }, { card: 'data', note: '必填:要看的数' }, { card: 'cadence', note: '必填:多久出一次数' },
{ card: 'access', note: '谁能用' }] },
{ id: 'approval', version: 1, label: '审批流程', description: '有人发起、有人审批,可以按金额分级', applies_when: '', tags: [],
personal: null,
cards: [{ card: 'purpose', note: '必填' }, { card: 'fields', note: '必填:发起时填什么' }, { card: 'people', note: '必填:谁审批' },
{ card: 'access', note: '' }] },
{ id: 'freeform', version: 1, label: '自由形', description: '从已有的运行或工作流导入', applies_when: '', tags: [], personal: '只读部分',
cards: [{ card: 'purpose', note: '' }, { card: 'cadence', note: '' }, { card: 'access', note: '' }] },
]
const item = (value, source = 'assistant', status, why = null) =>
({ value, source, status: status ?? (value == null ? 'missing' : source === 'user' ? 'confirmed' : 'proposed'), why })
const SPEC = {
format: 'agentlab.blueprint/1',
purpose: item('每周一看各门店上周的销售额和退款率', 'user'),
template: { id: 'periodic_board', version: 1 },
data: [
{ id: 'gmv', label: item('销售额', 'user'), unit: item('元'),
table: item({ source: 'shop', table: 'orders', label: '订单' }, 'catalog', 'proposed',
'数据目录里「订单」一行一单,业务日期是支付日,金额列是实收'),
measure: item('按实收金额求和'), date: item({ column: 'paid_at', rule: '按支付日' }, 'catalog', 'confirmed'),
filter: item(null), grain: item('一行一单', 'catalog', 'confirmed'), reuse: null },
{ id: 'refund', label: item('退款率', 'user'), unit: item(null), table: item(null), measure: item(null), date: item(null),
filter: item(null), grain: item(null), reuse: null },
],
cadence: item({ preset: 'weekly', weekday: 1, time: '09:00', tz: 'Asia/Shanghai', period: 'last_iso_week' }, 'template'),
people: [], fields: [], writeback: null,
access: { use: [{ kind: 'group', name: '门店' }], observe: [], manage: [] },
asks: [],
}
const UNMAPPED = { code: 'bp.data_unmapped', level: 'error', path: '/data/1/table', message: '「退款率」还没对到表或已发布的口径卡',
plain: { title: '「退款率」还没对到表', why: '不知道从哪张表取数,试运行出不了数', who: 'you', action: '在卡片上写明对到哪张表,或者让助手去对' } }
const BP = {
id: 'bp_week', name: '门店周报', owner: null, scope: 'shared', status: 'drafting', spec: SPEC,
template_id: 'periodic_board', template_version: 1, source: { kind: 'conversation' }, conversation_id: 'conv_bp',
workflow_id: null, app_id: null, last_trial_run_id: null, graph_hash: null,
joints: {
'start.fields': { status: 'filled', by: 'template' },
sql_gmv: { status: 'filled', by: 'assistant', label: '销售额的取数 SQL', kind: 'sql' },
sql_refund: { status: 'pending', by: 'template', label: '退款率的取数 SQL', kind: 'sql', note: '这一项助手还没填上:可以请助手填,或者换个说法再试' },
},
copied_from: null, issues: [UNMAPPED], created_at: ago(600_000), updated_at: ago(60_000),
}
/** 补齐之后(可以试运行;试过之后服务端按发布查,待确认的项挡发布不挡试运行) */
const READY = (extra = {}) => ({ ...clone(BP), issues: [], joints: { ...BP.joints, sql_refund: { status: 'filled', by: 'assistant' } }, ...extra })
const ASK = { id: 'q1', question: '销售额按下单日还是支付日算?', options: ['按下单日', '按支付日'], path: '/data/0/date', answer: null }
const STAMP = { as_of: ago(3600_000), as_of_text: '截至 2026-10-13 09:00;统计期 上一 ISO 周 2026-W41', pin: '门店周报(试运行)',
graph_hash: 'ab12cd34', calibers: [], tier: 'formal', tier_text: '完整出具', from: '试运行', sealed: true,
notes: ['数据是真的;这不是正式结果,发布后才会按定时任务出数'] }
const PREVIEW = {
format: 'agentlab.view/1',
app: { id: '', name: '门店周报', version: 0, level: null, needs_upgrade: false },
page: { id: 'home', title: '本周概览' }, pages: [{ id: 'home', title: '本周概览' }],
sources: { wk: { kind: 'trial', state: 'ok', run_id: 'run_trial', stamp: STAMP } },
tree: [
{ id: 'stamp', type: 'stamp', props: {}, data: { view: 'wk', state: 'ok', stamp: STAMP } },
{ id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { state: 'ok', text: '45.68 万元', name: '销售额' } },
],
forms: {}, problems: [],
}
const PREVIEW_FAILED = {
...PREVIEW, sources: { wk: { kind: 'trial', state: 'error', run_id: 'run_trial', stamp: null,
message: '「取数」这一步查询失败:表 refunds 里没有列 refund_amt' } },
tree: [{ id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { view: 'wk', state: 'error' } }],
}
const TRIAL_TASK = {
id: 'task_trial', approval_id: null, run_id: 'run_trial', node_id: 'review', app_id: null, app_name: null, workflow_name: null,
kind: 'approve', title: '财务审批', form: null, policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0,
payload: { kind: 'human_node', message: '张三报销 1,200 元' }, assignments: [], can_respond: true, outcome: null,
resolved_by: null, resolved_at: null, created_at: ago(60_000),
}
const STEP_KINDS = ['save_version', 'preflight', 'publish_workflow', 'create_schedule', 'create_app', 'publish_app', 'enable_schedule',
'grant', 'first_run']
const steps = (done, failedAt, message) => STEP_KINDS.map((kind, i) => ({
kind, status: i < done ? 'done' : kind === failedAt ? 'failed' : 'pending',
result: kind === 'create_app' && i < done ? { app_id: 'app_week' } : null,
...(kind === failedAt ? { error_code: 'plan.grant', message } : {}),
}))
const SUMMARY = [
{ step: 'save_version', text: '保存这一版工作流(和最近一次试运行是同一张图)' },
{ step: 'publish_workflow', text: '发布「门店周报」:此后每周一 09:00 按这一版自动出数' },
{ step: 'grant', text: '门店组的人可以使用' },
]
const PLAN_BLOCKED = {
id: 'plan_1', blueprint_id: 'bp_week', level: 'published', graph_hash: 'ab12cd34', status: 'planned', steps: steps(0),
summary: SUMMARY, options: { first_run: true },
problems: [
{ code: 'bp.unconfirmed', level: 'error', path: '/data/0/table', message: '「看哪些数·第 1 项·对到的表」还没确认',
plain: { title: '「销售额」对到的表还没确认', why: '这是助手按数据目录对的', who: 'you', action: '在卡片上点「确认」' } },
{ code: 'app.upgrade_policy', level: 'error', path: 'pins[0]', alias: 'weekly', message: '口径升版必须处置',
plain: { title: '口径变了,以前的数怎么处理还没选', why: '', who: 'you', action: '选一种处置' } },
{ code: 'app.view_unbound', level: 'warning', path: 'views.wk', message: '视图没有绑定定时任务',
plain: { title: '有一块看板没有对应的数', why: '', who: 'assistant', action: '交给助手按卡片改' } },
{ code: 'governed.metric_untraced', level: 'warning', path: null, message: '指标没有静态追到原始数据',
plain: { title: '有的指标追不到原始数据', why: '受管级要求', who: 'admin', action: '请管理员处理,或改选已发布级' } },
],
}
const PLAN_OK = { ...PLAN_BLOCKED, id: 'plan_2', problems: [] }
// ---------------------------------------------------------------- 浏览器
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一页。state:me(身份)、bp(蓝图)、plan(发布单剧本)、preview、streams(按指令挑的蓝图助手剧本)。
* 写请求记进 state.writes;蓝图助手、填关节的请求体记进 state.streams / state.fills
*/
async function open({ width = 1280, height = 900, state }) {
const ctx = await browser.newContext({ viewport: { width, height } })
opened.add(ctx)
const page = await ctx.newPage()
page.errors = []
page.on('pageerror', (e) => page.errors.push(e.message))
state.writes ??= []
state.chats ??= []
state.fills ??= []
await page.route((url) => url.pathname.startsWith('/api/'), async (route) => {
const r = route.request()
const url = new URL(r.url())
const p = url.pathname
const method = r.method()
const body = () => { try { return JSON.parse(r.postData() || '{}') } catch { return {} } }
const json = (data, status = 200) => route.fulfill({ status, json: data })
if (p === '/api/auth/me') return json(state.me ?? ADMIN_OFF)
if (p === '/api/templates') return json({ items: TEMPLATES })
if (p === '/api/workflows' && method === 'GET') {
return json([{ id: 'wf_sales', name: '销售日报', description: '', graph: { nodes: [], edges: [] }, tags: [], version: 4,
is_template: false, status: 'published', published_version: 3 }])
}
if (p === '/api/conversations' && method === 'GET') {
return json([
{ id: 'c_chat', title: '上周销售', kind: 'chat', archived: false, turn_count: 2, last_question: '上周各门店销售额是多少',
last_status: 'done', last_run_id: 'run_chat_1', last_active_at: ago(3600_000) },
{ id: 'c_fail', title: '失败的一问', kind: 'chat', archived: false, turn_count: 1, last_question: '跑挂了', last_status: 'error',
last_run_id: 'run_bad', last_active_at: ago(7200_000) },
])
}
// 蓝图对话的历史按蓝图取(权限跟着蓝图走,没开问数据的使用者也读得到)
if (/^\/api\/blueprints\/[^/]+\/conversation$/.test(p) && method === 'GET') {
return json({ id: 'conv_bp', title: '蓝图', kind: 'blueprint', archived: false, turn_count: 0, last_question: '', turns: state.past ?? [] })
}
if (p === '/api/conversations/conv_bp' && method === 'GET') {
return json({ id: 'conv_bp', title: '蓝图', kind: 'blueprint', archived: false, turn_count: 0, last_question: '', turns: state.past ?? [] })
}
if (/^\/api\/conversations\/conv_bp\/turns/.test(p)) {
state.writes.push({ method, path: p, body: r.postData() })
return json({ id: 't_rec', seq: 1, question: body().question ?? '', answer: '', explanation: '', status: 'done', error: '' })
}
if (p === '/api/copilot/blueprint-stream' && method === 'POST') {
const b = body()
state.chats.push(b)
const script = (state.streams ?? []).find((s) => b.instruction.includes(s.when)) ?? { ops: [{ op: 'done', explanation: '好' }] }
if (script.blueprint) state.bp = script.blueprint
return route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse(script.ops) })
}
if (p === '/api/copilot/fill-joints-stream' && method === 'POST') {
state.fills.push(body())
const filled = READY()
state.bp = filled
// 服务端 api/copilot_blueprint.fill_joints_stream 的形状:每组 group started → … → filled / failed,最后 final
return route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse([
{ op: 'model', model: 'fake-model' },
{ op: 'group', group: 'g1', joints: ['sql_refund'], status: 'started' },
{ op: 'group', group: 'g2', joints: ['metric_refund'], status: 'started' },
{ op: 'look', group: 'g1', id: 'L1', by: 'system', need: '这一组关节要用的表', status: 'ok', elapsed_ms: 10, objects: [] },
{ op: 'plan', group: 'g2', summary: '按口径卡算退款率' },
{ op: 'fill_joint', group: 'g1', joint: 'sql_refund', value: 'SELECT 1' },
{ op: 'group', group: 'g1', status: 'filled' },
{ op: 'group', group: 'g2', status: 'failed', message: '指标的算法用了口径卡里没有的取数' },
{ op: 'final', blueprint: filled, groups: { g1: { status: 'filled' }, g2: { status: 'failed' } },
problems: [{ code: 'tpl.joint_unfilled', level: 'error', message: '「退款率的算法」还没填',
plain: { title: '「退款率的算法」助手没填上', why: '', who: 'assistant', action: '换个说法再让助手填' } }] },
]) })
}
if (p === '/api/blueprints' && method === 'POST') {
const b = body()
state.creates = [...(state.creates ?? []), b]
const created = { ...clone(BP), id: 'bp_new', name: '新应用', scope: b.scope, spec: { ...clone(SPEC), data: [], template: null },
issues: [], joints: {}, source: b.source }
state.bp = created
return json(created, 201)
}
if (p === '/api/blueprints' && method === 'GET') {
return json({ items: [{ ...clone(BP), status: 'drafting' }, { ...clone(BP), id: 'bp_done', name: '已发布的', status: 'published' }] })
}
let m = p.match(/^\/api\/blueprints\/([^/]+)$/)
if (m && method === 'GET') return json(state.bp ?? BP)
if (m && method === 'PATCH') {
const b = body()
state.patches = [...(state.patches ?? []), b]
if (state.patchReject) return json({ detail: state.patchReject, code: 'bp.refused' }, 422)
return json(state.bp ?? BP)
}
m = p.match(/^\/api\/blueprints\/([^/]+)\/(instantiate|trial|preview|publish-plan|save-personal)$/)
if (m) {
const what = m[2]
if (what === 'trial') {
state.trials = [...(state.trials ?? []), body()]
state.bp = { ...(state.bp ?? BP), status: 'trialed', last_trial_run_id: 'run_trial' }
return json({ run_id: 'run_trial', blueprint: state.bp, inputs: { period: '2026-W41' } })
}
if (what === 'preview') return json(state.preview ?? PREVIEW)
if (what === 'instantiate') {
return json({ blueprint: state.bp ?? BP, app_spec: { format: 'agentlab.app/1', name: '门店周报', pins: [], slots: {}, entries: [],
forms: {}, task_ui: {}, views: {}, pages: [], hooks: [], notify: [] }, problems: [] })
}
if (what === 'publish-plan') {
state.planBodies = [...(state.planBodies ?? []), body()]
return json(state.plans?.shift() ?? PLAN_OK)
}
state.writes.push({ method, path: p })
return json({ app_id: 'app_mine', blueprint: { ...(state.bp ?? BP), status: 'personal', app_id: 'app_mine' } })
}
m = p.match(/^\/api\/publish-plans\/([^/]+)(?:\/(run|resume))?$/)
if (m) {
if (m[2]) state.planActs = [...(state.planActs ?? []), m[2]]
const next = (m[2] ? state.planRun?.[m[2]] : state.planPoll?.shift()) ?? state.planLast
if (next) state.planLast = next
return json(next ?? PLAN_OK)
}
if (p === '/api/tasks' && method === 'GET') return json({ items: url.searchParams.get('run_id') === 'run_trial' ? state.tasks ?? [] : [] })
if (p === '/api/tasks/count') return json({ mine: 0, apps: 0, other: 0 })
if (p.startsWith('/api/tasks/') && p.endsWith('/respond')) {
state.responds = [...(state.responds ?? []), body()]
state.tasks = []
return json({ task: { ...TRIAL_TASK, status: 'resolved', can_respond: false }, resolved: true })
}
if (p === '/api/apps' && method === 'GET') return json({ items: [] })
if (method !== 'GET') {
state.writes.push({ method, path: p, body: r.postData() })
return json({ detail: '检查脚本拦下了写请求' }, 503)
}
return route.continue()
})
return { ctx, page }
}
const noHScroll = (page) => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)
// ---------------------------------------------------------------- 新建应用
await section('新建应用:三个起点(搭建者)', async () => {
const state = {}
const { page } = await open({ state })
await page.goto(`${WEB}/apps?tab=apps`)
const create = page.locator('header [data-app-create]')
await create.waitFor({ timeout: 15000 })
check('应用首页的「新建应用」链到 /apps/create,范围是共享', await create.getAttribute('href') === '/apps/create'
&& await create.getAttribute('data-app-create') === 'shared', `${await create.getAttribute('href')}`)
await page.goto(`${WEB}/apps/create`)
await page.locator('[data-new-app="shared"]').waitFor({ timeout: 15000 })
check('还没做完的蓝图列在最上面「接着做」,做完的不列', await page.locator('[data-new-app-blueprint="bp_week"]').waitFor({ timeout: 5000 })
.then(() => true, () => false) && await page.locator('[data-new-app-blueprint="bp_done"]').count() === 0
&& await page.locator('[data-new-app-blueprint="bp_week"]').getAttribute('href') === '/blueprints/bp_week')
check('三个起点都在', await page.locator('[data-new-app-start="talk"]').count() === 1
&& await page.locator('[data-new-app-start="template"]').count() === 1 && await page.locator('[data-new-app-start="existing"]').count() === 1)
const board = page.locator('[data-template="periodic_board"]')
await board.waitFor({ timeout: 5000 })
check('模板卡写适用场景、会产出什么、要准备什么', (await board.locator('[data-template-when]').innerText()).includes('固定的数')
&& (await board.locator('[data-template-produces]').innerText()).includes('看板')
&& (await board.locator('[data-template-prepare]').innerText()).includes('看哪些数'))
check('「自由形」不作为从零开始的场景', await page.locator('[data-template="freeform"]').count() === 0)
check('最近的问数据回答只列答完、有运行的', await page.locator('[data-new-app-answer="run_chat_1"]').count() === 1
&& await page.locator('[data-new-app-answer="run_bad"]').count() === 0)
check('已发布的工作流列出来(搭建者)', await page.locator('[data-new-app-workflow="wf_sales"]').count() === 1)
await page.locator('[data-new-app-example]').first().click()
const text = await page.getByRole('textbox', { name: '你想做一个什么样的应用' }).inputValue()
check('点例句只是填进输入框', text.includes('每周一') && !(state.creates ?? []).length, text)
state.streams = [{ when: '每周一', ops: [{ op: 'heartbeat', phase: 'planning' }, { op: 'done', explanation: '整理好了' }] }]
await page.locator('[data-new-app-talk-go]').click()
await page.waitForURL(/\/blueprints\/bp_new$/, { timeout: 5000 }).catch(() => {})
check('开始:建对话起点的共享蓝图', state.creates?.[0]?.source?.kind === 'conversation' && state.creates[0].scope === 'shared',
JSON.stringify(state.creates?.[0]))
check('……进蓝图页', /\/blueprints\/bp_new$/.test(page.url()), page.url())
check('……这句话作为第一轮发给蓝图助手', await until(() => state.chats.length === 1)
&& state.chats[0].instruction === text && state.chats[0].blueprint_id === 'bp_new', JSON.stringify(state.chats[0]))
// 从模板、从回答、从已发布的工作流:各建一种起点
for (const [what, click, expect] of [
['模板', '[data-template-use="periodic_board"]', (b) => b.source.kind === 'template' && b.template === 'periodic_board'],
['问数据回答', '[data-new-app-answer="run_chat_1"] button', (b) => b.source.kind === 'run' && b.source.id === 'run_chat_1'],
['已发布的工作流', '[data-new-app-workflow="wf_sales"] button',
(b) => b.source.kind === 'workflow_version' && b.source.id === 'wf_sales' && b.source.version === 3],
]) {
const s = {}
const o = await open({ state: s })
await o.page.goto(`${WEB}/apps/create`)
await o.page.locator(click).first().waitFor({ timeout: 15000 })
await o.page.locator(click).first().click()
check(`从${what}开始:起点写对`, await until(() => (s.creates ?? []).length === 1) && expect(s.creates[0]), JSON.stringify(s.creates?.[0]))
check(`……进蓝图页、不发第一轮`, await o.page.waitForURL(/\/blueprints\/bp_new/, { timeout: 5000 }).then(() => true, () => false)
&& s.chats.length === 0)
}
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('新建应用:使用者(个人应用的开关)', async () => {
const state = { me: USER_ON }
const { page } = await open({ state })
await page.goto(`${WEB}/apps?tab=apps`)
const create = page.locator('header [data-app-create]')
check('开关开着:使用者也有「新建应用」,范围是个人', await create.waitFor({ timeout: 15000 }).then(() => true, () => false)
&& await create.getAttribute('data-app-create') === 'personal')
await page.goto(`${WEB}/apps/create`)
await page.locator('[data-new-app="personal"]').waitFor({ timeout: 15000 })
check('写明建的是个人应用', (await page.locator('[data-new-app-personal]').innerText()).includes('只有你自己看得见'))
await page.locator('[data-template="approval"]').waitFor({ timeout: 5000 })
check('个人应用用不了的模板不给选,照实说为什么', await page.locator('[data-template-use="approval"]').isDisabled()
&& (await page.locator('[data-template="approval"] [data-template-personal]').innerText()).includes('不能用这个模板'))
check('已发布的工作流只给搭建者:使用者没有', await page.locator('[data-new-app-workflows]').count() === 0)
await page.locator('[data-template-use="periodic_board"]').click()
check('建个人蓝图', await until(() => (state.creates ?? []).length === 1) && state.creates[0].scope === 'personal',
JSON.stringify(state.creates?.[0]))
const off = { me: USER_OFF }
const o = await open({ state: off })
await o.page.goto(`${WEB}/apps?tab=apps`)
await o.page.waitForTimeout(1500)
check('开关没开:使用者的应用首页没有「新建应用」', await o.page.locator('[data-app-create], [data-app-create-empty]').count() === 0)
await o.page.goto(`${WEB}/apps/create`)
await o.page.locator('[data-new-app="none"]').waitFor({ timeout: 15000 })
check('……直接打开 /apps/create:照实说没开放,不给起点', (await o.page.locator('[data-new-app="none"]').innerText()).includes('还没有开放个人应用')
&& await o.page.locator('[data-new-app-start]').count() === 0)
check('没有未捕获的运行时错误', page.errors.length === 0 && o.page.errors.length === 0, page.errors[0] ?? o.page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 卡片
await section('蓝图页:卡片的状态、对到的数据、改一项、缺项挡试运行', async () => {
const state = { bp: clone(BP) }
const { page } = await open({ state })
await page.goto(`${WEB}/blueprints/bp_week`)
await page.locator('[data-blueprint-cards]').waitFor({ timeout: 15000 })
check('按模板摆卡片(目的、场景、看哪些数、多久更新、谁能看)',
await page.locator('[data-blueprint-cards]').getAttribute('data-blueprint-cards') === 'purpose,template,data,cadence,access',
await page.locator('[data-blueprint-cards]').getAttribute('data-blueprint-cards'))
check('卡头标状态:目的已确认、看哪些数缺(服务端说缺)、多久更新待确认',
await page.locator('[data-card="purpose"]').getAttribute('data-card-status') === 'confirmed'
&& await page.locator('[data-card="data"]').getAttribute('data-card-status') === 'missing'
&& await page.locator('[data-card="cadence"]').getAttribute('data-card-status') === 'proposed')
check('选填的空项(筛选条件)不标「缺」', await page.locator('[data-bp-item="/data/0/filter"]').getAttribute('data-item-status') === 'empty')
check('服务端说缺的那一项标「缺」', await page.locator('[data-bp-item="/data/1/table"]').getAttribute('data-item-status') === 'missing')
const match = page.locator('[data-bp-match="shop.orders"]')
const matchText = await match.innerText()
check('对到数据的项写表、中文名、粒度、业务日期、为什么', ['shop.orders', '订单', '一行一单', 'paid_at', '按支付日', '一行一单,业务日期是支付日']
.every((k) => matchText.includes(k)), matchText.replace(/\n/g, ' '))
check('多久更新写成白话', (await page.locator('[data-bp-item="/cadence"] [data-bp-value]').innerText()).includes('每周一 09:00,统计上周'))
check('卡片上的问题照白话写', (await page.locator('[data-card="data"] [data-card-issues]').innerText()).includes('「退款率」还没对到表'))
const trial = page.locator('[data-blueprint-trial]')
check('缺项时「用真数试一次」置灰,写清缺什么', await trial.isDisabled()
&& (await page.locator('[data-blueprint-missing]').innerText()).includes('「退款率」还没对到表'))
check('没试过:没有「发布」', await page.locator('[data-blueprint-publish]').count() === 0)
// 改一项:bp_set,值是纯值
await page.locator('[data-bp-item="/data/0/unit"] [data-bp-edit]').click()
await page.locator('[data-bp-item="/data/0/unit"] [data-bp-editor] input').fill('万元')
await page.locator('[data-bp-item="/data/0/unit"] [data-bp-save]').click()
check('改一项:发 bp_set,值是纯值(来源由服务端记成你)', await until(() => (state.patches ?? []).length === 1)
&& JSON.stringify(state.patches[0]) === JSON.stringify({ ops: [{ op: 'bp_set', path: '/data/0/unit', value: '万元' }] }),
JSON.stringify(state.patches?.[0]))
// 确认:只改状态
await page.locator('[data-bp-item="/data/0/table"] [data-bp-confirm]').click()
check('确认:只改状态(/status = confirmed)', await until(() => (state.patches ?? []).length === 2)
&& JSON.stringify(state.patches[1].ops) === JSON.stringify([{ op: 'bp_set', path: '/data/0/table/status', value: 'confirmed' }]),
JSON.stringify(state.patches?.[1]))
// 加一项:bp_add,卡片字段写纯值
await page.locator('[data-bp-add="/data"]').click()
const dialog = page.getByRole('dialog')
await dialog.locator('input').fill('订单数')
await dialog.getByRole('button', { name: '加一项' }).click()
check('加一项:bp_add,卡片字段写纯值、id 不和已有的重', await until(() => (state.patches ?? []).length === 3)
&& JSON.stringify(state.patches[2].ops) === JSON.stringify([{ op: 'bp_add', path: '/data', value: { id: 'd3', label: '订单数' } }]),
JSON.stringify(state.patches?.[2]))
// 被拒:照实说服务端的原因
state.patchReject = '「单位」最多 20 个字'
await page.locator('[data-bp-item="/data/0/unit"] [data-bp-edit]').click()
await page.locator('[data-bp-item="/data/0/unit"] [data-bp-editor] input').fill('很长很长的单位')
await page.locator('[data-bp-item="/data/0/unit"] [data-bp-save]').click()
check('被拒:编辑框留着,提示服务端的原因', await page.getByText('「单位」最多 20 个字').first().waitFor({ timeout: 3000 }).then(() => true, () => false)
&& await page.locator('[data-bp-item="/data/0/unit"] [data-bp-editor]').count() === 1)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 反问
await section('反问:选项可点、「这一轮在等你回答」常显、回答记在问题上', async () => {
const asked = { ...clone(BP), spec: { ...clone(SPEC), asks: [ASK] } }
const answered = { ...clone(BP), spec: { ...clone(SPEC), asks: [{ ...ASK, answer: '按支付日' }] } }
const state = {
bp: clone(BP),
streams: [
{ when: '退款率', blueprint: asked, ops: [
{ op: 'heartbeat', phase: 'planning' },
{ op: 'look', id: 'L1', by: 'model', status: 'ok', objects: [{ kind: 'table', source: 'shop', named: [], picked: ['orders'],
joined: [], missing: [], total: 3 }] },
{ op: 'bp_set', path: '/data/0/measure', value: '按实收金额求和' },
{ op: 'bp_ask', id: 'q1', question: ASK.question, options: ASK.options, path: ASK.path },
{ op: 'final', blueprint: asked },
] },
{ when: '按支付日', blueprint: answered, ops: [{ op: 'bp_set', path: '/data/0/date', value: { column: 'paid_at' } },
{ op: 'done', explanation: '按支付日算' }, { op: 'final', blueprint: answered }] },
],
}
const { page } = await open({ state })
await page.goto(`${WEB}/blueprints/bp_week`)
await page.locator('[data-blueprint-chat]').waitFor({ timeout: 15000 })
const box = page.getByRole('textbox', { name: '对助手说' })
await box.fill('还要看退款率')
await box.press('Enter')
const ask = page.locator('[data-blueprint-ask="q1"]')
check('助手的反问画成可点的选项', await ask.waitFor({ timeout: 5000 }).then(() => true, () => false)
&& (await ask.locator('[data-blueprint-ask-option]').allInnerTexts()).join('|') === '按下单日|按支付日')
check('「这一轮在等你回答」常显', (await ask.locator('[data-blueprint-waiting]').innerText()).includes('这一轮在等你回答'))
check('输入框提示回答助手的问题', (await box.getAttribute('placeholder')).includes('回答助手的问题'))
const steps = await page.locator('[data-blueprint-chat]').innerText()
check('过程里说改了哪张卡片的哪一项、问了什么', steps.includes('改了「看哪些数」第 1 项的「怎么算」') && steps.includes('助手问你一个问题'),
steps.replace(/\n/g, ' ').slice(0, 300))
await ask.locator('[data-blueprint-ask-option="按支付日"]').click()
check('点选项:作为下一轮发给蓝图助手(回答记在问题上由服务端做)', await until(() => state.chats.length === 2)
&& JSON.stringify(state.chats[1]) === JSON.stringify({ blueprint_id: 'bp_week', instruction: '按支付日' }), JSON.stringify(state.chats[1]))
check('前端不另记这一轮、不另记回答(服务端已经记了,免得一轮两条)', !state.writes.some((w) => w.path.includes('/conversations/'))
&& !(state.patches ?? []).length, JSON.stringify(state.writes.map((w) => w.path)))
check('回答之后不再显示在等', await page.locator('[data-blueprint-ask]').waitFor({ state: 'detached', timeout: 5000 }).then(() => true, () => false))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 关节
await section('关节:逐组写进度,组之间并行', async () => {
const state = { bp: clone(BP) }
const { page } = await open({ state })
await page.goto(`${WEB}/blueprints/bp_week`)
const joints = page.locator('[data-bp-joints]')
await joints.waitFor({ timeout: 15000 })
check('列出生成关节的状态(服务端给的名字)', (await joints.locator('[data-bp-joint="sql_gmv"]').innerText()).includes('销售额的取数 SQL')
&& await joints.locator('[data-bp-joint="sql_refund"]').getAttribute('data-joint-status') === 'pending')
check('按卡片直接填好的关节不逐个列,汇总一句', await joints.locator('[data-bp-joint="start.fields"]').count() === 0
&& await joints.locator('[data-bp-joints-fixed]').getAttribute('data-bp-joints-fixed') === '1')
await joints.locator('[data-bp-fill]').click()
check('不点名哪几组:服务端填还有没填上的关节的那几组', await until(() => state.fills.length === 1)
&& JSON.stringify(state.fills[0]) === JSON.stringify({ blueprint_id: 'bp_week' }), JSON.stringify(state.fills[0]))
const groups = joints.locator('[data-bp-fill-groups]')
await groups.waitFor({ timeout: 5000 })
check('两组各一行', await groups.locator('[data-bp-fill-group]').count() === 2)
check('第一组填好、第二组没填上并写明原因', await joints.locator('[data-bp-fill-group="g1"]').getAttribute('data-group-status') === 'done'
&& await joints.locator('[data-bp-fill-group="g2"]').getAttribute('data-group-status') === 'failed'
&& (await joints.locator('[data-bp-fill-group="g2"]').innerText()).includes('口径卡里没有的取数'))
check('final 的蓝图换上:关节都已填', await joints.locator('[data-bp-joint="sql_refund"]').getAttribute('data-joint-status') === 'filled')
check('有一组没填上:按钮写「让助手重新填」', (await joints.innerText()).includes('让助手重新填') || await joints.locator('[data-bp-fill]').count() === 0)
check('final 带来的问题照白话列出', (await joints.locator('[data-bp-instantiate-problems]').innerText()).includes('「退款率的算法」助手没填上'))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 试运行
await section('试运行:横幅、输入、换一期再试、待办、出错', async () => {
const state = { bp: READY(), tasks: [TRIAL_TASK] }
const { page } = await open({ state })
await page.goto(`${WEB}/blueprints/bp_week`)
const trial = page.locator('[data-blueprint-trial]')
await trial.waitFor({ timeout: 15000 })
check('补齐之后可以试运行', !(await trial.isDisabled()))
await trial.click()
await page.waitForURL(/\/blueprints\/bp_week\/trial\?run=run_trial/, { timeout: 5000 })
check('试运行:不带输入时服务端按「多久更新」算最近一期', JSON.stringify(state.trials?.[0]) === '{}', JSON.stringify(state.trials))
const banner = page.locator('[data-trial-banner]')
await banner.waitFor({ timeout: 5000 })
check('横幅:数据是真的,但这不是正式结果', (await banner.locator('[data-trial-banner-title]').innerText()) === '试运行:数据是真的,但这不是正式结果')
check('横幅写这次的输入', (await banner.locator('[data-trial-inputs]').innerText()).includes('2026-W41'))
check('应用的样子用 ViewRenderer 渲染(预览)', await page.locator('[data-view-renderer="preview"]').count() === 1
&& (await page.locator('[data-stamp]').first().innerText()).includes('试运行'))
const tasks = page.locator('[data-trial-tasks]')
check('有待办:右侧列试运行里的待办', await tasks.waitFor({ timeout: 5000 }).then(() => true, () => false))
check('按钮写「以试运行身份…」', (await tasks.locator('[data-task-approve]').innerText()).includes('以试运行身份同意')
&& (await tasks.locator('[data-task-reject]').innerText()).includes('以试运行身份驳回'))
check('写入走测试环境或演练', (await tasks.locator('[data-trial-write-note]').innerText()).includes('测试环境'))
await tasks.locator('[data-task-approve]').click()
check('以试运行身份同意:回复待办', await until(() => (state.responds ?? []).length === 1) && state.responds[0].decision === 'approve')
await banner.locator('[data-trial-retry]').click()
await banner.locator('[data-trial-retry-go]').click()
check('换一期再试:再发起一次试运行', await until(() => (state.trials ?? []).length === 2))
// 出错:白话说为什么,给「交给助手修」「回到卡片改」
const bad = { bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }), preview: PREVIEW_FAILED,
streams: [{ when: '试运行出错了', ops: [{ op: 'done', explanation: '改了' }] }] }
const o = await open({ state: bad })
await o.page.goto(`${WEB}/blueprints/bp_week/trial?run=run_trial`)
const err = o.page.locator('[data-trial-error]')
check('出错时写清停在哪一步、为什么', await err.waitFor({ timeout: 15000 }).then(() => true, () => false)
&& (await err.innerText()).includes('没有列 refund_amt'))
await err.locator('[data-trial-fix]').click()
check('交给助手修:把原因作为一轮发给蓝图助手,回到卡片页', await until(() => bad.chats.length === 1)
&& bad.chats[0].instruction.includes('没有列 refund_amt') && /\/blueprints\/bp_week$/.test(o.page.url()), o.page.url())
check('没有未捕获的运行时错误', page.errors.length === 0 && o.page.errors.length === 0, page.errors[0] ?? o.page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 发布摘要
await section('发布摘要:三栏、处置、交给助手修', async () => {
const state = { bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }), plans: [clone(PLAN_BLOCKED), clone(PLAN_BLOCKED)] }
const { page } = await open({ state })
await page.goto(`${WEB}/blueprints/bp_week`)
await page.locator('[data-blueprint-publish]').click()
const dlg = page.locator('[data-plan="planned"]')
await dlg.waitFor({ timeout: 15000 })
check('每一步一句白话', (await dlg.locator('[data-plan-step="publish_workflow"]').innerText()).includes('此后每周一 09:00 按这一版自动出数'))
check('问题分三栏:你来定、助手可以修、要找管理员', await dlg.locator('[data-plan-who="you"]').getAttribute('data-plan-who-count') === '2'
&& await dlg.locator('[data-plan-who="assistant"]').getAttribute('data-plan-who-count') === '1'
&& await dlg.locator('[data-plan-who="admin"]').getAttribute('data-plan-who-count') === '1')
check('有 error 不能确认,写明为什么', await page.locator('[data-plan-confirm]').isDisabled() && await dlg.locator('[data-plan-blocked]').count() === 1)
check('关登录(管理员):可以选受管级', await dlg.locator('[data-plan-level]').count() === 1)
await dlg.locator('[data-plan-disposition="weekly"] button').first().click()
check('口径升版:选处置就重出发布单,带上处置', await until(() => (state.planBodies ?? []).length === 2)
&& state.planBodies[1].dispositions?.weekly === 'recompute', JSON.stringify(state.planBodies?.[1]))
await page.locator('[data-plan-fix]').click()
check('交给助手修:关掉摘要,把问题作为一轮发给助手', await until(() => state.chats.length === 1)
&& state.chats[0].instruction.includes('有一块看板没有对应的数'), JSON.stringify(state.chats[0]))
// 搭建者(开了登录):没有受管级可选
const b = { me: BUILDER, bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }), plans: [clone(PLAN_OK)] }
const o = await open({ state: b })
await o.page.goto(`${WEB}/blueprints/bp_week`)
await o.page.locator('[data-blueprint-publish]').click()
await o.page.locator('[data-plan="planned"]').waitFor({ timeout: 15000 })
check('搭建者:受管级只给管理员,不给选', await o.page.locator('[data-plan-level]').count() === 0)
check('没有未捕获的运行时错误', page.errors.length === 0 && o.page.errors.length === 0, page.errors[0] ?? o.page.errors[0] ?? '')
})
await section('发布摘要:确认、逐步打勾、停下、接着做', async () => {
const running = { ...clone(PLAN_OK), status: 'running', steps: steps(2) }
const stopped = { ...clone(PLAN_OK), status: 'stopped', steps: steps(7, 'grant', '名录里没有组「门店」:先在设置里建这个组,或者在卡片上改授权') }
const done = { ...clone(PLAN_OK), status: 'done', steps: steps(9) }
const state = {
bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }), plans: [clone(PLAN_OK)],
planRun: { run: running, resume: done }, planPoll: [running, stopped],
}
const { page } = await open({ state })
await page.goto(`${WEB}/blueprints/bp_week`)
await page.locator('[data-blueprint-publish]').click()
const confirm = page.locator('[data-plan-confirm]')
await confirm.waitFor({ timeout: 15000 })
check('确认按钮写清结果', (await confirm.innerText()).includes('发布并每周一 09:00 自动出数'), await confirm.innerText())
check('没有问题照实说', await page.locator('[data-plan-no-problems]').count() === 1)
check('看板类默认勾「发布后立即运行一次」', await page.locator('[data-plan-first-run]').isChecked())
await confirm.click()
check('执行:调 run', await until(() => (state.planActs ?? []).includes('run')))
check('逐步打勾:已完成的打勾', await page.locator('[data-plan-step="save_version"][data-step-status="done"]').waitFor({ timeout: 5000 }).then(() => true, () => false))
const stop = page.locator('[data-plan-stopped="grant"]')
check('停下时写停在哪一步、为什么', await stop.waitFor({ timeout: 8000 }).then(() => true, () => false)
&& (await stop.innerText()).includes('停在「授权」') && (await stop.innerText()).includes('名录里没有组「门店」'))
await page.locator('[data-plan-resume]').click()
check('接着做:调 resume', await until(() => (state.planActs ?? []).includes('resume')))
const openApp = page.locator('[data-plan-open-app]')
check('做完:给「打开应用」', await openApp.waitFor({ timeout: 5000 }).then(() => true, () => false)
&& await openApp.getAttribute('href') === '/apps/app_week')
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('个人蓝图:试过之后存成我的应用', async () => {
const state = { me: USER_ON, bp: READY({ scope: 'personal', status: 'trialed', last_trial_run_id: 'run_trial', owner: 'zhangsan' }) }
const { page } = await open({ state })
await page.goto(`${WEB}/blueprints/bp_week`)
const save = page.locator('[data-blueprint-save-personal]')
await save.waitFor({ timeout: 15000 })
check('个人蓝图:没有「发布」,有「存成我的应用」', await page.locator('[data-blueprint-publish]').count() === 0
&& (await save.innerText()).includes('存成我的应用'))
check('个人蓝图不摆「谁能看」', await page.locator('[data-card="access"]').count() === 0)
await save.click()
check('存成我的应用:调 save-personal,进应用页', await page.waitForURL(/\/apps\/app_mine$/, { timeout: 5000 }).then(() => true, () => false)
&& state.writes.some((w) => w.path === '/api/blueprints/bp_week/save-personal'), page.url())
})
await section('窄屏:390px 不出横向滚动', async () => {
const state = { bp: READY({ status: 'trialed', last_trial_run_id: 'run_trial' }) }
const { page } = await open({ width: 390, height: 844, state })
for (const path of ['/apps/create', '/blueprints/bp_week', '/blueprints/bp_week/trial?run=run_trial']) {
await page.goto(`${WEB}${path}`)
await page.waitForTimeout(1000)
check(`${path} 没有横向滚动`, await noHScroll(page))
}
})
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
process.exit(failed ? 1 : 0)
+121 -1
View File
@@ -25,7 +25,11 @@
// 彻底删除(清空也跳过它);
// 12. 限定数据源:点一下只查这个库,请求里带上 datasource_ids,库不在了说人话、补救是放开范围;
// 库列表没取回来时,从上一问接过来的范围照样看得见;
// 13. 画布助手的「在现有工作流上改 | 从头生成」分段控件;助手出错时同一句报错只说一遍。
// 13. 画布助手的「在现有工作流上改 | 从头生成」分段控件;助手出错时同一句报错只说一遍;
// 14. 助手反问(normal-user-apps N11):只问不改的一轮记成「助手反问」,问题作为这一轮的内容、落库进历史,不再一直停在
// 「正在理解需求」;输入框提示回答助手的问题;刷新后照样标「助手反问」,不当成没查库的回答;
// 15. 回答上的「做成应用」带着这次运行进蓝图(建运行起点的蓝图),不再提取工作流、跳画布的发布弹窗;
// 16. 服务端这一轮已经自查修正过(收到 check 的结论)还没过的:不再整轮重新生成一遍,直接说没过、给补救。
//
// 所有写请求都被拦在浏览器里,不碰任何库。
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时
@@ -194,11 +198,16 @@ const GUARD_CONVS = {
const FIX_CONVS = {
fixZ: conv('c0fixz', '会话 Z:参数定义有误(后端现在的说法)', 1, 60 * 24 * 27),
}
/** 只在「助手反问」那段出现:库里存着一轮以反问收尾的(answer 列里是助手问的那句话,meta.asked 标着) */
const ASKBACK_CONVS = {
askBack: conv('c0askbk', '助手反问过的会话', 1, 60 * 24 * 34),
}
/** 只在「问数据的证据」那段出现:库里存着一轮带证据的答案,刷新后回运行取 _evidence */
const ASK_CONVS = {
askZ: conv('c0askz', '问数据:各区域销售额和最大的一单', 1, 60 * 24 * 33),
}
const DETAIL = {
c0askbk: [turn('tab1', '上周销售额是多少', { answer: '销售额按下单日还是支付日算?', meta: { v: 1, asked: true } })],
c0askz: [turn('tz9', '上周各区域的销售额,最大的一单是多少', {
answer: EVQ.output.answer, graph: GRAPH, run_id: 'run-evq',
meta: { v: 1, runId: 'run-evq', runClass: 'exploratory', runStatus: 'succeeded', evidence: true },
@@ -385,6 +394,8 @@ const ctl = {
guardConvs: false,
/** 列表里多摆「问数据的证据」那段的会话(ASK_CONVS) */
askConvs: false,
/** 列表里多摆「助手反问」那段的会话(ASKBACK_CONVS) */
askBack: false,
/** 列表里多摆「先去改参数定义」那段的会话(FIX_CONVS) */
fixConvs: false,
/** 数据源列表取不回来 */
@@ -401,6 +412,7 @@ const resetDb = () => {
resetDb()
const allConvs = () => [...Object.values(CONVS), ...Object.values(TRASHED), ...(ctl.trashMore ? Object.values(TRASH_MORE) : []),
...(ctl.guardConvs ? Object.values(GUARD_CONVS) : []), ...(ctl.askConvs ? Object.values(ASK_CONVS) : []),
...(ctl.askBack ? Object.values(ASKBACK_CONVS) : []),
...(ctl.fixConvs ? Object.values(FIX_CONVS) : [])]
.filter((c) => !db.purged.has(c.id))
.map((c) => ({ ...c, archived: db.archived.has(c.id) }))
@@ -472,6 +484,26 @@ async function fakeApi(route) {
return route.fulfill({ status: 200, contentType: 'text/event-stream',
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
if (String(b.instruction ?? '').includes('自查没修好')) {
// 服务端自查交回模型改过、还是没过(check failed),final 带着同一批问题:前端不该再整轮重来
const ops = [
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 300 },
...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })),
{ op: 'check', status: 'failed', issues: ['「查数」节点还没有选择工具'] },
{ op: 'final', graph: GRAPH, explanation: '', issues: [{ level: 'error', node_id: 'ag', message: '「查数」节点还没有选择工具' }] },
]
return route.fulfill({ status: 200, contentType: 'text/event-stream',
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
if (String(b.instruction ?? '').includes('先问清楚')) {
// 助手要先问清楚才能动手(服务端 copilot_gen 的 ask):问题转出去,这一轮到此为止——没有 final,也没有 reply
const ops = [
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 300 },
{ op: 'ask', question: '销售额按下单日还是支付日算?' },
]
return route.fulfill({ status: 200, contentType: 'text/event-stream',
body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') })
}
if (String(b.instruction ?? '').includes('表示作废')) {
// 用户说了一条数据事实:服务端核对后转出目录修改提案,只回答、不建图
const ops = [
@@ -1462,6 +1494,94 @@ for (const theme of THEMES) {
await ctx.close()
})
await section('askback', '助手反问:这一轮记成「助手反问」,问题进历史,输入框提示回答', async () => {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0emptg')
const patches = log.patches.length
await send(page, '先问清楚再查上周销售额')
const t = await page.waitForFunction(() => window.__chat.getState().byConversation.c0emptg?.at(-1)?.phase !== 'planning', null,
{ timeout: 6000 }).then(() => true, () => false)
const turn = (await chatState(page)).byConversation.c0emptg.at(-1)
check('只问不改的一轮收尾,不再一直停在「正在理解需求」', t && turn.phase === 'done', turn.phase)
check('这一轮记成「助手反问」,问题作为这一轮的内容', turn.asked === '销售额按下单日还是支付日算?'
&& turn.output?.answer === turn.asked, JSON.stringify({ asked: turn.asked, output: turn.output }))
const card = page.locator('[data-turn]').last()
check('轮次头写「助手反问」,问题摆出来', (await card.innerText()).includes('助手反问')
&& (await card.innerText()).includes('销售额按下单日还是支付日算?'), (await card.innerText()).split('\n').slice(0, 6).join(' / '))
check('不标「本条未查询数据库」(这不是回答)', !(await card.innerText()).includes('本条未查询数据库'))
const box = page.getByRole('textbox', { name: '向数据提问' })
check('输入框提示回答助手的问题', (await box.getAttribute('placeholder')).includes('回答助手的问题'))
const saved = await until(() => log.patches.slice(patches).some((x) => x.body.answer === '销售额按下单日还是支付日算?'))
const body = log.patches.slice(patches).find((x) => x.body.answer)?.body
check('问题落库进历史(answer 列),meta 标着反问', saved && body.status === 'done' && body.meta?.asked === true, JSON.stringify(body))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
// 刷新之后:库里的这一轮照样是「助手反问」,不当成没查库的回答
ctl.askBack = true
const again = await open(theme)
await goto(again.page, 'c0askbk')
const restored = again.page.locator('[data-turn]').last()
await restored.waitFor({ timeout: 6000 })
check('刷新后照样标「助手反问」', (await restored.innerText()).includes('助手反问'), (await restored.innerText()).split('\n').slice(0, 4).join(' / '))
check('……不标「本条未查询数据库」', !(await restored.innerText()).includes('本条未查询数据库'))
check('……输入框提示回答助手的问题', (await again.page.getByRole('textbox', { name: '向数据提问' }).getAttribute('placeholder'))
.includes('回答助手的问题'))
ctl.askBack = false
await again.ctx.close()
})
await section('selfcheck', '服务端已经自查修正过还没过:不再整轮重新生成', async () => {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0emptg')
const gens = log.gens.length
await send(page, '自查没修好的问题')
const done = await page.waitForFunction(() => window.__chat.getState().byConversation.c0emptg?.at(-1)?.phase === 'error', null,
{ timeout: 6000 }).then(() => true, () => false)
await page.waitForTimeout(500)
const turn = (await chatState(page)).byConversation.c0emptg.at(-1)
check('只生成了一次(同一轮的自查修正已经做过,不再整轮重来)', log.gens.length - gens === 1, `${log.gens.length - gens} 次`)
check('这一轮照实说没过、说明已经修正过', done && turn.failure?.reason?.includes('已按检查结果修正过'), JSON.stringify(turn.failure))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
})
await section('makeapp', '做成应用:带着这次运行进蓝图,不再跳画布发布', async () => {
const { page, ctx, errors } = await open(theme)
const made = []
const extracted = []
await page.route('**/api/copilot/from-run**', (route) => { extracted.push(route.request().url()); return route.abort() })
await page.route(/\/api\/(blueprints|templates)(\/|\?|$)/, (route) => {
const r = route.request()
const path = new URL(r.url()).pathname
if (path === '/api/templates') return route.fulfill({ json: { items: [] } })
if (path === '/api/blueprints' && r.method() === 'POST') {
made.push(r.postDataJSON())
return route.fulfill({ status: 201, json: {
id: 'bp_from_run', name: 'B 的问题', owner: null, scope: 'shared', status: 'drafting', template_id: 'freeform',
template_version: 1, source: { kind: 'run', id: 'run-b1' }, conversation_id: null, workflow_id: null, app_id: null,
last_trial_run_id: null, graph_hash: 'x', joints: {}, copied_from: null, issues: [], created_at: iso(1), updated_at: iso(1),
spec: { format: 'agentlab.blueprint/1', purpose: { value: 'B 的问题', source: 'assistant', status: 'proposed' },
template: { id: 'freeform', version: 1 }, data: [], cadence: { value: null, source: 'assistant', status: 'missing' },
people: [], fields: [], writeback: null, access: { use: [], observe: [], manage: [] }, asks: [] },
} })
}
return route.fulfill({ status: 404, json: { detail: '检查脚本没有这个蓝图' } })
})
await goto(page, 'c0idleb')
const button = page.locator('[data-make-app]').last()
check('回答完成、有运行的轮次有「做成应用」', await button.waitFor({ timeout: 6000 }).then(() => true, () => false))
check('关登录(等同管理员):建共享蓝图', await button.getAttribute('data-make-app') === 'shared')
await button.click()
check('建运行起点的蓝图', await until(() => made.length === 1) && made[0].source?.kind === 'run' && made[0].source?.id === 'run-b1'
&& made[0].scope === 'shared', JSON.stringify(made[0]))
check('进蓝图页,不去画布', await page.waitForURL(/\/blueprints\/bp_from_run$/, { timeout: 5000 }).then(() => true, () => false)
&& !page.url().includes('/studio/'), page.url())
check('不再提取工作流(from-run)', extracted.length === 0, extracted.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
})
await section('approval', '停在审批上:审批卡立刻出现,不等 4 秒轮询', async () => {
const { page, ctx, errors } = await open(theme)
ctl.approvals = []
+244
View File
@@ -0,0 +1,244 @@
// 文件控件(docs/design/normal-user-apps.md N9、§3.8、§4.7)的回归检查。
//
// 守的是这些「看着没坏、其实不对」的地方:
// - 表单里 x-widget 为 file 的字段画成文件控件:拖放或点选,写明允许的类型和大小(来自 x-file)、谁能下载附件;
// - 两步走:先 POST /api/files(multipart,带上下文——入口是 {app_id, entry},待办是 {task_id}),拿回引用;引用就是
// 这个字段的值,发起时 values 里原样带着(只有引用的五个键);
// - 上传中写文件名、大小、进度,可以取消;服务端回包慢也不把进度冒充完成;
// - 超过大小、类型不在允许范围内的先在这里拦下,不发请求,说清原因;服务端拒收的(415 / 413)照实写服务端的原话;
// - 必填的没上传,本地拦下、挂在字段下面(规则和服务端同一份:必填,形状是文件引用);
// - 这次上传的移除时顺手删掉(DELETE /api/files/{id});
// - 已提交的引用(待办里只读的表单、实例页上的字段)显示成可下载的链接(/api/files/{id});
// - 不在入口、待办、试运行里的表单(页面的筛选表单)没有上传的上下文:照实说这里不能上传。
//
// 页面是真页面(走真的应用外壳),/api/apps/**、/api/files/**、/api/tasks/** 在浏览器层答掉,数据是这里虚构的(「报销审批」);
// 其余 GET 放行到服务端(外壳要它),别的写请求一律拦下、记在探针里,不碰真实写接口。
//
// node scripts/check-files.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-files.mjs
// CHECK_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'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${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 ago = (ms) => new Date(Date.now() - ms).toISOString()
// ---------------------------------------------------------------- 假数据
const FILE_FIELD = { type: 'object', title: '发票', 'x-widget': 'file', 'x-file': { accept: ['application/pdf', 'image/png'], max_mb: 1 } }
const FORM = {
format: 'agentlab.form/1', type: 'object', required: ['amount', 'invoice'], 'x-order': ['amount', 'invoice'],
properties: { amount: { type: 'number', title: '金额(元)', minimum: 0 }, invoice: FILE_FIELD },
}
const FILTER_FORM = {
format: 'agentlab.form/1', type: 'object', required: [], properties: { scan: { type: 'object', title: '扫描件', 'x-widget': 'file' } },
}
const APP = {
id: 'app_claim', name: '报销审批', description: '员工发起报销', icon: 'app', status: 'active', published_version: 2,
level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: ago(3600_000), scope: 'shared',
}
const HOME = {
format: 'agentlab.view/1',
app: { id: 'app_claim', name: '报销审批', version: 2, level: 'published', needs_upgrade: false },
page: { id: 'home', title: '报销' },
pages: [{ id: 'home', title: '报销' }],
sources: {},
tree: [
{ id: 'new', type: 'entry_button', props: { entry: 'apply' }, data: { entry: 'apply', label: '发起报销', disabled: false } },
{ id: 'filter', type: 'form', props: { form: 'filter' }, data: { form: 'filter' } },
],
forms: { apply: FORM, filter: FILTER_FORM },
problems: [],
}
const SUBMITTED = { file_id: 'f_inv', name: '发票-1001.pdf', size: 52_314, sha256: 'cd'.repeat(32), type: 'application/pdf' }
/** 财务的待办:表单里带着发起人交的发票(只读:不是这一位处理) */
const TASK = {
id: 'task_fin', approval_id: 'ap_fin', run_id: 'run_claim_1', node_id: 'finance', app_id: 'app_claim', app_name: '报销审批',
app_version: 2, workflow_name: '报销流程', instance_title: '张三 的报销', kind: 'input', title: '补充发票',
form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: { invoice: { ...FILE_FIELD, default: SUBMITTED } } },
policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0,
payload: { kind: 'human_node', message: '请补充发票' }, assignments: [], can_respond: false, outcome: null,
resolved_by: null, resolved_at: null, created_at: ago(600_000),
}
/** 一张能处理的待办:上传的上下文是 {task_id} */
const TASK_OPEN = { ...TASK, id: 'task_open', form: { ...TASK.form, properties: { invoice: FILE_FIELD } }, can_respond: true }
const pdf = (name, bytes) => ({ name, mimeType: 'application/pdf', buffer: Buffer.alloc(bytes, 0x25) })
// ---------------------------------------------------------------- 浏览器
const browser = await chromium.launch({ executablePath: CHROME })
async function open({ state, width = 1280, height = 900 }) {
const ctx = await browser.newContext({ viewport: { width, height } })
opened.add(ctx)
const page = await ctx.newPage()
page.errors = []
page.on('pageerror', (e) => page.errors.push(e.message))
await page.route((url) => url.pathname.startsWith('/api/'), async (route) => {
const r = route.request()
const url = new URL(r.url())
const p = url.pathname
if (r.method() !== 'GET') {
const raw = r.postDataBuffer()?.toString('utf8') ?? ''
state.writes.push({ method: r.method(), path: p, body: raw })
if (r.method() === 'POST' && p === '/api/files') {
// 服务端回包慢一点:上传中的样子要看得到。取消了的请求再答会抛错,吞掉
const id = `f_${state.writes.length}`
await new Promise((res) => setTimeout(res, state.uploadDelay ?? 600))
if (state.reject) return route.fulfill({ status: state.reject.status, json: { detail: state.reject.detail } }).catch(() => {})
const name = /filename="([^"]+)"/.exec(raw)?.[1] ?? 'file'
return route.fulfill({ status: 201, json: { file_id: id, name, size: 2048, sha256: 'ef'.repeat(32),
type: 'application/pdf', uploaded_at: ago(0) } }).catch(() => {})
}
if (r.method() === 'DELETE' && p.startsWith('/api/files/')) return route.fulfill({ status: 204, body: '' })
if (p === '/api/apps/app_claim/entries/apply') return route.fulfill({ status: 201, json: { run_id: 'run_claim_9', title: '报销' } })
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
}
if (p === '/api/auth/me') return route.fulfill({ json: { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false } })
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } })
if (p === '/api/apps') return route.fulfill({ json: { items: [APP] } })
if (p === '/api/apps/app_claim') return route.fulfill({ json: { ...APP, published: { version: 2, pages: HOME.pages, entries: [{ id: 'apply', label: '发起报销' }] } } })
if (p === '/api/apps/app_claim/pages/home') return route.fulfill({ json: HOME })
if (p === '/api/apps/app_claim/instances/run_claim_9') return route.fulfill({ status: 404, json: { detail: '检查脚本不画实例页' } })
if (p === '/api/tasks/task_fin') return route.fulfill({ json: TASK })
if (p === '/api/tasks/task_open') return route.fulfill({ json: TASK_OPEN })
return route.continue()
})
return { ctx, page }
}
const field = (page) => page.locator('[data-field="invoice"]')
const uploads = (state) => state.writes.filter((w) => w.method === 'POST' && w.path === '/api/files')
// ---------------------------------------------------------------- 上传
await section('入口:上传、进度、引用随表单提交、移除时删掉', async () => {
const state = { writes: [], uploadDelay: 1200 }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_claim/new/apply`)
await field(page).waitFor({ timeout: 15000 })
check('画成文件控件', await field(page).getAttribute('data-widget') === 'file'
&& await field(page).locator('[data-file-input="ready"] [data-file-drop]').count() === 1)
const limits = await field(page).locator('[data-file-limits]').innerText()
check('写明允许的类型和大小(来自 x-file)', limits.includes('PDF') && limits.includes('PNG 图片') && limits.includes('1 MB'), limits)
check('写明谁能下载附件', limits.includes('都能下载附件'), limits)
// 必填没上传:本地拦下
await page.locator('#entry-apply-amount').fill('1200')
await page.locator('[data-entry-submit]').click()
check('必填的文件没上传:本地拦下、挂在字段下面', (await field(page).locator('[data-field-error]').innerText().catch(() => '')) === '必填'
&& !state.writes.some((w) => w.path.endsWith('/entries/apply')))
await field(page).locator('[data-file-picker]').setInputFiles(pdf('发票-2001.pdf', 2048))
const sending = field(page).locator('[data-file-sending]')
check('上传中写文件名、大小', await sending.waitFor({ timeout: 3000 }).then(() => true, () => false)
&& (await sending.innerText()).includes('发票-2001.pdf') && (await sending.innerText()).includes('2.0 KB'),
await sending.innerText().catch(() => ''))
check('……写进度、不冒充完成', /正在上传|上传完成,正在等服务端核对/.test(await sending.innerText().catch(() => '')))
const value = field(page).locator('[data-file-value]')
await value.waitFor({ timeout: 5000 })
const up = uploads(state)[0]
check('先 POST /api/files,带上下文 {app_id, entry}', !!up && up.body.includes('"app_id":"app_claim"') && up.body.includes('"entry":"apply"'),
(up?.body ?? '').slice(0, 120))
check('上传完写文件名,给「换一个」「移除」', (await value.innerText()).includes('发票-2001.pdf')
&& await value.locator('[data-file-replace]').count() === 1 && await value.locator('[data-file-remove]').count() === 1)
await page.locator('[data-entry-submit]').click()
await page.waitForTimeout(500)
const start = state.writes.find((w) => w.path === '/api/apps/app_claim/entries/apply')
const values = start ? JSON.parse(start.body || '{}').values : null
check('发起时 values 里带着引用(只有五个键)', !!values && values.invoice?.file_id === 'f_1'
&& Object.keys(values.invoice).sort().join(',') === 'file_id,name,sha256,size,type', JSON.stringify(values))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
// 移除:这次上传的顺手删掉
const s2 = { writes: [], uploadDelay: 100 }
const two = await open({ state: s2 })
await two.page.goto(`${WEB}/apps/app_claim/new/apply`)
await field(two.page).locator('[data-file-picker]').setInputFiles(pdf('发票-3001.pdf', 1024))
await field(two.page).locator('[data-file-value]').waitFor({ timeout: 5000 })
await field(two.page).locator('[data-file-remove]').click()
await two.page.waitForTimeout(300)
check('移除这次上传的:DELETE /api/files/{id}', s2.writes.some((w) => w.method === 'DELETE' && w.path === '/api/files/f_1'))
check('……控件回到可以上传的样子', await field(two.page).locator('[data-file-drop]').count() === 1)
})
await section('上传:取消、超过大小和类型不对先拦下、服务端拒收照实说', async () => {
const state = { writes: [], uploadDelay: 4000 }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_claim/new/apply`)
await field(page).waitFor({ timeout: 15000 })
await field(page).locator('[data-file-picker]').setInputFiles(pdf('大文件.pdf', 2 * 1024 * 1024))
check('超过大小:不发请求,说清超过几 MB', (await field(page).locator('[data-file-failure]').innerText()).includes('超过 1 MB')
&& uploads(state).length === 0)
await field(page).locator('[data-file-picker]').setInputFiles({ name: '说明.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') })
const bad = await field(page).locator('[data-file-failure]').innerText()
check('类型不在允许范围内:不发请求,写明允许的类型', bad.includes('类型不在允许范围内') && bad.includes('PDF') && uploads(state).length === 0, bad)
await field(page).locator('[data-file-picker]').setInputFiles(pdf('慢.pdf', 1024))
await field(page).locator('[data-file-cancel]').click()
check('上传中可以取消,取消照实说', (await field(page).locator('[data-file-failure]').innerText()).includes('已取消上传'))
check('……没有留下值', await field(page).locator('[data-file-value]').count() === 0)
state.uploadDelay = 50
state.reject = { status: 415, detail: '这种类型的文件不能上传:只收 PDF、Word、Excel、CSV、图片和纯文本' }
await field(page).locator('[data-file-picker]').setInputFiles({ name: '扫描.png', mimeType: 'image/png', buffer: Buffer.alloc(512, 1) })
await page.waitForFunction(() => !!document.querySelector('[data-field="invoice"] [data-file-failure]')?.textContent?.includes('上传失败'),
null, { timeout: 5000 }).catch(() => {})
const refused = await field(page).locator('[data-file-failure]').innerText()
check('服务端拒收:照实写服务端的原话', refused.includes('「扫描.png」上传失败') && refused.includes('只收 PDF'), refused)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 下载、上下文
await section('已提交的引用是下载链接;待办的上传带 {task_id};筛选表单不能上传', async () => {
const state = { writes: [], uploadDelay: 100 }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/inbox/task_fin`)
await page.locator('[data-task="task_fin"]').waitFor({ timeout: 15000 })
const link = page.locator('[data-task="task_fin"] [data-field="invoice"] a[data-file-link]')
check('只读的表单里,已提交的引用是可下载的链接', await link.getAttribute('href') === '/api/files/f_inv'
&& (await link.innerText()).includes('发票-1001.pdf'), await link.getAttribute('href').catch(() => ''))
await page.goto(`${WEB}/apps/inbox/task_open`)
await page.locator('[data-task="task_open"] [data-field="invoice"]').waitFor({ timeout: 15000 })
await page.locator('[data-task="task_open"] [data-file-picker]').setInputFiles(pdf('补充发票.pdf', 1024))
await page.locator('[data-task="task_open"] [data-file-value]').waitFor({ timeout: 5000 })
const up = uploads(state).at(-1)
check('待办里上传带上下文 {task_id}', !!up && up.body.includes('"task_id":"task_open"'), (up?.body ?? '').slice(0, 120))
await page.goto(`${WEB}/apps/app_claim`)
const scan = page.locator('[data-node="filter"] [data-field="scan"]')
await scan.waitFor({ timeout: 15000 })
check('页面的筛选表单没有上传的上下文:照实说这里不能上传', await scan.locator('[data-file-input="no-context"]').count() === 1
&& (await scan.innerText()).includes('这里不能上传文件') && await scan.locator('[data-file-drop]').isDisabled())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
process.exit(failed ? 1 : 0)
+261
View File
@@ -0,0 +1,261 @@
// 个人应用的使用面(docs/design/normal-user-apps.md N7、§4.6)的回归检查。
//
// 守的是这些「看着没坏、其实不对」的地方:
// - 使用者建好的个人应用出现在应用首页单独一组「我的应用」,卡片角标写「个人」、不写「未发布」;共享的应用另列一组;
// - 个人应用的页上写明是个人应用(只有自己看得见、遮罩、探索运行未经发布审核),印章的来源照服务端写的
// 「探索 · 未经发布审核」,不另编;没有补算面板;
// - 「刷新」写明今天还剩几次(服务端在详情和刷新回包里给):点一次发 POST /refresh,剩余跟着回包变;用完了按钮置灰、
// 写「今天的刷新次数已用完」;服务端回 429 时照实说服务端的原话,不当成网络错误;
// - 「提交审核」:没选人不让提交;选一位搭建者或一个组、附一句说明,POST /submit-review 带 {to, note};提交后写
// 「审核中」和交给了谁,不再给提交按钮;
// - 审核状态以服务端为准(详情的 personal,形状同服务端 blueprints/personal.personal_state):审核待办还在等写「审核中」;
// 结论是已发布写「已有正式版本:去看看」并链到那个应用;
// 详情没给时看蓝图的状态(in_review 写「审核中」)。
// 使用者在蓝图里「存成我的应用」那一步在 check-blueprint 里查。
//
// 页面是真页面(走真的应用外壳),/api/apps/**、/api/blueprints/** 在浏览器层答掉,数据是这里虚构的(「我的退款周报」);
// 其余 GET 放行到服务端(外壳要它),写请求一律拦下、记在探针里,不碰真实写接口。
//
// node scripts/check-personal.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-personal.mjs
// CHECK_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'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${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 ago = (ms) => new Date(Date.now() - ms).toISOString()
const USER = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: ['门店'], verified: true }
// ---------------------------------------------------------------- 假数据
const MINE = {
id: 'app_mine', name: '我的退款周报', description: '每周看自己门店的退款', icon: 'app', status: 'active',
published_version: null, level: null, needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: ago(600_000),
scope: 'personal', owner: 'zhangsan', blueprint_id: 'bp_mine',
}
const SHARED = {
id: 'app_weekly', name: '门店周报', description: '每周一看各门店的销售概况', icon: 'app', status: 'active',
published_version: 3, level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: ago(3600_000),
scope: 'shared', owner: null, blueprint_id: null,
}
const STAMP = {
as_of: ago(3600_000), as_of_text: '截至 2026-10-14 09:05;统计期 上一 ISO 周', pin: 'mine', graph_hash: '9c1e22aa',
calibers: [], tier: 'formal', tier_text: '完整出具', from: '探索 · 未经发布审核', sealed: true, notes: [],
}
const HOME = {
format: 'agentlab.view/1',
app: { id: 'app_mine', name: '我的退款周报', version: 0, level: 'published', needs_upgrade: false },
page: { id: 'home', title: '本周退款' },
pages: [{ id: 'home', title: '本周退款' }],
sources: { main: { kind: 'exploratory', state: 'ok', run_id: 'run_p1', stamp: STAMP } },
tree: [
{ id: 'stamp', type: 'stamp', props: {}, data: { view: 'main', state: 'ok', stamp: STAMP } },
{ id: 'refund', type: 'metric', props: { label: '退款率' }, data: { state: 'ok', text: '3.2%', name: '退款率' } },
],
forms: {},
problems: [],
}
const detailOf = (personal) => ({ ...MINE, published: null, ...(personal === undefined ? {} : { personal }) })
// ---------------------------------------------------------------- 浏览器
const browser = await chromium.launch({ executablePath: CHROME })
async function open({ state, width = 1280, height = 900 }) {
const ctx = await browser.newContext({ viewport: { width, height } })
opened.add(ctx)
const page = await ctx.newPage()
page.errors = []
page.on('pageerror', (e) => page.errors.push(e.message))
await page.route((url) => url.pathname.startsWith('/api/'), (route) => {
const r = route.request()
const url = new URL(r.url())
const p = url.pathname
if (r.method() !== 'GET') {
state.writes.push({ method: r.method(), path: p, body: r.postData() })
if (p === '/api/apps/app_mine/refresh') {
const next = state.refreshes.shift()
if (!next) return route.fulfill({ status: 503, json: { detail: '检查脚本没有准备更多的刷新回包' } })
return next.status ? route.fulfill({ status: next.status, json: next.json }) : route.fulfill({ json: next })
}
if (p === '/api/apps/app_mine/submit-review') return route.fulfill({ json: { task_id: 'task_review_1' } })
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
}
if (p === '/api/auth/me') return route.fulfill({ json: state.me ?? USER })
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } })
if (p === '/api/tasks') return route.fulfill({ json: { items: [] } })
if (p === '/api/apps') return route.fulfill({ json: { items: [MINE, SHARED] } })
if (p === '/api/apps/app_mine') return route.fulfill({ json: state.detail })
if (p === '/api/apps/app_mine/pages/home') {
state.pageHits = (state.pageHits ?? 0) + 1
return route.fulfill({ json: HOME })
}
if (/^\/api\/apps\/[^/]+\/instances$/.test(p)) return route.fulfill({ json: { items: [] } })
if (p === '/api/blueprints/bp_mine') {
state.blueprintHits = (state.blueprintHits ?? 0) + 1
return route.fulfill({ json: { id: 'bp_mine', name: '我的退款周报', status: state.blueprintStatus ?? 'personal', scope: 'personal' } })
}
return route.continue()
})
return { ctx, page }
}
const bar = (page) => page.locator('[data-personal-bar]')
// ---------------------------------------------------------------- 首页
await section('首页:「我的应用」单独一组,角标「个人」', async () => {
const state = { writes: [], refreshes: [], detail: detailOf({ daily_limit: 5, remaining_today: 2, review: null }) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps?tab=apps`)
const group = page.locator('[data-app-group="personal"]')
check('建好的个人应用出现在「我的应用」一组', await group.waitFor({ timeout: 15000 }).then(() => true, () => false)
&& (await group.locator('h2').innerText()) === '我的应用' && await group.locator('[data-app="app_mine"]').count() === 1)
const card = group.locator('[data-app="app_mine"]')
check('卡片角标写「个人」', (await card.locator('[data-app-personal]').innerText()) === '个人')
check('……不写「未发布」(个人应用没有应用版本)', !(await card.innerText()).includes('未发布'), (await card.innerText()).replace(/\s+/g, ' '))
check('……没有「编辑」链接', await card.locator('a[href$="/edit"]').count() === 0)
check('共享的应用另列一组,不混进「我的应用」', await page.locator('[data-app-group="shared"] [data-app="app_weekly"]').count() === 1
&& await group.locator('[data-app="app_weekly"]').count() === 0)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 应用页
await section('应用页:写明是个人应用、印章照服务端写、刷新写剩余次数', async () => {
const state = {
writes: [], detail: detailOf({ daily_limit: 5, remaining_today: 2, review: null }),
refreshes: [{ run_id: 'run_p2', remaining: 1 }, { run_id: 'run_p3', remaining: 0 }],
}
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
check('页上写明:只有你看得见、未经发布审核', /只有你看得见/.test(await page.locator('[data-personal-banner]').innerText())
&& /未经发布审核/.test(await page.locator('[data-personal-banner]').innerText()))
check('页头角标写「个人」', (await page.locator('header [data-app-personal]').innerText()) === '个人')
check('印章的来源照服务端写「探索 · 未经发布审核」', (await page.locator('[data-stamp-row="from"]').innerText()).includes('探索 · 未经发布审核'))
check('个人应用没有「编辑」(使用者没有管理权)', await page.locator('[data-page-edit-start]').count() === 0)
const left = page.locator('[data-personal-remaining]')
check('「刷新」旁写明今天还剩几次(服务端给的)', (await left.innerText()) === '今天还剩 2 次', await left.innerText())
const hits = state.pageHits ?? 0
await page.locator('[data-personal-refresh]').click()
await page.waitForFunction(() => document.querySelector('[data-personal-remaining]')?.getAttribute('data-personal-remaining') === '1',
null, { timeout: 5000 }).catch(() => {})
check('点「刷新」发 POST /refresh', state.writes.filter((w) => w.path === '/api/apps/app_mine/refresh').length === 1)
check('……剩余次数按回包变成 1', (await left.innerText()) === '今天还剩 1 次', await left.innerText())
check('……刷新后重取页面', (state.pageHits ?? 0) > hits, `${hits} → ${state.pageHits ?? 0}`)
await page.locator('[data-personal-refresh]').click()
await page.waitForFunction(() => document.querySelector('[data-personal-remaining]')?.getAttribute('data-personal-remaining') === '0',
null, { timeout: 5000 }).catch(() => {})
check('用完了:写「今天的刷新次数已用完」', (await left.innerText()).includes('今天的刷新次数已用完'), await left.innerText())
check('……按钮置灰,不再发', await page.locator('[data-personal-refresh]').isDisabled())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('刷新:服务端回 429 时照实说原话、按钮置灰;不知道剩几次时只说有上限', async () => {
const state = {
writes: [], detail: detailOf(undefined),
refreshes: [{ status: 429, json: { detail: '今天的刷新次数已用完(每天 5 次)', code: 'personal_refresh_limit' } }],
}
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
check('详情没给剩余次数:不猜,只说有上限', (await page.locator('[data-personal-remaining]').innerText()).includes('有上限'))
await page.locator('[data-personal-refresh]').click()
const toast = page.getByText('今天的刷新次数已用完(每天 5 次)')
check('429:照实写服务端的原话', await toast.first().waitFor({ timeout: 5000 }).then(() => true, () => false))
check('……按钮置灰', await page.locator('[data-personal-refresh]').isDisabled())
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 提交审核
await section('提交审核:选人才能交、带说明;提交后写「审核中」', async () => {
const state = { writes: [], refreshes: [], detail: detailOf({ daily_limit: 5, remaining_today: 5, review: null }) }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_mine`)
await bar(page).waitFor({ timeout: 15000 })
await page.locator('[data-personal-submit-review]').click()
const dlg = page.getByRole('dialog')
await dlg.locator('[data-personal-review-dialog]').waitFor({ timeout: 5000 })
check('写明搭建者在副本上发布、个人应用不会被改动', /副本/.test(await dlg.innerText()) && /不会被改动/.test(await dlg.innerText()))
check('没选人不让提交', await dlg.locator('[data-personal-review-submit]').isDisabled())
await dlg.getByRole('textbox', { name: '交给谁审核' }).fill('lisi')
await dlg.getByRole('button', { name: '添加' }).click()
await dlg.locator('#personal-review-note').fill('门店店长都想看')
await dlg.locator('[data-personal-review-submit]').click()
await page.locator('[data-personal-review="in_review"]').waitFor({ timeout: 5000 }).catch(() => {})
const sent = state.writes.find((w) => w.path === '/api/apps/app_mine/submit-review')
const body = sent ? JSON.parse(sent.body || '{}') : {}
check('POST /submit-review 带 {to, note}', JSON.stringify(body.to) === JSON.stringify([{ kind: 'user', name: 'lisi' }])
&& body.note === '门店店长都想看', JSON.stringify(body))
const status = page.locator('[data-personal-review="in_review"]')
check('提交后写「审核中」和交给了谁', /审核中/.test(await status.innerText()) && /lisi/.test(await status.innerText()),
await status.innerText().catch(() => ''))
check('……不再给「提交审核」', await page.locator('[data-personal-submit-review]').count() === 0)
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('审核状态:以服务端为准(审核中、已有正式版本、详情没给时看蓝图)', async () => {
const a = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 3, review: { task_id: 't1', status: 'pending', to: [{ kind: 'group', name: '搭建组' }] } }) }
const one = await open({ state: a })
await one.page.goto(`${WEB}/apps/app_mine`)
await bar(one.page).waitFor({ timeout: 15000 })
check('详情说审核中:写「审核中」和交给的组', /已提交给 搭建组 审核/.test(await one.page.locator('[data-personal-review="in_review"]').innerText()))
check('……没有「提交审核」', await one.page.locator('[data-personal-submit-review]').count() === 0)
const b = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 3,
review: { task_id: 't1', status: 'resolved', decision: 'published', app_id: 'app_refund' } }) }
const two = await open({ state: b })
await two.page.goto(`${WEB}/apps/app_mine`)
await bar(two.page).waitFor({ timeout: 15000 })
const pub = two.page.locator('[data-personal-review="published"]')
check('审核发布后写「已有正式版本」', /已有正式版本/.test(await pub.innerText()), await pub.innerText().catch(() => ''))
check('……「去看看」链到正式应用', await pub.locator('[data-personal-formal]').getAttribute('href') === '/apps/app_refund')
const c = { writes: [], refreshes: [], detail: detailOf(undefined), blueprintStatus: 'in_review' }
const three = await open({ state: c })
await three.page.goto(`${WEB}/apps/app_mine`)
await bar(three.page).waitFor({ timeout: 15000 })
await three.page.locator('[data-personal-review="in_review"]').waitFor({ timeout: 5000 }).catch(() => {})
check('详情没给审核状态:看蓝图(in_review 写「审核中」)', (c.blueprintHits ?? 0) >= 1
&& await three.page.locator('[data-personal-review="in_review"]').count() === 1)
check('没有未捕获的运行时错误', [one, two, three].every((x) => x.page.errors.length === 0),
[one, two, three].map((x) => x.page.errors[0]).filter(Boolean).join(' | '))
})
await section('窄屏:390px 不出横向滚动', async () => {
const state = { writes: [], refreshes: [], detail: detailOf({ remaining_today: 2, review: null }) }
const { page } = await open({ state, width: 390, height: 844 })
for (const path of ['/apps?tab=apps', '/apps/app_mine']) {
await page.goto(`${WEB}${path}`)
await page.waitForTimeout(800)
check(`${path} 没有横向滚动`, await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1))
}
})
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
process.exit(failed ? 1 : 0)
+223
View File
@@ -0,0 +1,223 @@
// 记录列表 records_list(docs/design/normal-user-apps.md N8、§3.7、§4.7)的回归检查。
//
// 守的是这些「看着没坏、其实不对」的地方:
// - 行级可见在服务端做,界面只照服务端说的写:使用者(mine_only)的标题是「我的记录」、写明只显示自己提交的、不列提交人;
// 查看全部的人标题是「全部记录」、每行写提交人;界面不自己按人过滤(服务端交来几行就列几行);
// - 数据取自 GET /api/apps/{id}/records(带 table),每次打开实时取,不走封存;服务端解析页面时给了第一页(node.data)
// 的照它画、不再取一遍;
// - 按时间排序(序号就是提交先后),默认最新的在前,可以换成最早的在前;按内容或提交人筛选,写明筛出几条;
// - 每行可以展开看全部字段,附件是可下载的链接(/api/files/{id});
// - 更正不改原行:更正过的行标「已更正」,展开能看更正历史(带 history=true 再取一次,顺着指向往前找),原来那条的
// 值照原样写;
// - 没写记录表的组件取这个应用的第一张记录表(同服务端),照样列出来;服务端说这个应用还没有记录表(state=empty)的
// 照实说,不报错。
//
// 页面是真页面(走真的应用外壳),/api/apps/** 在浏览器层答掉,数据是这里虚构的(「门店报损」);其余 GET 放行到服务端
// (外壳要它),写请求一律拦下、记在探针里,不碰真实写接口。
//
// node scripts/check-records.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-records.mjs
// CHECK_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'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${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 at = (h) => new Date(Date.UTC(2026, 9, 12, h)).toISOString()
const USER = { mode: 'local', username: 'zhangsan', display_name: '张三', role: 'user', groups: ['门店'], verified: true }
const LEAD = { mode: 'local', username: 'wangwu', display_name: '王五', role: 'builder', groups: ['区域'], verified: true }
// ---------------------------------------------------------------- 假数据
/** 服务端解析页面时给的第一页里的列(和接口给的一样,单独写一份免得引用顺序出错) */
const COLUMNS_FIRST = [{ name: 'store', label: '门店', type: 'string' }]
const APP = {
id: 'app_loss', name: '门店报损', description: '门店每日报损登记', icon: 'app', status: 'active', published_version: 2,
level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'use', updated_at: at(1), scope: 'shared',
}
const HOME = {
format: 'agentlab.view/1',
app: { id: 'app_loss', name: '门店报损', version: 2, level: 'published', needs_upgrade: false },
page: { id: 'home', title: '报损' },
pages: [{ id: 'home', title: '报损' }],
sources: {},
tree: [
{ id: 'my_records', type: 'records_list', props: { table: 'loss', title: '报损登记' } },
{ id: 'no_table', type: 'records_list', props: {} },
{ id: 'server_page', type: 'records_list', props: { who: 'mine' },
data: { table: 'loss', version: 1, columns: COLUMNS_FIRST, mine_only: true, items: [] } },
{ id: 'empty_page', type: 'records_list', props: {},
data: { table: null, version: 0, columns: [], items: [], mine_only: true, state: 'empty', next_cursor: null } },
],
forms: {},
problems: [],
}
const COLUMNS = [
{ name: 'store', label: '门店', type: 'string' },
{ name: 'amount', label: '金额(元)', type: 'number' },
{ name: 'reason', label: '原因', type: 'string' },
{ name: 'photo', label: '照片', type: 'file' },
]
const PHOTO = { file_id: 'f_photo', name: '破损.png', size: 20480, sha256: 'ab'.repeat(32), type: 'image/png' }
const row = (id, seq, by, values, extra = {}) => ({
_id: id, _seq: seq, _submitted_by: by, _submitted_at: at(seq), _supersedes: null, values, ...extra,
})
/** zhangsan 的三条:r3 是对 r1 的更正(r1 被更正过,只在历史里) */
const MINE_ROWS = [
row('r2', 2, 'zhangsan', { store: '南山店', amount: 80, reason: '过期', photo: null }),
row('r3', 3, 'zhangsan', { store: '南山店', amount: 120, reason: '运输破损', photo: PHOTO }, { _supersedes: 'r1' }),
row('r5', 5, 'zhangsan', { store: '南山店', amount: 45, reason: '试吃', photo: null }),
]
const R1 = row('r1', 1, 'zhangsan', { store: '南山店', amount: 210, reason: '运输破损', photo: null }, { superseded: true })
const ALL_ROWS = [...MINE_ROWS, row('r4', 4, 'lisi', { store: '福田店', amount: 60, reason: '过期', photo: null })]
// ---------------------------------------------------------------- 浏览器
const browser = await chromium.launch({ executablePath: CHROME })
async function open({ state, width = 1280, height = 900 }) {
const ctx = await browser.newContext({ viewport: { width, height } })
opened.add(ctx)
const page = await ctx.newPage()
page.errors = []
page.on('pageerror', (e) => page.errors.push(e.message))
await page.route((url) => url.pathname.startsWith('/api/'), (route) => {
const r = route.request()
const url = new URL(r.url())
const p = url.pathname
if (r.method() !== 'GET') {
state.writes.push({ method: r.method(), path: p })
return route.fulfill({ status: 503, json: { detail: '检查脚本拦下了写请求' } })
}
if (p === '/api/auth/me') return route.fulfill({ json: state.me })
if (p === '/api/tasks/count') return route.fulfill({ json: { mine: 0, apps: 0, other: 0 } })
if (p === '/api/apps') return route.fulfill({ json: { items: [APP] } })
if (p === '/api/apps/app_loss') return route.fulfill({ json: { ...APP, my_role: state.role, published: { version: 2, pages: HOME.pages, entries: [] } } })
if (p === '/api/apps/app_loss/pages/home') return route.fulfill({ json: HOME })
if (p === '/api/apps/app_loss/records') {
state.reads.push(url.search)
const mine = state.role === 'use'
const items = mine ? MINE_ROWS : ALL_ROWS
const history = url.searchParams.get('history') === 'true' || url.searchParams.get('history') === '1'
return route.fulfill({ json: { table: 'loss', version: 1, columns: COLUMNS, mine_only: mine,
items: history ? [R1, ...items] : items } })
}
return route.continue()
})
return { ctx, page }
}
const list = (page) => page.locator('[data-node="my_records"] [data-component="records_list"]')
const ids = (page) => list(page).locator('[data-record]').evaluateAll((els) => els.map((e) => e.getAttribute('data-record')))
// ---------------------------------------------------------------- 我的记录
await section('我的记录:使用者只看到自己的、标题写「我的记录」、按时间排序、筛选', async () => {
const state = { writes: [], reads: [], me: USER, role: 'use' }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_loss`)
await list(page).locator('[data-record]').first().waitFor({ timeout: 15000 })
check('数据取自记录接口、带上记录表', state.reads.some((q) => /[?&]table=loss/.test(q)), state.reads.join(' | '))
check('使用者的标题是「我的记录」', (await list(page).locator('[data-records-title]').innerText()) === '我的记录')
check('……写明只显示自己提交的(服务端 mine_only)', (await list(page).locator('[data-records-hint]').innerText()).includes('只显示你自己提交的记录')
&& await list(page).getAttribute('data-records-scope') === 'mine')
check('……不列提交人', !(await list(page).innerText()).includes('由「zhangsan」提交'))
check('服务端交来几行就列几行(界面不自己按人过滤)', (await ids(page)).length === 3)
check('默认最新的在前', (await ids(page)).join(',') === 'r5,r3,r2', (await ids(page)).join(','))
await list(page).locator('[data-records-sort]').selectOption('oldest')
check('换成最早的在前', (await ids(page)).join(',') === 'r2,r3,r5', (await ids(page)).join(','))
await list(page).locator('[data-records-filter]').fill('破损')
check('按内容筛选,写明筛出几条', (await ids(page)).join(',') === 'r3'
&& (await list(page).locator('[data-records-count]').innerText()) === '1 / 3 条', await list(page).locator('[data-records-count]').innerText())
await list(page).locator('[data-records-filter]').fill('没有这样的')
check('筛不出来时照实说', (await list(page).innerText()).includes('没有符合筛选条件的记录'))
await list(page).locator('[data-records-filter]').fill('')
// 展开看全部字段和附件
await list(page).locator('[data-record="r3"] > button').click()
const detail = list(page).locator('[data-record="r3"] [data-record-detail]')
check('展开看全部字段', await detail.locator('[data-record-field]').count() === 4
&& (await detail.locator('[data-record-field="reason"]').innerText()).includes('运输破损'))
const link = detail.locator('[data-record-field="photo"] a[data-file-link]')
check('附件是可下载的链接', await link.getAttribute('href') === '/api/files/f_photo' && (await link.innerText()).includes('破损.png'),
await link.getAttribute('href').catch(() => ''))
check('没写记录表的组件取第一张记录表,照样列出来', await page.locator('[data-node="no_table"] [data-record]').count() === 3
&& state.reads.some((q) => !/[?&]table=/.test(q)), state.reads.join(' | '))
check('服务端给了第一页的照它画(这里是空的,写「你还没有提交过记录」)',
(await page.locator('[data-node="server_page"]').innerText()).includes('你还没有提交过记录'))
check('服务端说还没有记录表的照实说', (await page.locator('[data-node="empty_page"]').innerText()).includes('还没有记录表'))
check('只读:没有发出写请求', state.writes.length === 0, state.writes.map((w) => w.path).join(','))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 更正
await section('更正:更正过的行标「已更正」,展开能看更正历史、原来那条照原样', async () => {
const state = { writes: [], reads: [], me: USER, role: 'use' }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_loss`)
await list(page).locator('[data-record]').first().waitFor({ timeout: 15000 })
const r3 = list(page).locator('[data-record="r3"]')
check('更正过的行标「已更正」', await r3.getAttribute('data-record-corrected') === '' && (await r3.innerText()).includes('已更正'))
check('没更正过的不标', !(await list(page).locator('[data-record="r2"]').innerText()).includes('已更正'))
check('被更正的原行默认不列(只列当前有效的)', await list(page).locator('[data-record="r1"]').count() === 0)
await r3.locator('> button').click()
await r3.locator('[data-record-history-toggle]').click()
const hist = r3.locator('[data-record-history]')
check('展开更正历史:带 history 再取一次', await hist.waitFor({ timeout: 5000 }).then(() => true, () => false)
&& state.reads.some((q) => /[?&]history=(true|1)/.test(q)), state.reads.join(' | '))
check('……列出被更正的那一条,值照原样', await hist.locator('[data-record-history-item="r1"]').count() === 1
&& (await hist.locator('[data-record-history-item="r1"] [data-record-field="amount"]').innerText()).includes('210'))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
// ---------------------------------------------------------------- 全部记录
await section('全部记录:查看全部的人看到所有人的、每行写提交人', async () => {
const state = { writes: [], reads: [], me: LEAD, role: 'observe' }
const { page } = await open({ state })
await page.goto(`${WEB}/apps/app_loss`)
await list(page).locator('[data-record]').first().waitFor({ timeout: 15000 })
check('标题写组件的标题(不是「我的记录」)', (await list(page).locator('[data-records-title]').innerText()) === '报损登记')
check('……写明能查看全部记录', (await list(page).locator('[data-records-hint]').innerText()).includes('全部记录')
&& await list(page).getAttribute('data-records-scope') === 'all')
check('列出别人提交的', (await ids(page)).includes('r4'))
check('每行写提交人', (await list(page).locator('[data-record="r4"]').innerText()).includes('由「lisi」提交'))
await list(page).locator('[data-records-filter]').fill('lisi')
check('按提交人筛选', (await ids(page)).join(',') === 'r4', (await ids(page)).join(','))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
})
await section('窄屏:390px 不出横向滚动', async () => {
const state = { writes: [], reads: [], me: USER, role: 'use' }
const { page } = await open({ state, width: 390, height: 844 })
await page.goto(`${WEB}/apps/app_loss`)
await list(page).locator('[data-record]').first().waitFor({ timeout: 15000 })
await list(page).locator('[data-record="r3"] > button').click()
check('展开一行后没有横向滚动', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1))
})
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 全部通过')
process.exit(failed ? 1 : 0)
+46 -1
View File
@@ -1,5 +1,5 @@
// 编排页编辑体验的检查:撤销重做、问题面板定位、快捷键、三栏收起、调色板落点、
// 分支 default 拦截、?run=&focus= 参数、助手改图(应用 / 只回话 / 失败 / 停止)。
// 分支 default 拦截、?run=&focus= 参数、助手改图(应用 / 只回话 / 失败 / 停止 / 反问)。
//
// 不写库:要编辑的工作流、它的版本、画布会话、助手的流式输出都用 page.route 在
// 浏览器里伪造;后端只被读(目录、校验、变量分析这些纯计算)。保存走伪造的 PATCH,
@@ -1290,6 +1290,51 @@ await section('助手改图:一步撤销、回执、只回话、失败退回
await ctx.close()
})
// ================================================================ 6b
// 助手问回来一句、流就此结束(normal-user-apps N11):这一轮照样记进画布会话,问题作为这一轮的回话进历史,下一轮模型知道
// 自己问过什么;卡头写「助手反问」,不是「未修改画布」那种没下文。画布不动、不占撤销栈
await section('助手反问:这一轮照样记下,问题进历史', async () => {
const { page, state, errors } = await open()
const turns = []
/** 等页面这一侧或脚本这一侧的条件成立 */
const until = async (cond, ms = 3000) => {
const end = Date.now() + ms
while (!(await cond()) && Date.now() < end) await new Promise((r) => setTimeout(r, 50))
return cond()
}
await page.route(/\/api\/conversations\/[^/]+\/turns(\/[^/?]+)?$/, (route) => {
const req = route.request()
const body = req.postDataJSON() ?? {}
turns.push({ method: req.method(), body })
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
id: 't-ask', seq: 3, question: body.question ?? '', answer: body.answer ?? '', explanation: '', status: 'done', error: '' }) })
})
const before = await st(page)
const mem0 = await S(page, () => window.__studio.getState().copilotMemory.past.length)
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 300 },
{ op: 'ask', question: '分支按客户等级分,还是按订单金额分?' },
]
await S(page, () => window.__studio.getState().runCopilot('加一个分支', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const t = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
check('反问:这一轮收尾为「已回答」,问题作为回话', t.phase === 'done' && t.outcome === 'answered'
&& t.reply === '分支按客户等级分,还是按订单金额分?', JSON.stringify({ phase: t.phase, outcome: t.outcome, reply: t.reply }))
check('反问:画布没动、不占撤销栈', (await st(page)).nodes.length === before.nodes.length && (await st(page)).past === before.past)
const patched = await until(() => turns.some((x) => x.method === 'PATCH'))
const body = turns.find((x) => x.method === 'PATCH')?.body
check('反问:这一轮记进画布会话,问题进历史(answer)', patched && body?.answer === '分支按客户等级分,还是按订单金额分?'
&& body?.status === 'done', JSON.stringify(body))
check('反问:模型记忆跟着多一轮', await until(async () =>
(await S(page, () => window.__studio.getState().copilotMemory.past.length)) > mem0))
const card = page.locator('[data-assistant-panel] [data-turn]').last()
check('反问:卡头写「助手反问」,不写「未修改画布」', await card.waitFor({ timeout: 3000 }).then(() => true, () => false)
&& (await card.innerText()).includes('助手反问') && !(await card.innerText()).includes('未修改画布'),
(await card.innerText().catch(() => '')).split('\n').slice(0, 4).join(' / '))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
// ================================================================ 7
await section('入口:?run=&focus=、选择器、发布弹窗', async () => {
const { ctx, page } = await open({ path: `/studio/st-main?run=${FAKE_RUN}&focus=answer` })