前端:审批卡按审批当场拼出待办(不再为一张卡取一次待办,收到中断就出卡;有 task_id 走待办接口,没有走老的审批接口);矮窗口导航两档高度不再叠加,480px 放得下九项;待办数随目录启动时就取、外壳订阅时当场记底,派给自己的新待办通知不再漏;头一回取目录的卡住从启动算起;外壳横向裁掉悬停提示的占位;证据抽屉同一指标在取时不另发请求;编辑器去掉第二个 main、检查一次只跑一遍;记录页对不上待办的审批照旧画审批卡,待办行写 runName 和节点名;检查脚本按新页面更新断言(沙箱没有待办时用合成的两条、未保存与已删除工作流分开认、生效记录页签、能力子页签)

This commit is contained in:
ThearchyHelios
2026-10-14 23:57:43 +08:00
parent 49f6f42df2
commit c6a29bfd7c
21 changed files with 355 additions and 154 deletions
+4 -2
View File
@@ -285,11 +285,13 @@ await section('检查:和发布同一套规则,点一条定位到组件', as
check('列出错误和提醒', await waitFor(page, async () => await issues.count() === 3))
await page.locator('[data-app-issue="app.binding_target_missing"] [data-app-issue-locate]').click()
await page.waitForURL('**/edit/pages')
check('点「定位」:跳到页面一节、选中那个组件(点号加下标的 path)', await page.locator('[data-app-inspector="gmv"]').count() === 1)
// 页面一节要等组件目录到了才画页面树和属性面板:等它出来再判,别读到骨架屏
const shows = (sel) => page.locator(sel).waitFor({ timeout: 4000 }).then(async () => await page.locator(sel).count() === 1, () => false)
check('点「定位」:跳到页面一节、选中那个组件(点号加下标的 path)', await shows('[data-app-inspector="gmv"]'))
await page.goto(`${WEB}/apps/app-ae/edit/check`, { waitUntil: 'networkidle' })
await page.locator('[data-app-issue="app.expr_arith"] [data-app-issue-locate]').click()
await page.waitForURL('**/edit/pages')
check('JSON 指针的 path 也认得', await page.locator('[data-app-inspector="warn"]').count() === 1)
check('JSON 指针的 path 也认得', await shows('[data-app-inspector="warn"]'))
})
await section('发布:固定版本升了版,必须为每一项选处置', async () => {
+4 -2
View File
@@ -945,9 +945,11 @@ await section('正式运行:入口看 published_version;运行期间画布
await page.waitForTimeout(400)
const pop = (await page.locator('.sf-pop').innerText()).replace(/\s+/g, ' ')
check('提示不含画布改动', pop.includes('不含画布改动'), pop.slice(0, 80))
// 发起表单统一用 SchemaForm 画(X2):字段按名字挂 data-field,id 是 run-formal-<字段名>
check('表单字段取已发布版本的(topic),不取画布的(q)',
await page.locator('.sf-pop textarea#run-field-topic').count() === 1
&& await page.locator('.sf-pop textarea#run-field-q').count() === 0)
await page.locator('.sf-pop [data-launch-form] [data-field="topic"] #run-formal-topic').count() === 1
&& await page.locator('.sf-pop [data-field="q"]').count() === 0,
(await page.locator('.sf-pop [data-field]').evaluateAll((els) => els.map((e) => e.getAttribute('data-field')))).join(','))
const formalPop = await marks('.sf-pop')
check('正式运行浮层的标题和确认按钮都是实心圆点', formalPop.formal === 2 && formalPop.exploratory === 0 && formalPop.shield === 0,
JSON.stringify(formalPop))
+8 -3
View File
@@ -191,13 +191,17 @@ const browser = await chromium.launch({ executablePath: CHROME })
// ================================================================
await section('能力列表:效果、筛选、同步', async () => {
const { page, state, errors } = await open('/tools/capabilities')
check('工具页有「能力」页签', await page.locator('[role=tab]', { hasText: '能力' }).count() === 1)
// 「能力」页签里还有「全部能力 / 待审阅」两个子页签(D-i),按名字整字匹配
check('工具页有「能力」页签', await page.getByRole('tab', { name: '能力', exact: true }).count() === 1,
(await page.locator('[role=tab]').allInnerTexts()).join(' | '))
check('列出全部能力', await page.locator('[data-capability]').count() === CAPS.length)
const pay = page.locator('[data-capability="erp_pay"] [data-capability-effect]')
check('治理后的效果和同步得出的不一致时标出来', (await pay.innerText()).includes('写入') && (await pay.innerText()).includes('*'))
check('没治理过的按同步得出的效果显示', (await page.locator('[data-capability="erp_query"] [data-capability-effect]').innerText()).includes('只读'))
await page.locator('[data-capability="mcp:crm/find"]').click()
check('能力名带斜杠也能打开(地址转义)', await page.locator('[data-capability-editor="mcp:crm/find"]').count() === 1)
// 右栏的治理设置是点开之后另取的:等它出来再判
check('能力名带斜杠也能打开(地址转义)', await waitFor(page, async () =>
await page.locator('[data-capability-editor="mcp:crm/find"]').count() === 1), page.url())
await page.locator('[data-capability-sync]').click()
check('立即同步', await waitFor(page, async () => state.syncs === 1))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
@@ -273,7 +277,8 @@ await section('能力:待审阅复用数据目录的审阅队列,成批确
check('冲突的那项照实标出(说的是能力,不是表)', await waitFor(page, async () =>
(await page.locator('[data-review-summary]').innerText()).includes('所在能力刚被修改过')))
await rows.first().locator('[data-review-open]').click()
check('点开一项:右栏是这个能力的治理设置,标出那个位置', await page.locator('[data-capability-editor="erp_pay"] [data-capability-focus="/amount"]').count() === 1)
check('点开一项:右栏是这个能力的治理设置,标出那个位置', await waitFor(page, async () =>
await page.locator('[data-capability-editor="erp_pay"] [data-capability-focus="/amount"]').count() === 1), page.url())
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
+6 -3
View File
@@ -1994,14 +1994,17 @@ await section('应用平台:发布成功做成应用;已发布版本的使
})
await page.route(/\/api\/ledger\/workflow-versions\/.+$/, (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(usage) }))
await page.keyboard.press('Alt+h')
// 编排页一进来焦点在助手输入框里,那时的 ⌥H 是打字:先把焦点放回页面(同 check-studio)
await page.evaluate(() => document.activeElement?.blur?.())
await page.keyboard.press('Alt+KeyH')
const sheet = page.locator('[role="dialog"][aria-label="版本历史"]')
await sheet.waitFor()
check('版本列表标出已退役的版本', await sheet.locator('[data-version-retired-chip]').count() === 1)
await sheet.locator('ol button', { hasText: 'v4' }).first().click()
const cur = sheet.locator('[data-version-lifecycle="4"]')
check('展开当前发布版:写使用情况', await waitFor(page, async () => await cur.count() === 1)
&& (await cur.innerText()).includes('被 1 个应用固定'))
// 使用情况是展开之后另取的(账本接口):等它回来再读,别读到占位的「…」
check('展开当前发布版:写使用情况', await waitFor(page, async () => await cur.count() === 1
&& (await cur.innerText().catch(() => '')).includes('被 1 个应用固定')), await cur.innerText().catch(() => ''))
check('……当前发布版的退役按钮不可点,写明原因', await cur.locator('[data-version-retire]').isDisabled()
&& (await cur.innerText()).includes('当前发布版不能退役'))
await sheet.locator('ol button', { hasText: 'v3' }).first().click()
+71 -12
View File
@@ -131,6 +131,28 @@ const listTop = await getJson('/runs?limit=50')
const wfHost = (await getJson('/workflows'))[0]
const base = allRuns.find((r) => r.status === 'succeeded' && !r.workflow_id)
/**
* 合成的两条待处理待办(沙箱里一条都没有时用):挂在沙箱里真实的运行上——最近的一条,和排在第 100 名之后的一条
* (原来只取 100 条时找不到的那种)。一条等了 8 天,一条刚来
*/
function syntheticTasks() {
const picks = [allRuns[0], allRuns[Math.min(allRuns.length - 1, 120)]].filter((r, i, a) => r && a.indexOf(r) === i)
return picks.map((r, i) => {
const at = new Date(Date.now() - (i ? 8 * 86400_000 : 3 * 60_000)).toISOString()
return { id: `fake0task0list${i}`, approval_id: `fake0appr0list${i}`, run_id: r.id, node_id: 'gate', node_label: '人工审批',
run_class: r.run_class ?? 'exploratory', workflow_id: r.workflow_id ?? null, workflow_name: r.workflow_name || '订单日报',
app_id: null, app_version: null, kind: i ? 'input' : 'approve', title: i ? '补一下预算上限' : '这份报表可以发出吗?', form: null,
policy: { kind: 'any' }, status: 'pending', due_at: null, escalation_level: 0, payload: {}, can_respond: true,
assignments: [{ id: `fake0as${i}`, principal_kind: 'anyone', principal: '', source: 'anyone', status: 'pending',
responded_by: null, responded_at: null, verified: false }],
outcome: null, resolved_by: null, resolved_at: null, created_at: at }
})
}
const syntheticApprovals = (tasks) => tasks.map((t) => ({ id: t.approval_id, run_id: t.run_id, node_id: t.node_id,
mode: t.kind === 'input' ? 'input' : 'approve', title: t.title, payload: { kind: 'human_node' }, status: 'pending', response: {},
created_at: t.created_at, workflow_id: t.workflow_id, workflow_name: t.workflow_name, node_label: t.node_label,
run_status: 'interrupted', run_class: t.run_class, task_id: t.id }))
// ------------------------------------------------------------------ 页签
await section('页签与地址', async () => {
@@ -182,30 +204,50 @@ await section('待办页签', async () => {
ws.send(JSON.stringify({ seq: lastSeq + 2, type: 'node.started', node_id: target.node_id, ts: t + 0.05, data: { resumed: true } }))
})
}
// 沙箱是开发库的拷贝:手上的待办刚好都处理完时一条都没有,列表这几项就只剩空态可看。那时拿两条合成的待办来验列表
// (挂在沙箱里真实的运行上,一条排在最近 100 条之外);点进详情、别处批掉这几段要真待审批,照旧只在有的时候跑
const synthetic = !pendingTasks.length && allRuns.length > 0
const listTasks = synthetic ? syntheticTasks() : pendingTasks
if (synthetic) {
console.log(' · 沙箱里没有待处理的待办:列表这几项用合成的 2 条')
fakes.set('GET /api/tasks', (url) => ({ status: 200, json: { items: url.searchParams.get('run_id') ? [] : listTasks } }))
fakes.set('GET /api/approvals', (url) => ({ status: 200, json: url.searchParams.get('run_id') ? [] : syntheticApprovals(listTasks) }))
}
await page.goto(`${WEB}/runs?tab=approvals`, { waitUntil: 'networkidle' })
await page.waitForTimeout(400)
check('页签叫「待办」', (await page.locator('[role=tab][data-tab=approvals]').innerText()).includes('待办'))
const taskRows = page.locator('[data-tasks-list] [data-task-id]')
const shownTasks = await taskRows.evaluateAll((els) => els.map((e) => e.getAttribute('data-task-id')))
check('沙箱里不属于应用的待办都在页签里', pendingTasks.length > 0 && pendingTasks.every((t) => shownTasks.includes(t.id)),
`API ${pendingTasks.length} 条 / 页面 ${shownTasks.length} 条`)
check(synthetic ? '不属于应用的待办都在页签里(合成的 2 条)' : '沙箱里不属于应用的待办都在页签里',
listTasks.length > 0 && listTasks.every((t) => shownTasks.includes(t.id)),
`API ${listTasks.length} 条 / 页面 ${shownTasks.length} 条`)
check('存量的待审批都补建了待办', pending.every((a) => pendingTasks.some((t) => t.approval_id === a.id)),
`${pending.filter((a) => !pendingTasks.some((t) => t.approval_id === a.id)).length} 条没有对应的待办`)
const deep = pendingTasks.filter((t) => allRuns.findIndex((r) => r.id === t.run_id) >= 100)
const deep = listTasks.filter((t) => allRuns.findIndex((r) => r.id === t.run_id) >= 100)
check('排在最近 100 条之外的待办也看得到(原来的死路)', deep.every((t) => shownTasks.includes(t.id)),
`${deep.length} 条在第 100 名之后`)
check('每条都写着等了多久', await page.locator('[data-tasks-list] [data-task-age]').count() === shownTasks.length)
check('每条标着待办的种类', await page.locator('[data-tasks-list] [data-task-kind-chip]').count() === shownTasks.length)
if (pendingTasks.length) {
const rowText = await page.locator(`[data-task-id="${pendingTasks[0].id}"]`).innerText()
const hasWf = rowText.includes(pendingTasks[0].workflow_name ?? '')
if (listTasks.length) {
const first = listTasks[0]
const rowText = await page.locator(`[data-task-id="${first.id}"]`).innerText()
// 应用里的写应用名;没保存就跑的写「未保存的工作流」、不露「临时图」(同审批行的 runName)
const unsaved = !first.workflow_id && (!first.workflow_name || ['临时图', '未保存的工作流'].includes(first.workflow_name))
const wfName = first.app_name || (unsaved ? '未保存的工作流' : first.workflow_name ?? '')
const hasWf = rowText.includes(wfName) && !rowText.includes('临时图')
const hasNode = rowText.includes(`「${first.node_label ?? first.node_id}」`)
// 名字来自沙箱库,只报在不在
check('每条写着工作流名', hasWf, `工作流名 ${hasWf ? '在' : '缺'}`)
check('每条写着工作流名和节点(节点写名字,不写 id)', hasWf && hasNode,
`工作流名 ${hasWf ? '在' : '缺'} · 节点 ${hasNode ? '在' : '缺'}`)
check('每条有处理入口', rowText.includes('去处理'))
}
check('列表下面写明应用里的待办在哪里处理', (await page.locator('[data-tasks-list]').innerText().catch(() => '')).includes('应用 → 待办'))
const badge = await page.locator('[role=tab][data-tab=approvals]').innerText()
check('页签上的数和待办条数一致', badge.includes(String(pendingTasks.length)), badge.replace(/\s+/g, ' '))
check('页签上的数和待办条数一致', badge.includes(String(listTasks.length)), badge.replace(/\s+/g, ' '))
if (synthetic) {
fakes.delete('GET /api/tasks')
fakes.delete('GET /api/approvals')
}
if (pending.length) {
const target = pending[0]
const task = pendingTasks.find((t) => t.approval_id === target.id)
@@ -1563,8 +1605,21 @@ await section('工作流筛选:未保存的工作流', async () => {
if (wfOptions.includes('__none__')) {
await listAfter(() => page.locator('[data-runs-workflow]').selectOption('__none__'), (p) => p.get('workflow_id') === '__none__')
check('地址记下 ?wf=__none__', new URL(page.url()).searchParams.get('wf') === '__none__')
const names = await page.locator('[data-runs-list] [data-run-id] > div:first-of-type').evaluateAll((els) => els.map((e) => e.textContent ?? ''))
check('筛出来的都是未保存的工作流', names.length > 0 && names.every((n) => n.includes('未保存的工作流')), `${names.length} 行`)
const rows = await page.locator('[data-runs-list] [data-run-id]').evaluateAll((els) => els.map((e) => ({
id: e.getAttribute('data-run-id'), name: e.querySelector(':scope > div:first-of-type')?.textContent ?? '' })))
// 没有工作流的运行有两种:没保存就跑的(记的名字是「临时图」、空,或已经是「未保存的工作流」),和工作流后来删掉了的
// (记着当时的名字)。前一种一律写「未保存的工作流」、不露「临时图」;后一种照记下的名字写。名字来自沙箱库,只报条数
const byId = new Map((await getJson('/runs?workflow_id=__none__&limit=200')).map((r) => [r.id, r]))
const unsavedName = (r) => !r.workflow_name || ['临时图', '未保存的工作流'].includes(r.workflow_name)
const wrong = rows.filter(({ id, name }) => {
const r = byId.get(id)
if (!r || r.workflow_id) return true
return unsavedName(r) ? !name.includes('未保存的工作流') : !name.includes(r.workflow_name)
})
check('筛出来的都是没有工作流的运行:没保存就跑的写「未保存的工作流」,不露「临时图」',
rows.length > 0 && !wrong.length && rows.every((r) => !r.name.includes('临时图'))
&& rows.some((r) => r.name.includes('未保存的工作流')),
`${rows.length} 行 · ${rows.filter((r) => unsavedName(byId.get(r.id) ?? {})).length} 行是没保存就跑的 · ${wrong.length} 行不对`)
await page.locator('[data-runs-workflow]').selectOption('')
await settle()
}
@@ -1886,8 +1941,10 @@ await section('证据页签:左边报告、右边常驻面板、下方审计
})
await page.goto(`${WEB}/runs/${EV}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail]').waitFor()
// 应用平台在工件后面加了「生效记录」(§3.8)
const tabOrder = (await page.locator('[data-run-detail] [role=tab]').evaluateAll((els) => els.map((e) => e.getAttribute('data-tab')))).join(',')
check('详情多了「证据」页签(在航迹和工件之间)', await viewTab('evidence').count() === 1
&& (await page.locator('[data-run-detail] [role=tab]').evaluateAll((els) => els.map((e) => e.getAttribute('data-tab')))).join(',') === 'stream,trace,evidence,artifacts')
&& tabOrder === 'stream,trace,evidence,artifacts,effects', tabOrder)
check('没打开证据页签之前不取证据图和审计表', !evRequests.some((u) => u.includes(EV)), evRequests.join(' '))
await viewTab('evidence').click()
await page.locator('[data-view-pane=evidence] [data-evidence-pane=cited]').waitFor({ timeout: 8000 }).catch(() => {})
@@ -2294,7 +2351,9 @@ await section('待办页签:审批带着 task_id,新来的审批按 id 补
const byId = []
const onRequest = (req) => {
const u = new URL(req.url())
if (u.pathname === '/api/tasks') lists.push(u.search)
// 只数这个页签的整表重取(assigned=all)。外壳的新待办通知按「派给我的」(assigned=me)取,那是另一回事:
// 它在派给我的待办数变了、或刚打开页面记底时取一次
if (u.pathname === '/api/tasks' && u.searchParams.get('assigned') !== 'me') lists.push(u.search)
const m = u.pathname.match(/^\/api\/tasks\/([^/]+)$/)
if (m && m[1] !== 'count') byId.push(m[1])
}
+10 -2
View File
@@ -92,11 +92,13 @@ async function open({ theme = 'dark', state }) {
// 主题以本地为准,别让沙箱库里存的偏好把这次检查的主题改掉
if (url.pathname === '/api/settings') return route.fulfill({ json: { ui: { theme } } })
if (url.pathname === '/api/approvals') return route.fulfill({ json: state.approvals })
// 导航上的两个徽标按来源分开数:「记录」数不属于应用的(other),「应用」数应用里的(apps)。这里的待审批都不属于应用
// 导航上的两个徽标按来源分开数:「记录」数不属于应用的(other),「应用」数应用里的(apps)。这里的待审批都不属于应用。
// latest 同服务端:派给我的待处理待办里最新一条的开出时间(数不变、却换了一条时,新待办通知看它)
if (url.pathname === '/api/tasks/count') {
const other = state.approvals.filter((a) => a.status === 'pending').length
const apps = state.appTasks ?? 0
return route.fulfill({ json: { mine: state.tasks ? state.tasks.length : other + apps, apps, other } })
const latest = state.tasks?.length ? state.tasks.map((t) => t.created_at).sort().at(-1) : null
return route.fulfill({ json: { mine: state.tasks ? state.tasks.length : other + apps, apps, other, latest } })
}
// 派给我的待办(外壳只在待办数变了时取一次,拿来判断哪些是新来的)
if (url.pathname === '/api/tasks' && state.tasks) return route.fulfill({ json: { items: state.tasks } })
@@ -566,6 +568,12 @@ await section('新的待办', '\n=== 派给自己的新待办:后台时发系
const notes = await page.evaluate(() => window.__notes)
check('已按待审批通知过的那一条不再发第二条', notes.some((n) => n.tag === 'approval:shell-ap-task')
&& !notes.some((n) => n.tag === 'task:shell-task-ap'), notes.map((n) => n.tag).join(','))
// 数不变、却换了一条(核实的那条处理掉了,又来一条新的):派给我的还是 2 条,靠最新一条的时间(latest)认出来
state.tasks = [state.tasks[1], taskOf('shell-task-swap', { title: '退款结果未知,请核实', created_at: new Date().toISOString() })]
const swapped = await page.waitForFunction(() => window.__notes.some((n) => n.tag === 'task:shell-task-swap'), null, { timeout: 12000 })
.then(() => true, () => false)
check('数不变、换了一条:按最新一条的时间认出新来的,照样发通知', swapped,
(await page.evaluate(() => window.__notes.map((n) => n.tag))).join(','))
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
await ctx.close()
})
+2 -1
View File
@@ -2901,7 +2901,8 @@ await section('应用平台:人工节点的流程配置、入口字段的类
nodes: [
node('in', 'input', 0, 200, '入口', { fields: [{ name: 'amount', required: true }, { name: 'kind' }] }),
node('h', 'human', 300, 200, '财务审批', { mode: 'approve', title: '审批', message: '{{ input.amount }}' }),
node('pay', 'tool', 600, 200, '付款', { args: {} }),
// 选了工具:调用工具节点没选工具时「运行」按钮被挡住(错误级问题),发起表单就打不开
node('pay', 'tool', 600, 200, '付款', { tool: 'http_request', args: {} }),
node('out', 'output', 900, 200, '成果', { fields: [{ name: '结果', value: '{{ last_message }}' }] }),
],
edges: [edge('in', 'h'), edge('h', 'pay', 'approved'), edge('pay', 'out')],
+8 -1
View File
@@ -118,7 +118,14 @@ async function countNotice(page, text, settle = 1200) {
}
await section('画布助手栏', async () => {
const { page, errors } = await visit('/studio')
// 守的是空态(还没跟助手说过话):/studio 落到最近改过的那张图,真数据里它常常已经有几轮对话,空态就不出来。
// 这张图的助手对话按「没有」回,和一张新图一样(建对话的 POST 照旧被写闸门掐掉)
const { page, errors } = await newPage()
await page.route((url) => url.pathname === '/api/conversations' && url.searchParams.get('kind') === 'canvas',
(route) => (route.request().method() === 'GET'
? route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }) : route.fallback()))
await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
await page.waitForTimeout(700)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
const body = await page.locator('body').innerText()