Files
AgentLab/scripts/check-publish.mjs
T
ThearchyHelios fcdcc30d4e 可点击证据第二期:取数链路打通、问数据的答案逐段可点;发布前自动修复
一、可点击证据第二期

取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
  表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
  再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号

证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
  遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
  和子工作流同一套规则;子工作流节点也补上了升版处置的配置项

Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
  模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口

二、发布前自动修复

- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
  Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽

验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
2026-09-28 21:20:04 +08:00

826 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 发布前检查与自动修复的检查:发布弹窗打开就列问题、逐条修(auto / choice / assist)、
// 先预览再「应用并重新检查」、问题面板里的「发布前检查」、老后端(两个接口 404)照旧。
//
// 不写库:工作流、保存、发布、publish-check、autofix 全在浏览器里用 page.route 伪造。
// 伪造的检查接口按请求里的图现算(下面的 lint),所以修完、存完、再查,结论会真的变;
// 保存的载荷、autofix 的请求体都拦下来核对。其余写请求一律 409 并记账,检查结束时必须是空的。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时
// 带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-publish.mjs
// PUBLISH_ONLY=choice,老后端 只跑段名里含这些字的段
// PUBLISH_SHOTS=<目录> 几处界面各截一张亮、暗(含 360px)
import { mkdirSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const ONLY = (process.env.PUBLISH_ONLY ?? '').split(',').filter(Boolean)
const SHOTS = process.env.PUBLISH_SHOTS ?? ''
if (SHOTS) mkdirSync(SHOTS, { recursive: true })
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
// ---------------------------------------------------------------- 夹具(通用示例名)
const node = (id, type, x, label, config = {}) => ({ id, type, position: { x, y: 120 }, data: { label, config } })
const chain = (...ids) => ids.slice(1).map((t, i) => ({ id: `e_${ids[i]}_${t}`, source: ids[i], target: t, sourceHandle: null, label: '' }))
/** 受管模板的样子:审批全放行、子工作流没钉版本、契约缺 metrics_from 和 required、有协作团队、契约不 strict */
const HARD = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('fetch', 'agent', 240, '取数', { prompt: '查 orders 本周的 gmv', tools: ['db_query__shop'], approval: 'never' }),
node('sub', 'subgraph', 480, '方法卡', { workflow_id: 'pf-lib', input: {} }),
node('caliber', 'metrics', 720, '周报口径', { caliber: '周报口径', caliber_version: 'v1',
metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }, { id: 'orders', name: '订单数', expression: 'vars.kpi.orders' }] }),
node('caliber2', 'metrics', 960, '补充口径', { caliber: '补充口径', caliber_version: 'v1',
metrics: [{ id: 'aov', name: '客单价', expression: 'vars.kpi.gmv / vars.kpi.orders' }] }),
node('team', 'supervisor', 1200, '复核团队', { goal: '复核', max_rounds: 4, agents: [{ name: 'checker', tools: [] }] }),
node('done', 'output', 1440, '成果', { fields: [{ name: 'report', value: '{{ vars.report }}' }],
contract: { narrative: '{{ vars.report }}', strict: false } }),
],
edges: chain('in', 'fetch', 'sub', 'caliber', 'caliber2', 'team', 'done'),
}
/** 只有一处能自动修的错(审批)加一条能自动修的提示(strict):一键修完就能发 */
const EASY = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('fetch', 'agent', 240, '取数', { prompt: '查 orders 本周的 gmv', tools: ['db_query__shop'], approval: 'never' }),
node('caliber', 'metrics', 480, '周报口径', { caliber: '周报口径', caliber_version: 'v1',
metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }] }),
node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ vars.report }}' }],
contract: { metrics_from: ['caliber'], narrative: '{{ vars.report }}', required: ['gmv'], strict: false } }),
],
edges: chain('in', 'fetch', 'caliber', 'done'),
}
/** 受管、唯一的出口没有契约:图级问题(没有节点),修复却落在那个出口上——生成契约骨架,required 要人选 */
const BARE = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('caliber', 'metrics', 240, '周报口径', { caliber: '周报口径', caliber_version: 'v1',
metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }, { id: 'orders', name: '订单数', expression: 'vars.kpi.orders' }] }),
node('write', 'report', 480, '报告撰写', { instructions: '写周报' }),
node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ nodes.write.text }}' }] }),
],
edges: chain('in', 'caliber', 'write', 'done'),
}
/**
* 同一个修复挂在好几条问题上:两个 agent 都没写审批、跟随全图默认的「全部自动放行」(问题各在节点上,
* 修复只有一条、落在全图上);契约缺 metrics_from,validate 和门禁各报一条(同 code、同节点、同一个修复)
*/
const DUP = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('ask1', 'agent', 240, '查数员', { prompt: '查 orders 本周的 gmv', tools: ['db_query__shop'] }),
node('ask2', 'agent', 480, '复核员', { prompt: '复核 orders 本周的 gmv', tools: ['db_query__shop'] }),
node('caliber', 'metrics', 720, '周报口径', { caliber: '周报口径', caliber_version: 'v1',
metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }] }),
node('done', 'output', 960, '成果', { fields: [{ name: 'report', value: '{{ vars.report }}' }],
contract: { narrative: '{{ vars.report }}', required: ['gmv'], strict: true } }),
],
edges: chain('in', 'ask1', 'ask2', 'caliber', 'done'),
}
const wf = (id, name, graph, extra = {}) => ({
id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 3, is_template: false,
status: 'draft', published_version: null, published_by: null, run_count: 0,
created_at: '2026-09-20T02:00:00Z', updated_at: '2026-09-26T02:00:00Z', ...extra,
})
const FAKES = {
'pf-hard': wf('pf-hard', '__publish_check_hard__', HARD, { status: 'governed', published_version: 2 }),
'pf-easy': wf('pf-easy', '__publish_check_easy__', EASY),
'pf-lib': wf('pf-lib', '__publish_check_lib__', { nodes: [], edges: [] }, { status: 'published', published_version: 4 }),
'pf-bare': wf('pf-bare', '__publish_check_bare__', BARE, { status: 'governed', published_version: 2 }),
'pf-dup': wf('pf-dup', '__publish_check_dup__', DUP, { status: 'governed', published_version: 2 }),
// 带全图默认的图:画布只管节点和连线,保存、检查、修复都得把 defaults 原样带着
'pf-defs': wf('pf-defs', '__publish_check_defaults__', { ...EASY, defaults: { approval: 'always', model: 'demo-model' } }),
}
const clone = (x) => JSON.parse(JSON.stringify(x))
const who = (n) => `「${n.data.label}」`
/**
* 伪造的门禁:按请求里的图现算,形状照 PF-SPEC(issues 带 code 和 fix,fixes 列修法)。
* 已发布档只给提示,受管档有错。修法的 id 是 code:节点(图级的是 code:graph)。
* followsNever:当作这张图的全图默认是「全部自动放行」(画布上不带 defaults,按工作流认)
*/
function lint(graph, level, { followsNever = false } = {}) {
const hard = level === 'governed'
const issues = []
const fixes = []
const add = (n, code, message, fix, isError = true) => {
const id = `${code}:${n.id}`
issues.push({ level: isError && hard ? 'error' : 'warning', node_id: n.id, code, message, fix: fix ? id : null, field: null })
if (fix) fixes.push({ id, code, node_id: n.id, ...fix })
}
const cards = graph.nodes.filter((n) => n.type === 'metrics')
const outputs = graph.nodes.filter((n) => n.type === 'output')
// 图级:唯一的出口没有契约。问题不挂节点,修复落在那个出口上(和后端 autofix._target 一样)
if (hard && outputs.length === 1 && !outputs[0].data.config?.contract) {
const id = `governed.no_contract:${outputs[0].id}`
issues.push({ level: 'error', node_id: null, code: 'governed.no_contract', fix: id, field: null,
message: '受管模板至少要有一个「成果 / 出具」节点声明出具契约' })
fixes.push({ id, code: 'governed.no_contract', node_id: outputs[0].id, kind: 'choice', multiple: true,
label: `给${who(outputs[0])}生成出具契约,再选出必需的指标`,
options: cards.flatMap((m) => m.data.config.metrics.map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` }))) })
}
for (const n of graph.nodes) {
const c = n.data.config ?? {}
if (followsNever && n.type === 'agent' && !c.approval && (c.tools ?? []).length) {
const id = 'governed.default_approval_never:graph'
issues.push({ level: hard ? 'error' : 'warning', node_id: n.id, code: 'governed.default_approval_never', fix: id, field: 'approval',
message: `${who(n)}(Agent)没写自己的审批策略,跟随全图默认的「全部自动放行」` })
if (!fixes.some((f) => f.id === id)) {
fixes.push({ id, code: 'governed.default_approval_never', node_id: null, kind: 'auto', label: '把全图默认的审批策略改成「仅危险工具需要审批」',
preview: { field: 'defaults.approval', before: 'never', after: 'dangerous' } })
}
}
if (n.type === 'agent' && c.approval === 'never' && (c.tools ?? []).length) {
add(n, 'governed.agent_approval_never', `${who(n)}(Agent)的审批策略是「全部自动放行」,受管模板要求危险工具至少人工审批`,
{ kind: 'auto', label: '把审批策略改成「仅危险工具需要审批」', preview: { field: 'approval', before: 'never', after: 'dangerous' } })
}
if (n.type === 'subgraph' && !c.workflow_version) {
add(n, 'governed.subgraph_unpinned', `${who(n)}(子工作流)没有钉住版本,口径会随上游最新版漂移`,
{ kind: 'auto', label: '钉到它最新的已发布版本 v4', preview: { field: 'workflow_version', before: null, after: 4 } })
}
if (n.type === 'supervisor') {
add(n, 'governed.supervisor', `${who(n)}(多 Agent 协作):受管模板不允许全动态规划的节点`,
{ kind: 'assist', label: '换成固定编排是结构性的改动,交给 Copilot' })
}
if (n.type === 'output' && c.contract) {
const k = c.contract
if (!(k.metrics_from ?? []).length) {
// 真后端 validate 和门禁各报一条(同 code、同节点,说法不同),指向同一个修复;validate 的排在最前
if (followsNever) {
issues.unshift({ level: 'error', node_id: n.id, code: 'contract.metrics_from_missing', fix: `contract.metrics_from_missing:${n.id}`,
field: 'contract.metrics_from', message: '出具契约缺 metrics_from(指标来自哪个「口径卡」节点)' })
}
add(n, 'contract.metrics_from_missing', '出具契约没有声明 metrics_from(指标来自哪个「口径卡」节点)', {
kind: 'choice', label: '指标来自哪张口径卡', default: cards[0]?.id,
options: cards.map((m) => ({ value: m.id, label: m.data.label, hint: `${m.data.config.metrics.length} 个指标` })),
})
}
if (hard && !(k.required ?? []).length) {
const from = (k.metrics_from ?? []).length ? cards.filter((m) => k.metrics_from.includes(m.id)) : cards
add(n, 'contract.required_missing', '受管模板的出具契约必须声明 required(必需指标)', {
kind: 'choice', multiple: true, label: '哪些指标缺了就不该出具',
options: from.flatMap((m) => m.data.config.metrics.map((x) => ({ value: x.id, label: `${x.id} · ${x.name}` }))),
})
}
if (hard && !k.strict) {
add(n, 'contract.strict_off', '受管模板建议把出具契约设为 strict:非 strict 下未回指的数字只降档不拦截',
{ kind: 'auto', label: '把出具契约设为 strict', preview: { field: 'contract.strict', before: false, after: true } }, false)
}
}
}
return { level, ok: !issues.some((i) => i.level === 'error'), issues, fixes }
}
/** 伪造的 autofix:在副本上照选中的修法改,逐项记 changes;钉不上的(pf-lib 没发过版时)进 rejected */
function autofix(body, { unpublished = false, followsNever = false } = {}) {
const graph = clone(body.graph)
const before = lint(graph, body.level, { followsNever })
const changes = []
const rejected = []
const applied = []
const byId = (id) => graph.nodes.find((n) => n.id === id)
for (const id of body.apply ?? []) {
const fix = before.fixes.find((f) => f.id === id)
if (!fix) { rejected.push({ fix_id: id, reason: '这条问题已经不在了' }); continue }
if (fix.code === 'governed.default_approval_never') {
// 图级:改的是全图默认,不落在任何节点上
graph.defaults = { ...(graph.defaults ?? {}), approval: 'dangerous' }
changes.push({ fix_id: id, node_id: null, node_title: '', field: 'defaults.approval', before: 'never', after: 'dangerous', label: fix.label })
applied.push(id)
continue
}
const n = byId(fix.node_id)
const c = n.data.config
const change = (field, from, to) => changes.push({ fix_id: id, node_id: n.id, node_title: n.data.label, field, before: from, after: to, label: fix.label })
if (fix.code === 'governed.agent_approval_never') { change('approval', c.approval, 'dangerous'); c.approval = 'dangerous' }
else if (fix.code === 'governed.subgraph_unpinned') {
if (unpublished) { rejected.push({ fix_id: id, reason: '嵌的工作流还没有发布过版本,钉不上:先去发布它,或者交给人选' }); continue }
change('workflow_version', c.workflow_version ?? null, 4); c.workflow_version = 4
} else if (fix.code === 'contract.strict_off') { change('contract.strict', !!c.contract.strict, true); c.contract.strict = true }
else if (fix.code === 'contract.metrics_from_missing') {
const v = body.choices?.[id]
if (v === undefined) { rejected.push({ fix_id: id, reason: '要人选:没给选择' }); continue }
change('contract.metrics_from', c.contract.metrics_from ?? null, [v]); c.contract.metrics_from = [v]
} else if (fix.code === 'governed.no_contract') {
const v = body.choices?.[id]
if (!Array.isArray(v) || !v.length) { rejected.push({ fix_id: id, reason: '要人选:没给选择' }); continue }
const contract = { report_from: 'write', metrics_from: ['caliber'], strict: true, required: v }
change('contract', null, contract); c.contract = contract
} else if (fix.code === 'contract.required_missing') {
const v = body.choices?.[id]
if (!Array.isArray(v) || !v.length) { rejected.push({ fix_id: id, reason: '要人选:没给选择' }); continue }
change('contract.required', c.contract.required ?? null, v); c.contract.required = v
} else { rejected.push({ fix_id: id, reason: '这一条不能直接修' }); continue }
applied.push(id)
}
let assist = null
if (body.assist) {
const team = byId('team')
if (team) {
changes.push({ fix_id: 'assist', node_id: 'team', node_title: team.data.label, field: 'max_rounds', before: team.data.config.max_rounds, after: 2,
label: 'Copilot:先把协作轮数收紧' })
team.data.config.max_rounds = 2
}
assist = { ok: true, summary: '把复核团队的轮数收紧到 2 轮;换成固定编排要删节点,按规矩没动它',
questions: ['复核团队要换成哪一个固定编排的 Agent?它现在只有一个成员 checker,保留它的职责吗?'] }
}
const remaining = lint(graph, body.level, { followsNever })
return { graph, changes, applied, rejected, remaining: remaining.issues, assist, ok: remaining.ok, ops: [] }
}
// ---------------------------------------------------------------- 浏览器
const browser = await chromium.launch({ executablePath: CHROME })
const writes = []
/**
* backend:'new'(两个接口都有)| 'old'(两个接口 404)| 'nofix'(有检查、没有 autofix)
* save:'ok' | 'fail'(保存接口 500)| 'slow'(1.5 秒后才回,看保存进行中的样子)
*/
async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new', save = 'ok', unpublished = false } = {}) {
const followsNever = id === 'pf-dup'
const ctx = await browser.newContext({ viewport: { width, height } })
opened.add(ctx)
await ctx.addInitScript(() => { try { localStorage.setItem('agentlab_actor', '检查脚本') } catch { /* noop */ } })
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
const state = { checks: [], autofixes: [], patches: [], publishes: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend }
await page.route(/\/api\//, (route) => {
const req = route.request()
if (req.method() === 'GET') return route.continue()
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
return json(route, { detail: '检查脚本不写库' }, 409)
})
await page.route(/\/api\/workflows\/(validate|variables)$/, (route) => route.continue())
await page.route(/\/api\/workflows(\?.*)?$/, async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
const real = await route.fetch().then((r) => r.json()).catch(() => [])
return json(route, [...Object.values(FAKES), ...(Array.isArray(real) ? real : [])])
})
await page.route(/\/api\/workflows\/pf-[a-z]+(\/.*)?(\?.*)?$/, async (route) => {
const req = route.request()
const [, , , wid, sub, v] = new URL(req.url()).pathname.split('/')
const method = req.method()
const base = FAKES[wid]
if (sub === 'versions' && !v) return json(route, [4, 3, 2].map((n) => ({ id: `v${n}`, version: n, note: '', created_at: '2026-09-26T02:00:00Z', published: n === 4 })))
if (sub === 'versions' && v) return json(route, { id: `v${v}`, version: Number(v), workflow_id: wid, graph: base.graph, graph_hash: 'x', input_fields: [] })
if (method === 'GET' && !sub) return json(route, wid === id ? { ...base, graph: state.graph, version: state.version } : base)
if (method === 'PATCH' && !sub && wid === id) {
const body = req.postDataJSON()
state.patches.push(body)
if (state.save === 'fail') return json(route, { detail: '数据库锁住了,稍后再试' }, 500)
if (state.save === 'slow') await new Promise((r) => setTimeout(r, 1500))
state.version += 1
state.graph = body.graph
return json(route, { ...base, graph: body.graph, version: state.version, status: 'draft' })
}
if (method === 'POST' && sub === 'publish-check') {
const body = req.postDataJSON()
// 开发模式的 StrictMode 会把挂载时的 effect 跑两遍(第一遍的请求随即被取消):
// 300ms 内一模一样的两次只记一次,数的是「查了几回」
const key = JSON.stringify(body)
const last = state.checks.at(-1)
if (!(last && last.key === key && Date.now() - last.at < 300)) state.checks.push({ ...body, key, at: Date.now() })
if (state.backend === 'old') return json(route, { detail: 'Not Found' }, 404)
return json(route, lint(body.graph ?? state.graph, body.level, { followsNever }))
}
if (method === 'POST' && sub === 'autofix') {
const body = req.postDataJSON()
state.autofixes.push(body)
if (state.backend !== 'new') return json(route, { detail: 'Not Found' }, 404)
return json(route, autofix(body, { unpublished, followsNever }))
}
if (method === 'POST' && sub === 'publish') {
const body = req.postDataJSON()
state.publishes.push(body)
const r = lint(state.graph, body.level, { followsNever })
// 真后端的 /publish 回包:问题带 code、不带 fix(fix 只有 publish-check 给);老后端连 code 也没有
const issues = state.backend === 'old' ? r.issues.map(({ code: _c, fix: _f, ...rest }) => rest)
: r.issues.map(({ fix: _f, ...rest }) => rest)
return json(route, r.ok ? { ok: true, level: body.level, version: state.version, issues } : { ok: false, level: body.level, version: state.version, issues })
}
return route.fallback()
})
await page.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (route) => {
const req = route.request()
if (req.method() === 'GET' && new URL(req.url()).pathname.endsWith('/conversations')) return json(route, [])
return json(route, { id: 'pf-conv', title: '', kind: 'canvas', archived: false, turn_count: 0, last_question: '', turns: [] })
})
await page.goto(`${WEB}/studio/${id}`, { waitUntil: 'networkidle' })
await page.waitForFunction((wid) => window.__studio?.getState().workflow?.id === wid, id, { timeout: 15000 })
await page.waitForTimeout(400)
return { ctx, page, errors, state }
}
const dialog = (page) => page.locator('[role="dialog"]')
async function openDialog(page) {
await page.getByRole('button', { name: '发布', exact: true }).click()
await dialog(page).waitFor()
}
const waitFor = async (page, fn, ms = 5000) => {
for (let t = 0; t < ms; t += 100) {
if (await fn()) return true
await page.waitForTimeout(100)
}
return false
}
const submit = (page) => dialog(page).locator('[data-publish-submit]')
const cfgOf = (graph, id) => graph?.nodes?.find((n) => n.id === id)?.data?.config
const shoot = async (page, name, target) => {
if (!SHOTS) return
for (const theme of ['light', 'dark']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await (target ?? page).screenshot({ path: `${SHOTS}/${name}-${theme}.png` }).catch(() => {})
}
await page.evaluate(() => document.documentElement.removeAttribute('data-theme'))
}
// ================================================================
await section('打开弹窗就列出问题,每条按修法给控件', async () => {
const { page, state, errors } = await open()
await openDialog(page)
await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue]').count()) > 0)
check('一打开就调 publish-check(没点发布)', state.checks.length === 1 && state.publishes.length === 0, JSON.stringify(state.checks.map((c) => c.level)))
check('……按工作流现在的等级(受管)查,带着画布上的图', state.checks[0]?.level === 'governed' && state.checks[0]?.graph?.nodes?.length === HARD.nodes.length)
const rows = await dialog(page).locator('[data-preflight-issue]').count()
check('问题先列出来,一条一行', rows === 6, `${rows} 行`)
const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')
check('结论一句:会被拦下 5 处,另有 1 条提示', summary.includes('会被门禁拦下 5 处') && summary.includes('1 条提示'), summary)
check('auto 类有「修复」', await dialog(page).locator('[data-fix-kind="auto"] [data-fix-action="auto"]').count() === 3)
check('choice 类是选择控件:单选用 radio(两张口径卡)、多选用 checkbox(三个指标)', await dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"] input[type="radio"]').count() === 2
&& await dialog(page).locator('[data-fix-choice="contract.required_missing:done"] input[type="checkbox"]').count() === 3)
check('assist 类有「交给 Copilot」', (await dialog(page).locator('[data-fix-kind="assist"] [data-fix-action="assist"]').innerText()).includes('交给 Copilot'))
const all = await dialog(page).locator('[data-fix-all]').innerText().catch(() => '')
check('顶部「一键修复可自动修的 3 处」', all.includes('一键修复可自动修的 3 处'), all)
check('多选不给默认全选:一个都没勾', await dialog(page).locator('[data-fix-choice="contract.required_missing:done"] input:checked').count() === 0)
check('单选的建议值只标「建议」,不替人选', await dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"] input:checked').count() === 0
&& (await dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"]').innerText()).includes('建议'))
check('发布按钮照旧能点(不修也能发,门禁照旧拦)', await submit(page).isEnabled())
await shoot(page, 'publish-dialog-issues', dialog(page))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('auto:预览逐项写「节点 · 字段:原值 → 新值」,应用并重新检查', async () => {
const { page, state } = await open({ id: 'pf-hard', unpublished: true })
await openDialog(page)
await dialog(page).locator('[data-fix-all]').waitFor()
await dialog(page).locator('[data-fix-all]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
const req = state.autofixes[0]
check('一键修复请求:apply 是三处 auto 的 id,不带 assist', JSON.stringify([...(req?.apply ?? [])].sort()) === JSON.stringify(
['contract.strict_off:done', 'governed.agent_approval_never:fetch', 'governed.subgraph_unpinned:sub']) && !req?.assist, JSON.stringify(req?.apply))
const lines = await dialog(page).locator('[data-fix-change]').allInnerTexts()
const approval = lines.find((l) => l.includes('取数')) ?? ''
check('预览:「取数」· 审批策略:全部自动放行 → 仅危险工具需要审批', /「取数」\s*·\s*审批策略:\s*全部自动放行\s*仅危险工具需要审批/.test(approval.replace(/\n.*$/s, '')), approval)
const strict = lines.find((l) => l.includes('严格模式')) ?? ''
check('预览:契约里的键用界面叫法(出具契约 · 严格模式:否 → 是)', /出具契约 · 严格模式:\s*否\s*是/.test(strict), strict)
const rejected = await dialog(page).locator('[data-fix-rejected]').innerText().catch(() => '')
check('没采用的写出原因', rejected.includes('钉不上') && rejected.includes('钉到它最新的已发布版本'), rejected)
check('预览期间发布按钮不可用(先应用或放弃)', await submit(page).isDisabled())
check('预览不改画布、不保存', state.patches.length === 0
&& await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'fetch').data.config.approval) === 'never')
await shoot(page, 'publish-dialog-preview', dialog(page))
await dialog(page).locator('[data-fix-apply]').click()
await waitFor(page, async () => state.patches.length === 1 && state.checks.length >= 2 && await dialog(page).locator('[data-fix-preview]').count() === 0)
const saved = state.patches[0]?.graph
check('应用 = 走保存接口存草稿:载荷里是修好的图', cfgOf(saved, 'fetch')?.approval === 'dangerous' && cfgOf(saved, 'done')?.contract?.strict === true
&& !cfgOf(saved, 'sub')?.workflow_version, JSON.stringify({ a: cfgOf(saved, 'fetch')?.approval, s: cfgOf(saved, 'done')?.contract?.strict }))
check('……没采用的那处不在载荷里;节点和连线一个没少', saved?.nodes?.length === HARD.nodes.length && saved?.edges?.length === HARD.edges.length)
check('……写了版本说明', /^发布前修复:/.test(state.patches[0]?.note ?? ''), state.patches[0]?.note)
check('存完再查一遍 publish-check', state.checks.length === 2 && cfgOf(state.checks[1]?.graph, 'fetch')?.approval === 'dangerous')
const st = await page.evaluate(() => { const s = window.__studio.getState(); return { dirty: s.dirty, v: s.workflow.version, past: s.past.length, label: s.past.at(-1)?.label } })
check('画布落上修复、存完不再「未保存」,撤销栈里有这一步', !st.dirty && st.v === 4 && st.past >= 1 && /发布前修复/.test(st.label ?? ''), JSON.stringify(st))
const rows = await dialog(page).locator('[data-preflight-issue]').count()
check('重新检查后清单跟着变(剩 4 处错,strict 的提示没了)', rows === 4
&& await dialog(page).locator('[data-preflight-issue="contract.strict_off"]').count() === 0, `${rows} 行`)
check('整个过程没有发布请求', state.publishes.length === 0)
})
await section('auto:修完没有错了,发布按钮才可用;发布只在人点之后发生', async () => {
const { page, state } = await open({ id: 'pf-easy' })
await openDialog(page)
await dialog(page).locator('[data-fix-all]').waitFor()
check('草稿工作流默认按「已发布」查', state.checks[0]?.level === 'published')
await dialog(page).locator('[role="radio"]:has-text("受管")').click()
await waitFor(page, async () => state.checks.length === 2)
check('换成受管就按受管重查', state.checks[1]?.level === 'governed')
await dialog(page).locator('[data-fix-all]').waitFor()
await dialog(page).locator('[data-fix-all]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
const after = await dialog(page).locator('[data-fix-after-check]').innerText().catch(() => '')
check('预览写明应用后门禁不再拦', after.includes('不再拦'), after)
check('预览期间发布不可用', await submit(page).isDisabled())
await dialog(page).locator('[data-fix-apply]').click()
await waitFor(page, async () => state.checks.length === 3 && await dialog(page).locator('[data-fix-preview]').count() === 0)
const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')
check('重新检查通过', summary.includes('发布前检查通过'), summary)
check('发布按钮变为可用', await submit(page).isEnabled())
check('到这一步还没有任何发布请求', state.publishes.length === 0)
await submit(page).click()
await waitFor(page, async () => state.publishes.length === 1)
check('人点了「发布为受管」才发出一次发布', state.publishes.length === 1 && state.publishes[0].level === 'governed', JSON.stringify(state.publishes))
})
await section('choice:没选之前不能应用;选了之后载荷里是选的值', async () => {
const { page, state } = await open()
await openDialog(page)
const req = dialog(page).locator('[data-fix-choice="contract.required_missing:done"]')
await req.waitFor()
check('没选时「预览」不可用,并说先选', await req.locator('[data-fix-action="choice"]').isDisabled() && (await req.innerText()).includes('先选好再预览'))
await req.locator('label:has-text("gmv") input').check()
await req.locator('label:has-text("orders") input').check()
check('选了之后可以预览', await req.locator('[data-fix-action="choice"]').isEnabled())
await req.locator('[data-fix-action="choice"]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
const body = state.autofixes.at(-1)
check('请求:apply 只有这一项,choices 是选的两个', JSON.stringify(body?.apply) === JSON.stringify(['contract.required_missing:done'])
&& JSON.stringify(body?.choices?.['contract.required_missing:done']) === JSON.stringify(['gmv', 'orders']), JSON.stringify(body))
const change = await dialog(page).locator('[data-fix-change]').first().innerText()
check('预览:出具契约 · 必需指标:(空)→ gmv、orders', /出具契约 · 必需指标:\s*(空)\s*gmv、orders/.test(change), change)
await dialog(page).locator('[data-fix-apply]').click()
await waitFor(page, async () => state.patches.length === 1)
check('保存载荷里 required 是选的值', JSON.stringify(cfgOf(state.patches[0]?.graph, 'done')?.contract?.required) === JSON.stringify(['gmv', 'orders']),
JSON.stringify(cfgOf(state.patches[0]?.graph, 'done')?.contract))
// 单选:选「补充口径」
const from = dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"]')
await waitFor(page, async () => (await from.count()) === 1 && await from.locator('[data-fix-action="choice"]').isVisible())
await from.locator('label:has-text("补充口径") input').check()
await from.locator('[data-fix-action="choice"]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
check('单选:choices 里是选中的那一个值', state.autofixes.at(-1)?.choices?.['contract.metrics_from_missing:done'] === 'caliber2', JSON.stringify(state.autofixes.at(-1)?.choices))
// 改动的值是节点 id(caliber2):预览里写节点名,和选的时候看到的是同一个名字
const fromLine = await dialog(page).locator('[data-fix-change="contract.metrics_from_missing:done"]').first().innerText().catch(() => '')
check('预览写节点名、不写 id:出具契约 · 指标来自:(空)→「补充口径」', /出具契约 · 指标来自:\s*(空)\s*「补充口径」/.test(fromLine)
&& !fromLine.includes('caliber2'), fromLine)
})
await section('assist:Copilot 的改动先预览、它的问题原样摆出来,不确认就不保存', async () => {
const { page, state } = await open()
await openDialog(page)
await dialog(page).locator('[data-fix-action="assist"]').waitFor()
await dialog(page).locator('[data-fix-action="assist"]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
const body = state.autofixes.at(-1)
check('请求带 assist: true', body?.assist === true, JSON.stringify(body))
const change = await dialog(page).locator('[data-fix-change]').first().innerText().catch(() => '')
check('Copilot 的改动逐项预览', change.includes('复核团队') && /4\s*2/.test(change), change)
const q = await dialog(page).locator('[data-fix-questions]').innerText().catch(() => '')
check('它提的问题原样显示', q.includes('复核团队要换成哪一个固定编排的 Agent?它现在只有一个成员 checker,保留它的职责吗?'), q)
check('它的总结也摆出来', (await dialog(page).locator('[data-fix-assist]').innerText()).includes('按规矩没动它'))
await shoot(page, 'publish-dialog-assist', dialog(page))
await dialog(page).getByRole('button', { name: '放弃', exact: true }).click()
await page.waitForTimeout(300)
check('放弃:不保存、画布不动、预览收起', state.patches.length === 0 && await dialog(page).locator('[data-fix-preview]').count() === 0
&& await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'team').data.config.max_rounds) === 4)
check('放弃后发布按钮恢复可用', await submit(page).isEnabled())
check('没有发布请求', state.publishes.length === 0)
})
await section('保存失败、接口不存在、画布改过:都有明确状态', async () => {
const bad = await open({ id: 'pf-easy', save: 'fail' })
await openDialog(bad.page)
await dialog(bad.page).locator('[data-fix-all]').click()
await dialog(bad.page).locator('[data-fix-apply]').click()
await dialog(bad.page).locator('[data-fix-save-failed]').waitFor()
const msg = await dialog(bad.page).locator('[data-fix-save-failed]').innerText()
check('保存失败:说清没存上、修复已在画布上', msg.includes('草稿没保存上') && msg.includes('数据库锁住了') && msg.includes('保存成功之前不能发布'), msg)
check('……发布按钮不可用(不能把没修的那一版发出去)', await submit(bad.page).isDisabled())
check('……不重查', bad.state.checks.length === 1)
bad.state.save = 'ok'
await dialog(bad.page).getByRole('button', { name: '重试保存' }).click()
await waitFor(bad.page, async () => bad.state.checks.length === 2)
check('重试保存成功后重新检查、发布可用', bad.state.patches.length === 2 && await submit(bad.page).isEnabled())
const nofix = await open({ backend: 'nofix' })
await openDialog(nofix.page)
await dialog(nofix.page).locator('[data-fix-all]').click()
await dialog(nofix.page).locator('[data-fix-preview="unsupported"]').waitFor()
check('autofix 不存在:说这个后端还不支持自动修复', (await dialog(nofix.page).locator('[data-fix-preview]').innerText()).includes('还不支持自动修复'))
check('……发布按钮照旧可用', await submit(nofix.page).isEnabled())
const moved = await open({ id: 'pf-easy' })
await openDialog(moved.page)
await dialog(moved.page).locator('[data-fix-all]').click()
await dialog(moved.page).locator('[data-fix-apply]').waitFor()
await moved.page.evaluate(() => { const s = window.__studio.getState(); s.updateNode('fetch', { label: '取数2' }) })
await dialog(moved.page).locator('[data-fix-apply]').click()
await moved.page.waitForTimeout(300)
check('预览之后画布内容又改过:不套用、说要重查', moved.state.patches.length === 0
&& (await dialog(moved.page).locator('[data-fix-preview]').innerText()).includes('对不上了'))
})
await section('正式运行编辑锁定时不给修复按钮', async () => {
const { page } = await open()
await page.evaluate(() => {
const s = window.__studio.getState()
window.__studio.setState({ runPhase: 'running', trace: { ...s.trace, runClass: 'formal' } })
})
await openDialog(page)
await dialog(page).locator('[data-preflight-issue]').first().waitFor()
check('问题照样列出来', await dialog(page).locator('[data-preflight-issue]').count() === 6)
check('没有任何修复控件', await dialog(page).locator('[data-fix-action], [data-fix-all], [data-fix-choice]').count() === 0)
check('写明为什么现在不能修', (await dialog(page).locator('[data-preflight-locked]').innerText()).includes('正式运行进行中'))
})
await section('老后端(两个接口 404):界面和现在一样', async () => {
const { page, state, errors } = await open({ backend: 'old' })
await openDialog(page)
await waitFor(page, async () => state.checks.length === 1)
await page.waitForTimeout(300)
check('打开时不列问题、不给修复控件', await dialog(page).locator('[data-preflight-issue], [data-fix-action], [data-fix-all]').count() === 0)
check('只一句安静的说明:点发布时门禁照旧检查', (await dialog(page).locator('[data-preflight-unsupported]').innerText().catch(() => '')).includes('门禁照旧检查'))
check('发布按钮可用', await submit(page).isEnabled())
await submit(page).click()
await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue]').count()) > 0)
const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')
check('点了发布被拦:照旧列出门禁拦下的问题', summary.includes('门禁拦下了 5 处') && await dialog(page).locator('[data-preflight-issue]').count() === 6, summary)
check('……仍然没有修复控件', await dialog(page).locator('[data-fix-action], [data-fix-all]').count() === 0)
await dialog(page).locator('[data-preflight-issue]').filter({ hasText: '取数' }).locator('button').first().click()
await page.waitForTimeout(300)
check('点一条关掉弹窗、定位到节点(原有行为)', await dialog(page).count() === 0
&& await page.evaluate(() => window.__studio.getState().selectedId) === 'fetch')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('问题面板的发布前检查:选档、同一套修法、点一条定位', async () => {
const { page, state } = await open({ id: 'pf-easy' })
await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } })
await page.keyboard.press('Alt+KeyP')
await page.locator('#dock-problems').waitFor()
await page.locator('[data-problems-mode="publish"]').click()
await page.locator('[data-preflight="panel"]').waitFor().catch(() => {})
await waitFor(page, async () => state.checks.length === 1)
check('切到「发布前检查」就按当前等级查一遍(草稿 → 已发布)', await page.locator('[data-preflight="panel"]').count() === 1
&& state.checks[0]?.level === 'published', JSON.stringify(state.checks.map((c) => c.level)))
await page.locator('[data-preflight="panel"] [role="radio"]:has-text("受管")').click()
await waitFor(page, async () => state.checks.length === 2)
check('选「受管」重查', state.checks[1]?.level === 'governed')
await page.locator('[data-preflight="panel"] [data-fix-all="2"]').waitFor()
check('同一套修复控件', await page.locator('[data-preflight="panel"] [data-fix-action="auto"]').count() === 2)
await shoot(page, 'problems-preflight', page.locator('section[aria-label="问题"]'))
await page.locator('[data-preflight="panel"] [data-preflight-issue]').filter({ hasText: '取数' }).locator('button').first().click()
await page.waitForTimeout(300)
check('点一条定位到节点', await page.evaluate(() => window.__studio.getState().selectedId) === 'fetch')
// 画布改过:结论可能过时
await page.evaluate(() => { const s = window.__studio.getState(); s.updateNode('caliber', { label: '周报口径2' }) })
await page.waitForSelector('[data-preflight="panel"] [data-preflight-stale]', { timeout: 6000 }).catch(() => {})
check('查完画布又改过:提示结果可能过时', await page.locator('[data-preflight="panel"] [data-preflight-stale]').count() === 1)
await page.locator('[data-preflight="panel"]').getByRole('button', { name: '重新检查' }).click()
await waitFor(page, async () => state.checks.length === 3)
await page.locator('[data-preflight="panel"] [data-fix-all]').click()
await page.locator('[data-preflight="panel"] [data-fix-apply]').click()
// 重查的请求发出去之后,回包画上去之前清单还是上一份:等预览收起(它在重查回来之后才收)
await waitFor(page, async () => state.patches.length === 1 && state.checks.length === 4
&& await page.locator('[data-preflight="panel"] [data-fix-preview]').count() === 0)
check('面板里应用:存草稿(带上画布上别的未保存改动)再重查', cfgOf(state.patches[0]?.graph, 'fetch')?.approval === 'dangerous'
&& state.patches[0]?.graph?.nodes?.find((n) => n.id === 'caliber')?.data?.label === '周报口径2')
const summary = await page.locator('[data-preflight="panel"] [data-preflight-summary]').innerText().catch(() => '')
check('……重查通过', summary.includes('发布前检查通过'), summary)
check('面板里也不发布', state.publishes.length === 0)
await page.locator('[data-problems-mode="lint"]').click()
check('切回「校验」照旧是原来的问题清单', await page.locator('[data-preflight="panel"]').count() === 0)
})
await section('同一个修复只画一次:validate 和门禁各报一条、几条问题共用一个图级修复', async () => {
const { page, state, errors } = await open({ id: 'pf-dup' })
await openDialog(page)
await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue]').count()) > 0)
const rows = await dialog(page).locator('[data-preflight-issue]').count()
check('同 code、同节点的两条合成一行', rows === 3
&& await dialog(page).locator('[data-preflight-issue="contract.metrics_from_missing"]').count() === 1, `${rows} 行`)
const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')
check('……结论按一处算:会被门禁拦下 3 处', summary.includes('会被门禁拦下 3 处'), summary)
const choice = dialog(page).locator('[data-fix-choice="contract.metrics_from_missing:done"]')
check('契约那一处只有一组选项、一个「预览」', await choice.count() === 1 && await choice.locator('[data-fix-action]').count() === 1)
const follow = dialog(page).locator('[data-preflight-issue="governed.default_approval_never"]')
check('两个节点共用的图级修复只画一次,挂在第一条上', await follow.count() === 2
&& await follow.nth(0).locator('[data-fix-action="auto"]').count() === 1 && await follow.nth(1).locator('[data-fix-action]').count() === 0
&& await dialog(page).locator('[data-fix-action="auto"]').count() === 1)
check('一键修复算一处', (await dialog(page).locator('[data-fix-all]').getAttribute('data-fix-all').catch(() => '')) === '1')
await dialog(page).locator('[data-fix-all]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
const line = await dialog(page).locator('[data-fix-change]').first().innerText().catch(() => '')
check('图级改动的预览:「整张工作流」 · 全图默认 · 审批策略:全部自动放行 → 仅危险工具需要审批',
/「整张工作流」\s*·\s*全图默认 · 审批策略:\s*全部自动放行\s*仅危险工具需要审批/.test(line), line)
await dialog(page).getByRole('button', { name: '放弃', exact: true }).click()
// 真点了发布被拦:/publish 的回包只有 code 没有 fix。图级修复按 code 认回来,照样只画一次
await submit(page).click()
await waitFor(page, async () => state.publishes.length === 1
&& (await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')).includes('门禁拦下了'))
const gateSummary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')
check('被拦之后:照样合成一行(门禁拦下了 3 处)', gateSummary.includes('门禁拦下了 3 处')
&& await dialog(page).locator('[data-preflight-issue]').count() === 3, gateSummary)
check('……图级修复(回包里没有 fix)照样挂在第一条上,只画一次', await follow.nth(0).locator('[data-fix-action="auto"]').count() === 1
&& await dialog(page).locator('[data-fix-action="auto"]').count() === 1)
check('……契约那一处照样只有一组选项', await choice.count() === 1)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('图级问题:契约骨架按键写节点名;发布被拦之后修复控件还在', async () => {
const { page, state, errors } = await open({ id: 'pf-bare' })
await openDialog(page)
const row = dialog(page).locator('[data-preflight-issue="governed.no_contract"]')
await row.waitFor()
check('打开时:「没有带契约的出口」旁边有生成契约骨架的选项', await row.locator('[data-fix-choice="governed.no_contract:done"]').count() === 1)
await row.locator('label:has-text("gmv") input').check()
await row.locator('[data-fix-action="choice"]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
const change = await dialog(page).locator('[data-fix-change]').first().innerText().catch(() => '')
const after = await dialog(page).locator('[data-fix-change] [data-fix-after]').first().innerText().catch(() => '')
const lines = after.split('\n').map((l) => l.trim()).filter(Boolean)
check('契约骨架一键一行,键名用界面叫法、节点写名字', /「成果」\s*·\s*出具契约:\s*(空)/.test(change)
&& JSON.stringify(lines) === JSON.stringify(['指标来自:「周报口径」', '报告来自:「报告撰写」', '必需指标:gmv', '严格模式:是']), lines.join(' | '))
check('……不露节点 id、英文键名、JSON', !/caliber|write|metrics_from|report_from|required|strict|[{}"]/.test(after), after)
await dialog(page).getByRole('button', { name: '放弃', exact: true }).click()
await submit(page).click()
await waitFor(page, async () => state.publishes.length === 1
&& (await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')).includes('门禁拦下了'))
const gate = dialog(page).locator('[data-preflight-issue="governed.no_contract"]')
check('发布被拦(回包只有 code 没有 fix):图级问题旁边照样有生成契约骨架的选项', state.publishes.length === 1
&& await gate.locator('[data-fix-choice="governed.no_contract:done"]').count() === 1)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('保存进行中不能换等级;存失败之后换了等级也还能重试保存', async () => {
const slow = await open({ id: 'pf-easy', save: 'slow' })
await openDialog(slow.page)
await dialog(slow.page).locator('[data-fix-all]').click()
await dialog(slow.page).locator('[data-fix-apply]').click()
await waitFor(slow.page, async () => slow.state.patches.length === 1)
const radios = dialog(slow.page).locator('[role="radio"]')
check('弹窗:保存进行中两个等级都不可点', await radios.nth(0).isDisabled() && await radios.nth(1).isDisabled())
await radios.nth(1).click({ force: true, timeout: 1000 }).catch(() => {})
await slow.page.waitForTimeout(200)
check('……点了也不换档、不按别的档重查', slow.state.checks.every((c) => c.level === 'published')
&& await radios.nth(0).getAttribute('aria-checked') === 'true')
await waitFor(slow.page, async () => slow.state.checks.length === 2 && await radios.nth(1).isEnabled(), 6000)
check('……存完、重查完,等级又能选', await radios.nth(1).isEnabled())
// 问题面板:同一条
const pane = await open({ id: 'pf-easy', save: 'slow' })
await pane.page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } })
await pane.page.keyboard.press('Alt+KeyP')
await pane.page.locator('[data-problems-mode="publish"]').click()
const panel = pane.page.locator('[data-preflight="panel"]')
await panel.locator('[data-fix-all]').click()
await panel.locator('[data-fix-apply]').click()
await waitFor(pane.page, async () => pane.state.patches.length === 1)
const levels = panel.locator('[role="radio"]')
check('问题面板:保存进行中两个等级都不可点', await levels.nth(0).isDisabled() && await levels.nth(1).isDisabled())
await levels.nth(1).click({ force: true, timeout: 1000 }).catch(() => {})
await pane.page.waitForTimeout(200)
check('……点了也不换档', pane.state.checks.every((c) => c.level === 'published') && await levels.nth(0).getAttribute('aria-checked') === 'true')
const bad = await open({ id: 'pf-easy', save: 'fail' })
await openDialog(bad.page)
await dialog(bad.page).locator('[data-fix-all]').click()
await dialog(bad.page).locator('[data-fix-apply]').click()
await dialog(bad.page).locator('[data-fix-save-failed]').waitFor()
await dialog(bad.page).locator('[role="radio"]:has-text("受管")').click()
await waitFor(bad.page, async () => bad.state.checks.length === 2)
await bad.page.waitForTimeout(250)
check('存失败之后换了等级:说明和「重试保存」都还在', await dialog(bad.page).locator('[data-fix-save-failed]').count() === 1
&& (await dialog(bad.page).locator('[data-fix-save-failed]').innerText().catch(() => '')).includes('草稿没保存上')
&& await dialog(bad.page).getByRole('button', { name: '重试保存' }).count() === 1)
check('……发布照旧不可用,说的是修复还没保存上', await submit(bad.page).isDisabled()
&& await submit(bad.page).getAttribute('title') === '修复还没保存上', await submit(bad.page).getAttribute('title'))
bad.state.save = 'ok'
await dialog(bad.page).getByRole('button', { name: '重试保存' }).click()
await waitFor(bad.page, async () => bad.state.checks.length === 3 && await submit(bad.page).isEnabled())
check('……重试保存:存上、按换过的等级重查、发布可用', bad.state.patches.length === 2 && bad.state.checks[2]?.level === 'governed'
&& await submit(bad.page).isEnabled() && await dialog(bad.page).locator('[data-fix-save-failed]').count() === 0)
})
await section('保存和页面自己的保存同一套规矩:回滚说明保留、画布锁着不存', async () => {
const { page, state } = await open({ id: 'pf-easy' })
// 刚从版本历史恢复了旧版本:页面自己的保存会把「回滚到 v2」写进版本说明
await page.evaluate(() => window.__studio.setState({ pendingNote: '回滚到 v2' }))
await openDialog(page)
await dialog(page).locator('[data-fix-all]').click()
await dialog(page).locator('[data-fix-apply]').click()
await waitFor(page, async () => state.patches.length === 1)
check('版本说明保留「回滚到 v2」,后面接着写发布前修复', /^回滚到 v2;发布前修复:/.test(state.patches[0]?.note ?? ''), state.patches[0]?.note)
const bad = await open({ id: 'pf-easy', save: 'fail' })
await openDialog(bad.page)
await dialog(bad.page).locator('[data-fix-all]').click()
await dialog(bad.page).locator('[data-fix-apply]').click()
await dialog(bad.page).locator('[data-fix-save-failed]').waitFor()
// 存失败之后正式运行开始了:画布锁着,这时重试保存不能 PATCH
await bad.page.evaluate(() => {
const s = window.__studio.getState()
window.__studio.setState({ runPhase: 'running', trace: { ...s.trace, runClass: 'formal' } })
})
bad.state.save = 'ok'
await dialog(bad.page).getByRole('button', { name: '重试保存' }).click()
await bad.page.waitForTimeout(400)
check('画布锁着(正式运行进行中)时重试保存:不存', bad.state.patches.length === 1, `${bad.state.patches.length} 次保存`)
const why = await dialog(bad.page).locator('[data-fix-save-failed]').innerText().catch(() => '')
check('……说清为什么没存,「重试保存」还在', why.includes('正式运行进行中')
&& await dialog(bad.page).getByRole('button', { name: '重试保存' }).count() === 1, why)
check('……发布照旧不可用', await submit(bad.page).isDisabled())
await bad.page.evaluate(() => window.__studio.setState({ runPhase: 'idle' }))
await dialog(bad.page).getByRole('button', { name: '重试保存' }).click()
await waitFor(bad.page, async () => bad.state.patches.length === 2 && bad.state.checks.length === 2)
check('锁解开之后重试保存:存上、重查', bad.state.patches.length === 2 && bad.state.checks.length === 2)
})
await section('全图默认不会被画布弄丢:保存、发布前检查、修复都带着 defaults', async () => {
const { ctx, page, errors, state } = await open({ id: 'pf-defs' })
const first = await page.evaluate(() => {
const s = window.__studio.getState()
const n = s.nodes.find((x) => x.data.nodeType !== 'input') ?? s.nodes[0]
s.updateNode(n.id, { label: `${n.data.label}·改` })
return n.id
})
await page.evaluate(() => window.__studio.getState().save('检查脚本:改个名字'))
await page.waitForTimeout(300)
const saved = state.patches.at(-1)?.graph
check('改了一个节点再保存:全图默认原样带着(以前整个被抹掉,审批 always 悄悄放宽)',
saved?.defaults?.approval === 'always' && saved?.defaults?.model === 'demo-model',
JSON.stringify(saved?.defaults ?? null))
await openDialog(page)
const sent = state.checks.at(-1)?.graph
check('发布前检查发出去的图带着全图默认', sent?.defaults?.approval === 'always', JSON.stringify(sent?.defaults ?? null))
await page.keyboard.press('Escape')
const dirty = await page.evaluate(() => {
const s = window.__studio.getState()
const g = { nodes: s.nodes.map((n) => ({ id: n.id, type: n.data.nodeType, position: n.position, data: { label: n.data.label, config: n.data.config } })),
edges: s.edges.map((e) => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle ?? null, label: '' })),
defaults: { approval: 'dangerous', model: 'demo-model' } }
s.applyFixes(g, '发布前修复:全图默认')
return window.__studio.getState().dirty
})
check('修复只改了全图默认:画布照样算有改动', dirty === true)
await page.evaluate(() => window.__studio.getState().save('检查脚本:修复'))
await page.waitForTimeout(300)
const fixed = state.patches.at(-1)?.graph
check('修复改的全图默认存了进去', fixed?.defaults?.approval === 'dangerous' && fixed?.defaults?.model === 'demo-model',
JSON.stringify(fixed?.defaults ?? null))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
void first
})
await section('360px 下不横向滚动', async () => {
const { page } = await open({ width: 360, height: 780 })
// 窄屏上「发布」收在「更多操作」里
await page.getByRole('button', { name: '更多操作' }).click()
await page.locator('[role="menu"][aria-label="更多操作"] [role^="menuitem"]', { hasText: '发布' }).click()
await dialog(page).waitFor()
await dialog(page).locator('[data-fix-all]').waitFor()
await dialog(page).locator('[data-fix-all]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
// 量弹窗本身:编排页的工具栏在 360px 下本来就比视口宽(那是页面自己的事),弹窗是 fixed 的一层,
// 要守的是它里面的东西——问题、选项、预览——一样都不伸出视口、不出横向滚动条
const m = await page.evaluate(() => {
const d = document.querySelector('[role="dialog"]')
const r = d.getBoundingClientRect()
const wide = [...d.querySelectorAll('*')].filter((el) => {
const b = el.getBoundingClientRect()
return b.width > 0 && (b.right > window.innerWidth + 0.5 || b.left < -0.5)
}).map((el) => el.tagName + (el.className ? `.${String(el.className).split(' ')[0]}` : ''))
const scrollers = [...d.querySelectorAll('*')].filter((el) => el.scrollWidth > el.clientWidth + 1
&& !['visible', 'hidden', 'clip'].includes(getComputedStyle(el).overflowX)).length
return { left: r.left, right: r.right, vw: window.innerWidth, wide: wide.slice(0, 4), scrollers, own: d.scrollWidth - d.clientWidth }
})
check('360px:弹窗在视口里、里面没有元素伸出去、没有横向滚动条', m.left >= 0 && m.right <= m.vw && !m.wide.length && m.scrollers === 0 && m.own <= 0, JSON.stringify(m))
await shoot(page, 'publish-dialog-360', page)
})
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)