mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:51:59 +00:00
可点击证据第五期:老工作流一键升级为可追溯结构;受管问数据图的 cells 一键选;pytest 并行
一、一键升级(只给预览,不自动保存)
- engine/upgrade.py:确定性改写,纯函数、重复执行结果不变
- R1 契约的叙述指向模型调用节点 → 换成报告撰写节点(system 和 prompt 合成写作要求,模型配置照搬),契约改成 report_from
- R2 没有契约、但模型写的文字进了出口,而且上游有取数 → 同样换成报告撰写
- R3 input → 智能体 → 出口(问数据的典型图)→ 中间插入报告撰写节点,出口字段接报告的正文;
只改直接相连的出口,经过别的节点读它的出口原样保留并写明原因
- R4 配了 output_schema 的智能体 → 打开 cite_fields
- R5 代码节点的产出被口径卡读 → 不改,只建议标成取数,等人确认
- 一个叙述里混了几个写作者的,不猜,整步不做并写明原因
- POST /api/copilot/upgrade-evidence:返回改动、说明和改完的检查结果;assist 时再交给 Copilot 把纯算术的代码节点
改写成口径卡表达式,不是纯算术的保留并给警告
- 升级和发布前修复走同一道「不许降低要求」的检查,只多两个由升级点名的例外:模型调用换成报告撰写、R3 改接的那条连线;
改完不能更糟
- 校验对符合 R1–R4 的旧结构给一条建议「可以升级为可追溯结构」,不算提示、不挡发布
- Copilot 自查:报告撰写节点上游没有任何能引用数字的来源时打回
二、界面
- 问题面板的快速修复「升级为可追溯结构(预览改动)」:逐项写「节点 · 字段:原值 → 新值」,节点类型的变化写成
「模型调用 → 报告撰写」,新插的节点和改接的连线另外写明;「应用」后走现有的保存,全图默认不丢
- 画布改过、存失败、画布被锁都有明确状态;保存失败后手动存成功会清掉「没存上」的提示,
画布和预览对不上时「重试保存」不再盲存;校验接口失败时升级块照样在
- 记录页里没有逐段证据的运行给横幅「这次的报告没有逐段证据 → 升级这张图」,跳到编排页并打开升级预览
- 修掉一处旧缺陷:应用修复时只换配置、不换节点类型,模型调用换成报告撰写后存下去的还是模型调用
三、受管门禁:cells 一键选
- 图里没有口径卡、契约又没写 cells: true 的受管图,以前能过门禁、正式运行却每次失败;现在门禁拦下,
发布弹窗给两个选项:写 cells: true,或交给 Copilot 改成口径卡指标(人亲手选的不算替人打开,Copilot 自己打开照样拦)
- 「修完要更好」的复核不再把改完才冒出来、只能由人拍板的这一处算作改坏:Copilot 补上契约、把 cells 留给人问,
这一步会被采用,不再被判「冒出新的问题」作废
四、测试
- 开发依赖加上 pytest-xdist(锁文件只多了 pytest-xdist 和 execnet):全量后端测试 -n auto 约 16 秒,原来串行约 2 分钟
- 修掉并行下暴露的一处测试竞态:运行先落终态、再另一个事务封存,测试改成等封存完成再核对
This commit is contained in:
+539
-3
@@ -1,5 +1,7 @@
|
||||
// 发布前检查与自动修复的检查:发布弹窗打开就列问题、逐条修(auto / choice / assist)、
|
||||
// 先预览再「应用并重新检查」、问题面板里的「发布前检查」、老后端(两个接口 404)照旧。
|
||||
// 另有一键升级为可追溯结构(可点击证据五期):问题面板的快速修复、升级预览、应用走现有的保存、
|
||||
// 从记录页横幅跳过来(?upgrade=1)。升级接口和校验给的那条建议同样在浏览器里伪造。
|
||||
//
|
||||
// 不写库:工作流、保存、发布、publish-check、autofix 全在浏览器里用 page.route 伪造。
|
||||
// 伪造的检查接口按请求里的图现算(下面的 lint),所以修完、存完、再查,结论会真的变;
|
||||
@@ -117,6 +119,35 @@ const HANDOFF = {
|
||||
edges: chain('in', 'calc', 'caliber', 'copilot', 'done'),
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键升级的旧结构(R1、R4、R5 都有):出具契约的 narrative 指着一个模型调用节点;取数的 agent 配了
|
||||
* output_schema 没开 cite_fields;口径卡还吃一个代码节点的产出。带全图默认,升级、保存都得把它带着
|
||||
*/
|
||||
const LEGACY = {
|
||||
nodes: [
|
||||
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
|
||||
node('fetch', 'agent', 240, '取数', { prompt: '查 orders 本周的 gmv', tools: ['db_query__shop'], assign_to: 'kpi',
|
||||
output_schema: { type: 'object', properties: { gmv: { type: 'number' } } } }),
|
||||
node('calc', 'code', 240, '计算', { code: 'print(1)', assign_to: 'calc' }),
|
||||
node('caliber', 'metrics', 480, '周报口径', { caliber: '周报口径', caliber_version: 'v1',
|
||||
metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }, { id: 'ratio', name: '比例', expression: 'vars.calc.ratio' }] }),
|
||||
node('story', 'llm', 720, '写周报', { system: '你是分析师', prompt: '按口径写本周周报' }),
|
||||
node('done', 'output', 960, '成果', { fields: [{ name: 'report', value: '{{ nodes.story.text }}' }],
|
||||
contract: { metrics_from: ['caliber'], narrative: '{{ nodes.story.text }}', required: ['gmv'], strict: true } }),
|
||||
],
|
||||
edges: [...chain('in', 'fetch', 'caliber', 'story', 'done'), ...chain('in', 'calc', 'caliber')],
|
||||
defaults: { approval: 'always', model: 'demo-model' },
|
||||
}
|
||||
/** 问数据页的典型图(R3):输入 → agent → 成果,升级要在 agent 和成果之间插一个报告撰写节点 */
|
||||
const ASK = {
|
||||
nodes: [
|
||||
node('in', 'input', 0, '问题', { fields: [{ name: 'question', required: true }] }),
|
||||
node('fetch', 'agent', 240, '取数', { prompt: '{{ input.question }}', tools: ['db_query__shop'] }),
|
||||
node('done', 'output', 480, '成果', { fields: [{ name: 'answer', value: '{{ nodes.fetch.text }}' }] }),
|
||||
],
|
||||
edges: chain('in', 'fetch', '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,
|
||||
@@ -131,6 +162,8 @@ const FAKES = {
|
||||
'pf-handoff': wf('pf-handoff', '__publish_check_handoff__', HANDOFF, { status: 'governed', published_version: 2 }),
|
||||
// 带全图默认的图:画布只管节点和连线,保存、检查、修复都得把 defaults 原样带着
|
||||
'pf-defs': wf('pf-defs', '__publish_check_defaults__', { ...EASY, defaults: { approval: 'always', model: 'demo-model' } }),
|
||||
'pf-legacy': wf('pf-legacy', '__upgrade_check_legacy__', LEGACY, { status: 'published', published_version: 3 }),
|
||||
'pf-ask': wf('pf-ask', '__upgrade_check_ask__', ASK),
|
||||
}
|
||||
|
||||
const clone = (x) => JSON.parse(JSON.stringify(x))
|
||||
@@ -293,6 +326,82 @@ function autofix(body, { unpublished = false, followsNever = false } = {}) {
|
||||
return { graph, changes, applied, rejected, handoff, remaining: remaining.issues, assist, ok: remaining.ok, ops: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 伪造的校验建议:图里还有模型调用节点,或者 agent 的文字直接进了成果(问数据的图),就给一条 info
|
||||
* 「可以升级为可追溯结构」。升级完(节点换成了报告撰写、插了报告节点)就没有了
|
||||
*/
|
||||
function upgradeAdvice(graph) {
|
||||
const byId = new Map(graph.nodes.map((n) => [n.id, n]))
|
||||
const old = graph.nodes.some((n) => n.type === 'llm')
|
||||
|| graph.edges.some((e) => byId.get(e.source)?.type === 'agent' && byId.get(e.target)?.type === 'output')
|
||||
return old ? [{ level: 'info', code: 'evidence.upgrade_available', node_id: null, field: null, message: '可以升级为可追溯结构' }] : []
|
||||
}
|
||||
|
||||
/** R5、R3 的说明原文:原样显示,检查逐字比对 */
|
||||
const R5_NOTE = '「计算」的产出被口径卡引用:它要是只在取数、不做计算,可以标成 source(取数);标不标由你确认,这次没有改它'
|
||||
const R3_NOTE = '报告会直接引用查询单元格。要按受管级别正式出具,得在出具契约里写 cells: true,或者改走口径卡:选哪条要你拿主意,升级没有替你加契约'
|
||||
|
||||
/**
|
||||
* 伪造的升级接口,形状照 E5-back 的 engine/upgrade.py:一步(fix_id = 规则:节点)的几项改动共用一句 label;
|
||||
* 换类型是 field type,新插入的节点是 field node(after 是 {id, type, label, config}),连线是 field edge
|
||||
* (改接的两头都有),成果字段整列改。bare:只给图、不给 changes(前端按前后两张图自己列)。
|
||||
* assist:Copilot 的一段总结和保留没改的代码节点
|
||||
*/
|
||||
function upgradeFake(body, { bare = false } = {}) {
|
||||
const graph = clone(body.graph)
|
||||
const changes = []
|
||||
const notes = []
|
||||
const at = (id) => graph.nodes.find((n) => n.id === id)
|
||||
const step = (rule, id, label, list) => changes.push(...list.map((c) => ({ fix_id: `${rule}:${id}`, rule, label, ...c })))
|
||||
const story = at('story')
|
||||
if (story?.type === 'llm') {
|
||||
const c = story.data.config
|
||||
const instructions = `${c.system}\n\n${c.prompt}`
|
||||
story.type = 'report'
|
||||
story.data.config = { instructions }
|
||||
const done = at('done')
|
||||
const before = clone(done.data.config.contract)
|
||||
done.data.config.contract = { metrics_from: ['caliber'], report_from: 'story', required: ['gmv'], strict: true }
|
||||
step('R1', 'story', '把「写周报」换成报告撰写,出具契约改为核对它的文档', [
|
||||
{ node_id: 'story', node_title: '写周报', field: 'type', before: 'llm', after: 'report' },
|
||||
{ node_id: 'story', node_title: '写周报', field: 'instructions', before: null, after: instructions },
|
||||
{ node_id: 'story', node_title: '写周报', field: 'system', before: c.system, after: null },
|
||||
{ node_id: 'story', node_title: '写周报', field: 'prompt', before: c.prompt, after: null },
|
||||
{ node_id: 'done', node_title: '成果', field: 'contract', before, after: done.data.config.contract },
|
||||
])
|
||||
}
|
||||
const fetch = at('fetch')
|
||||
const done = at('done')
|
||||
if (!at('report') && fetch && done && graph.edges.some((e) => e.source === 'fetch' && e.target === 'done')) {
|
||||
const config = { instructions: '{{ input.question }}' }
|
||||
graph.nodes.push({ id: 'report', type: 'report', position: { x: 480, y: 240 }, data: { label: '报告撰写', config } })
|
||||
done.position = { x: 720, y: 120 }
|
||||
graph.edges = graph.edges.filter((e) => !(e.source === 'fetch' && e.target === 'done'))
|
||||
graph.edges.push({ id: 'fetch:->report', source: 'fetch', target: 'report' }, { id: 'report:->done', source: 'report', target: 'done' })
|
||||
const was = clone(done.data.config.fields)
|
||||
done.data.config.fields = was.map((f) => ({ ...f, value: '{{ nodes.report.text }}' }))
|
||||
step('R3', 'fetch', '在「取数」和「成果」之间插入报告撰写,出口改取报告的正文', [
|
||||
{ node_id: 'report', node_title: '报告撰写', field: 'node', before: null, after: { id: 'report', type: 'report', label: '报告撰写', config } },
|
||||
{ node_id: null, node_title: '', field: 'edge', before: null, after: { source: 'fetch', target: 'report' } },
|
||||
{ node_id: null, node_title: '', field: 'edge', before: { source: 'fetch', target: 'done' }, after: { source: 'report', target: 'done' } },
|
||||
{ node_id: 'done', node_title: '成果', field: 'fields', before: was, after: done.data.config.fields },
|
||||
])
|
||||
notes.push({ rule: 'R3', node_id: 'report', level: 'info', text: R3_NOTE })
|
||||
}
|
||||
if (fetch?.data.config.output_schema && !fetch.data.config.cite_fields) {
|
||||
fetch.data.config.cite_fields = true
|
||||
step('R4', 'fetch', '打开「取数」的按出处核对字段', [
|
||||
{ node_id: 'fetch', node_title: '取数', field: 'cite_fields', before: null, after: true }])
|
||||
}
|
||||
if (at('calc')) notes.push({ rule: 'R5', node_id: 'calc', level: 'info', text: R5_NOTE })
|
||||
const assist = body.assist
|
||||
? { ok: true, summary: '「计算」调用了日期函数,不是纯算术:按规矩保留原样', questions: [],
|
||||
warnings: ['「计算」不是纯算术(用了 datetime),保留原样,没有改成口径卡表达式'] }
|
||||
: null
|
||||
return { graph, changes: bare ? [] : changes, ops: [], notes, issues: [], applied: [...new Set(changes.map((c) => c.fix_id))],
|
||||
rejected: [], assist, ok: true }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 浏览器
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME })
|
||||
@@ -302,17 +411,28 @@ 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, fixDelay = 0 } = {}) {
|
||||
/**
|
||||
* upgrade(一键升级那几段):'new' 有接口 | 'old' 接口 404 | 'bare' 只回图不回 changes;
|
||||
* advice:校验给不给那条建议(老后端不给);path:打开的地址(默认 /studio/<id>)
|
||||
*/
|
||||
async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new', save = 'ok', unpublished = false, fixDelay = 0,
|
||||
upgrade = 'new', advice = true, path = null, upgradeDelay = 0 } = {}) {
|
||||
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 */ } })
|
||||
// 升级那几段:问题面板拉高一些,预览一屏看得全(截图也看得清)
|
||||
if (id === 'pf-legacy' || id === 'pf-ask') {
|
||||
await ctx.addInitScript(() => { try { localStorage.setItem('agentlab.studio.dockHeight', '460') } 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 }
|
||||
// validate:'ok' | 'fail'(校验接口 500,后端断开时和保存一起失败)。检查中途改它,下一次校验就按新的回
|
||||
const state = { checks: [], autofixes: [], patches: [], publishes: [], upgrades: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend, upgrade,
|
||||
validate: 'ok' }
|
||||
|
||||
await page.route(/\/api\//, (route) => {
|
||||
const req = route.request()
|
||||
@@ -321,6 +441,19 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
|
||||
return json(route, { detail: '检查脚本不写库' }, 409)
|
||||
})
|
||||
await page.route(/\/api\/workflows\/(validate|variables)$/, (route) => route.continue())
|
||||
// 一键升级的两张图:校验按图现算那条建议(真后端怎么说不影响这几段)
|
||||
if (id === 'pf-legacy' || id === 'pf-ask') {
|
||||
await page.route(/\/api\/workflows\/validate$/, (route) => state.validate === 'fail'
|
||||
? json(route, { detail: '校验服务暂时连不上' }, 500)
|
||||
: json(route, { ok: true, issues: advice ? upgradeAdvice(route.request().postDataJSON()?.graph ?? { nodes: [], edges: [] }) : [] }))
|
||||
}
|
||||
await page.route(/\/api\/copilot\/upgrade-evidence$/, async (route) => {
|
||||
const body = route.request().postDataJSON()
|
||||
state.upgrades.push(body)
|
||||
if (state.upgrade === 'old') return json(route, { detail: 'Not Found' }, 404)
|
||||
if (upgradeDelay) await new Promise((r) => setTimeout(r, upgradeDelay))
|
||||
return json(route, upgradeFake(body, { bare: state.upgrade === 'bare' }))
|
||||
})
|
||||
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(() => [])
|
||||
@@ -377,7 +510,8 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
|
||||
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' })
|
||||
// 带参数进来(?upgrade=1)时不等网络静下来:参数没摘掉的话页面会一直重复请求,要让下面的检查说出是哪一条
|
||||
await page.goto(`${WEB}${path ?? `/studio/${id}`}`, { waitUntil: path ? 'load' : 'networkidle' })
|
||||
await page.waitForFunction((wid) => window.__studio?.getState().workflow?.id === wid, id, { timeout: 15000 })
|
||||
await page.waitForTimeout(400)
|
||||
return { ctx, page, errors, state }
|
||||
@@ -877,6 +1011,408 @@ await section('choice 里选「交给 Copilot」:请求带 assist、等待时
|
||||
check('没有页面错误', !errors.length && !errors2.length, [...errors, ...errors2].join(' | '))
|
||||
})
|
||||
|
||||
// ================================================================ 一键升级为可追溯结构(五期)
|
||||
|
||||
const dock = (page) => page.locator('#dock-problems')
|
||||
async function openProblems(page) {
|
||||
await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } })
|
||||
await page.keyboard.press('Alt+KeyP')
|
||||
await dock(page).waitFor()
|
||||
}
|
||||
const upgradeBox = (page) => dock(page).locator('[data-upgrade]')
|
||||
const preview = (page) => dock(page).locator('[data-upgrade-preview]')
|
||||
const flat = (t) => String(t ?? '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
await section('升级:建议不算问题,问题面板给快速修复;预览逐项写改动,类型变化写清楚,说明原样', async () => {
|
||||
const { page, state, errors } = await open({ id: 'pf-legacy' })
|
||||
await waitFor(page, async () => page.evaluate(() => window.__studio.getState().advice.length > 0))
|
||||
const chip = flat(await page.locator('button.chip').first().innerText().catch(() => ''))
|
||||
const st = await page.evaluate(() => { const s = window.__studio.getState(); return { issues: s.issues.length, advice: s.advice.map((a) => a.code) } })
|
||||
check('校验给的建议进 advice,不进 issues(节点卡、检查器、运行按钮只认 error / warning)', st.issues === 0
|
||||
&& JSON.stringify(st.advice) === '["evidence.upgrade_available"]', JSON.stringify(st))
|
||||
check('工具栏照样说「可运行」:建议不算提示', chip.includes('可运行'), chip.slice(0, 120))
|
||||
const kept = await page.evaluate(async () => {
|
||||
// 按页面自己加载时的地址 import:改过的模块地址带 ?t=,直接写 /src/… 会拿到另一份实例
|
||||
const url = performance.getEntriesByType('resource').map((e) => e.name)
|
||||
.find((n) => { try { return new URL(n).pathname === '/src/canvas/issues.ts' } catch { return false } })
|
||||
const m = await import(url ?? '/src/canvas/issues.ts')
|
||||
const info = { level: 'info', code: 'evidence.upgrade_available', message: '可以升级为可追溯结构', node_id: null }
|
||||
const warn = { level: 'warning', code: null, message: '一条提示', node_id: null }
|
||||
return { problems: m.problemsOf([info, warn], []).map((p) => p.level), check: m.normalizeCheck({ issues: [info, warn], fixes: [] }, 'published').issues.length }
|
||||
}).catch((e) => ({ error: String(e) }))
|
||||
check('建议不进问题清单,也不算进发布前检查的「另有 N 条提示」', JSON.stringify(kept) === JSON.stringify({ problems: ['warning'], check: 1 }), JSON.stringify(kept))
|
||||
await openProblems(page)
|
||||
check('问题面板:没有问题行,但有「可以升级为可追溯结构」', await dock(page).locator('[data-problem]').count() === 0
|
||||
&& (await upgradeBox(page).locator('[data-upgrade-advice]').innerText().catch(() => '')).includes('可以升级为可追溯结构'))
|
||||
const action = upgradeBox(page).locator('[data-upgrade-action]')
|
||||
check('快速修复写「升级为可追溯结构(预览改动)」', flat(await action.innerText().catch(() => '')) === '升级为可追溯结构(预览改动)',
|
||||
await action.innerText().catch(() => ''))
|
||||
check('……「没有发现问题」照样说', (await dock(page).innerText()).includes('没有发现问题'))
|
||||
check('还没点之前不请求升级接口', state.upgrades.length === 0)
|
||||
await action.click()
|
||||
await page.locator('#dock-problems [data-upgrade-preview="ready"]').waitFor()
|
||||
const req = state.upgrades[0]
|
||||
check('请求带着画布上的整张图(连同全图默认),不带 assist', req?.graph?.nodes?.length === LEGACY.nodes.length
|
||||
&& req?.graph?.edges?.length === LEGACY.edges.length && req?.graph?.defaults?.approval === 'always' && !('assist' in (req ?? {})),
|
||||
JSON.stringify({ n: req?.graph?.nodes?.length, d: req?.graph?.defaults, a: req?.assist }))
|
||||
const typeRow = flat(await preview(page).locator('[data-upgrade-change="type"]').innerText().catch(() => ''))
|
||||
check('节点类型的变化写清楚:「写周报」· 节点类型:模型调用 → 报告撰写', /^「写周报」 · 节点类型:\s*模型调用\s*报告撰写$/.test(typeRow)
|
||||
&& flat(await preview(page).locator('[data-upgrade-change="type"] [data-upgrade-before]').innerText()) === '模型调用'
|
||||
&& flat(await preview(page).locator('[data-upgrade-change="type"] [data-upgrade-after]').innerText()) === '报告撰写', typeRow)
|
||||
const r1 = flat(await preview(page).locator('[data-upgrade-step="R1"] [data-upgrade-step-label]').innerText().catch(() => ''))
|
||||
check('同一步的几项改动放在一组,规则(R1)和这一步的说明只写一次', r1 === 'R1 把「写周报」换成报告撰写,出具契约改为核对它的文档'
|
||||
&& await preview(page).locator('[data-upgrade-step="R1"] [data-upgrade-change]').count() === 5
|
||||
&& !(await preview(page).locator('[data-upgrade-step="R1"] ul').innerText()).includes('出具契约改为核对它的文档'), r1)
|
||||
const rows = (await preview(page).locator('[data-upgrade-change]').allInnerTexts()).map(flat)
|
||||
const contract = rows.find((r) => r.includes('出具契约')) ?? ''
|
||||
check('契约改动按键一行写、节点引用写节点名(报告来自:「写周报」)', contract.includes('「成果」 · 出具契约') && contract.includes('报告来自:「写周报」')
|
||||
&& contract.includes('叙述:{{ nodes.story.text }}'), contract)
|
||||
const cite = rows.find((r) => r.includes('取数')) ?? ''
|
||||
check('R4 用字段的界面叫法:「取数」· 按出处核对字段:(空)→ 是', /「取数」 · 按出处核对字段:\s*(空)\s*是/.test(cite), cite)
|
||||
const instructions = rows.find((r) => r.includes('写作要求')) ?? ''
|
||||
check('换了类型的节点,新字段按新类型的叫法念(写作要求)', instructions.includes('「写周报」 · 写作要求') && instructions.includes('按口径写本周周报'), instructions)
|
||||
const prompt = rows.find((r) => r.includes('按口径写本周周报') && !r.includes('写作要求')) ?? ''
|
||||
check('……换掉类型时去掉的,按原来的叫法念(用户提示:… → (空))', /「写周报」 · 用户提示:\s*按口径写本周周报\s*(空)/.test(prompt), prompt)
|
||||
check('一共 6 处改动,标题写着', flat(await preview(page).locator('.font-medium').first().innerText()) === '升级预览 · 6 处改动')
|
||||
check('合计:1 个节点换了类型、改了 2 个节点的配置', flat(await preview(page).locator('[data-upgrade-summary]').innerText().catch(() => ''))
|
||||
=== '合计:1 个节点换了类型、改了 2 个节点的配置')
|
||||
const note = await preview(page).locator('[data-upgrade-note="R5"] [data-upgrade-note-text]').innerText().catch(() => '')
|
||||
check('说明原样显示(R5:代码节点要不要标 source,等人确认)', note === R5_NOTE && await preview(page).locator('[data-upgrade-note]').count() === 1, note)
|
||||
check('升级后没有新的错误:照实说', flat(await preview(page).locator('[data-upgrade-after-check]').innerText().catch(() => '')) === '升级后校验和门禁都没有新的错误')
|
||||
check('预览不改画布、不保存', state.patches.length === 0
|
||||
&& await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'story').data.nodeType) === 'llm')
|
||||
check('预览开着时快速修复按钮收起(不重复开)', await upgradeBox(page).locator('[data-upgrade-action]').count() === 0)
|
||||
await shoot(page, 'upgrade-preview', page.locator('section[aria-label="问题"]'))
|
||||
await preview(page).locator('[data-upgrade-discard]').click()
|
||||
check('放弃:预览收起,快速修复回来,画布没动', await preview(page).count() === 0 && await upgradeBox(page).locator('[data-upgrade-action]').count() === 1
|
||||
&& state.patches.length === 0)
|
||||
// 问题面板停在「发布前检查」时又开了一次升级预览(记录页的横幅跳过来):切回「校验」,预览看得见
|
||||
await dock(page).locator('[data-problems-mode="publish"]').click()
|
||||
await dock(page).locator('[data-preflight="panel"]').waitFor()
|
||||
await page.evaluate(() => window.__studio.getState().previewUpgrade())
|
||||
await page.locator('#dock-problems [data-upgrade-preview="ready"]').waitFor({ timeout: 4000 }).catch(() => {})
|
||||
check('停在发布前检查时开了升级预览:切回「校验」,预览摆着', await dock(page).locator('[data-problems-mode="lint"]').getAttribute('aria-checked') === 'true'
|
||||
&& await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1)
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('升级:应用走现有的保存,全图默认不丢,节点类型真的换了', async () => {
|
||||
const { page, state, errors } = await open({ id: 'pf-legacy' })
|
||||
await page.evaluate(() => window.__studio.setState({ pendingNote: '回滚到 v2' }))
|
||||
await openProblems(page)
|
||||
await upgradeBox(page).locator('[data-upgrade-action]').click()
|
||||
await page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
await preview(page).locator('[data-upgrade-apply]').click()
|
||||
await waitFor(page, async () => state.patches.length === 1 && await preview(page).count() === 0)
|
||||
const saved = state.patches[0]?.graph
|
||||
const story = saved?.nodes?.find((n) => n.id === 'story')
|
||||
check('保存的载荷:「写周报」换成报告撰写,写作要求是合成的', story?.type === 'report' && story?.data?.config?.instructions === '你是分析师\n\n按口径写本周周报',
|
||||
JSON.stringify(story))
|
||||
check('……契约改成 report_from,required / strict 原样', JSON.stringify(cfgOf(saved, 'done')?.contract)
|
||||
=== JSON.stringify({ metrics_from: ['caliber'], report_from: 'story', required: ['gmv'], strict: true }))
|
||||
check('……全图默认原样带着(画布只管节点和连线,保存照样不能丢)', JSON.stringify(saved?.defaults) === JSON.stringify(LEGACY.defaults),
|
||||
JSON.stringify(saved?.defaults ?? null))
|
||||
check('……节点和连线一个没少', saved?.nodes?.length === LEGACY.nodes.length && saved?.edges?.length === LEGACY.edges.length)
|
||||
check('版本说明:「回滚到 v2」保留,后面接着写升级', state.patches[0]?.note === '回滚到 v2;升级为可追溯结构', state.patches[0]?.note)
|
||||
const st = await page.evaluate(() => {
|
||||
const s = window.__studio.getState()
|
||||
return { type: s.nodes.find((n) => n.id === 'story').data.nodeType, dirty: s.dirty, label: s.past.at(-1)?.label, v: s.workflow.version,
|
||||
card: document.querySelector('.react-flow__node[data-id="story"] [data-type]')?.getAttribute('data-type') ?? '' }
|
||||
})
|
||||
check('画布上的节点真的换成报告撰写(卡片也跟着换)', st.type === 'report' && st.card === 'report', JSON.stringify(st))
|
||||
check('存完不再「未保存」,撤销栈里这一步叫「升级为可追溯结构」', !st.dirty && st.label === '升级为可追溯结构' && st.v === 4, JSON.stringify(st))
|
||||
await waitFor(page, async () => (await upgradeBox(page).count()) === 0, 4000)
|
||||
check('重新校验之后建议没了,入口跟着收起', await upgradeBox(page).count() === 0)
|
||||
check('只存了一次,没有发布', state.patches.length === 1 && state.publishes.length === 0)
|
||||
const toasts = (await page.locator('[data-toast], [role=status]').allInnerTexts()).join(' ')
|
||||
check('提示存成了哪一版', toasts.includes('已升级为可追溯结构,存为草稿 v4'), toasts.slice(0, 160))
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('升级:问数据的图插入报告节点、改接连线;后端只给图时照样逐项列', async () => {
|
||||
const { page, state } = await open({ id: 'pf-ask' })
|
||||
await openProblems(page)
|
||||
await upgradeBox(page).locator('[data-upgrade-action]').waitFor()
|
||||
await upgradeBox(page).locator('[data-upgrade-action]').click()
|
||||
await page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
const added = flat(await preview(page).locator('[data-upgrade-change="node"]').innerText().catch(() => ''))
|
||||
check('新插入的节点:新增节点:「报告撰写」(报告撰写)', added === '新增节点:「报告撰写」(报告撰写)', added)
|
||||
const edges = (await preview(page).locator('[data-upgrade-change="edge"]').allInnerTexts()).map(flat)
|
||||
check('新连线:「取数」 → 「报告撰写」(节点名取自升级后的图)', edges.includes('新连线:「取数」 → 「报告撰写」'), edges.join(' | '))
|
||||
check('改接的连线:连线:「取数」 → 「成果」 改接为 「报告撰写」 → 「成果」', edges.some((e) => /^连线:「取数」 → 「成果」\s*改接为\s*「报告撰写」 → 「成果」$/.test(e)),
|
||||
edges.join(' | '))
|
||||
const field = flat(await preview(page).locator('[data-upgrade-change="value"]').innerText().catch(() => ''))
|
||||
check('成果字段整列改:一项一行(answer:… → answer:{{ nodes.report.text }})', /^「成果」 · 成果字段:\s*answer:\{\{ nodes\.fetch\.text \}\}\s*answer:\{\{ nodes\.report\.text \}\}$/.test(field), field)
|
||||
const note = await preview(page).locator('[data-upgrade-note="R3"] [data-upgrade-note-text]').innerText().catch(() => '')
|
||||
check('R3 的说明(契约要不要写 cells 由人定)原样显示', note === R3_NOTE, note)
|
||||
check('合计:新增 1 个节点、改了 1 个节点的配置、连线变动 3 处', flat(await preview(page).locator('[data-upgrade-summary]').innerText().catch(() => ''))
|
||||
=== '合计:新增 1 个节点、改了 1 个节点的配置、连线变动 3 处')
|
||||
await shoot(page, 'upgrade-preview-ask', page.locator('section[aria-label="问题"]'))
|
||||
await preview(page).locator('[data-upgrade-apply]').click()
|
||||
await waitFor(page, async () => state.patches.length === 1)
|
||||
const saved = state.patches[0]?.graph
|
||||
check('应用:新节点和改接的连线都存进去(成果改取报告的正文,没替人加契约)', saved?.nodes?.some((n) => n.id === 'report' && n.type === 'report')
|
||||
&& saved?.edges?.some((e) => e.source === 'report' && e.target === 'done') && !saved?.edges?.some((e) => e.source === 'fetch' && e.target === 'done')
|
||||
&& cfgOf(saved, 'done')?.fields?.[0]?.value === '{{ nodes.report.text }}' && !cfgOf(saved, 'done')?.contract, JSON.stringify(saved?.edges))
|
||||
check('……画布上也有了这个节点', await page.evaluate(() => window.__studio.getState().nodes.some((n) => n.id === 'report' && n.data.nodeType === 'report')))
|
||||
|
||||
const bare = await open({ id: 'pf-ask', upgrade: 'bare' })
|
||||
await openProblems(bare.page)
|
||||
await upgradeBox(bare.page).locator('[data-upgrade-action]').click()
|
||||
await bare.page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
const kinds = await preview(bare.page).locator('[data-upgrade-change]').evaluateAll((els) => els.map((e) => e.getAttribute('data-upgrade-change')))
|
||||
check('后端只给图、不给逐项改动:按前后两张图自己列(新节点、改的配置、连线)', kinds.includes('node') && kinds.includes('value')
|
||||
&& kinds.filter((k) => k === 'edge').length === 3, kinds.join(','))
|
||||
check('……照样能应用', await preview(bare.page).locator('[data-upgrade-apply]').count() === 1)
|
||||
})
|
||||
|
||||
await section('升级:画布锁着不给入口;预览摆着时锁上,应用落不下去', async () => {
|
||||
const { page, state } = await open({ id: 'pf-legacy' })
|
||||
await openProblems(page)
|
||||
await upgradeBox(page).locator('[data-upgrade-action]').waitFor()
|
||||
await page.evaluate(() => {
|
||||
const s = window.__studio.getState()
|
||||
window.__studio.setState({ runPhase: 'running', trace: { ...s.trace, runClass: 'formal' } })
|
||||
})
|
||||
await page.waitForTimeout(200)
|
||||
check('正式运行进行中:没有快速修复按钮', await upgradeBox(page).locator('[data-upgrade-action]').count() === 0)
|
||||
check('……写明为什么现在不能升级', flat(await upgradeBox(page).locator('[data-upgrade-locked]').innerText().catch(() => '')).includes('正式运行进行中'))
|
||||
await page.evaluate(() => window.__studio.getState().previewUpgrade())
|
||||
await page.waitForTimeout(200)
|
||||
check('锁着时直接调也不请求升级接口', state.upgrades.length === 0)
|
||||
await page.evaluate(() => window.__studio.setState({ runPhase: 'idle' }))
|
||||
await upgradeBox(page).locator('[data-upgrade-action]').click()
|
||||
await page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
await page.evaluate(() => window.__studio.setState({ copilot: { ...window.__studio.getState().copilot, active: true } }))
|
||||
await page.waitForTimeout(200)
|
||||
check('助手在改时:预览还在,但没有「应用」和「交给 Copilot」', await preview(page).count() === 1
|
||||
&& await preview(page).locator('[data-upgrade-apply], [data-upgrade-assist]').count() === 0
|
||||
&& flat(await upgradeBox(page).locator('[data-upgrade-locked]').innerText().catch(() => '')).includes('助手正在改'))
|
||||
const ok = await page.evaluate(() => window.__studio.getState().applyUpgrade())
|
||||
check('锁着时直接调应用也落不下去、不保存', ok === false && state.patches.length === 0
|
||||
&& await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'story').data.nodeType) === 'llm')
|
||||
})
|
||||
|
||||
await section('升级:老后端(校验不给这条建议)不给入口;升级接口 404 照实说', async () => {
|
||||
const { page, state } = await open({ id: 'pf-legacy', advice: false })
|
||||
await openProblems(page)
|
||||
await page.waitForTimeout(600)
|
||||
check('没有这条建议:问题面板里没有升级入口', await upgradeBox(page).count() === 0 && (await dock(page).innerText()).includes('没有发现问题'))
|
||||
check('……也不去请求升级接口', state.upgrades.length === 0)
|
||||
|
||||
const old = await open({ id: 'pf-legacy', upgrade: 'old' })
|
||||
await openProblems(old.page)
|
||||
await upgradeBox(old.page).locator('[data-upgrade-action]').click()
|
||||
await old.page.locator('#dock-problems [data-upgrade-preview]:not([data-upgrade-preview="loading"])').waitFor()
|
||||
check('升级接口不存在(404):照实说这个后端还不支持', await preview(old.page).getAttribute('data-upgrade-preview') === 'unsupported'
|
||||
&& flat(await preview(old.page).innerText()).includes('这个后端还不支持一键升级'), await preview(old.page).innerText())
|
||||
check('……没有应用按钮、不保存', await preview(old.page).locator('[data-upgrade-apply]').count() === 0 && old.state.patches.length === 0)
|
||||
})
|
||||
|
||||
await section('升级:再交给 Copilot 改语义层:请求带 assist,保留没改的写出来', async () => {
|
||||
const { page, state } = await open({ id: 'pf-legacy', upgradeDelay: 800 })
|
||||
await openProblems(page)
|
||||
await upgradeBox(page).locator('[data-upgrade-action]').click()
|
||||
await page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
const btn = preview(page).locator('[data-upgrade-assist]')
|
||||
check('预览里有「再交给 Copilot 改语义层」,悬停说清它改什么、要调模型', flat(await btn.innerText()) === '再交给 Copilot 改语义层'
|
||||
&& (await btn.getAttribute('title') ?? '').includes('纯算术'))
|
||||
await btn.click()
|
||||
const loading = page.locator('[data-upgrade-preview="loading"]')
|
||||
await loading.waitFor()
|
||||
check('等待时说 Copilot 在改', flat(await loading.innerText()).includes('Copilot 正在改语义层'))
|
||||
check('请求带复核用的发布级别:已发布的图按「已发布」', state.upgrades[0]?.level === 'published', JSON.stringify(state.upgrades[0]?.level))
|
||||
check('第二次请求带 assist: true、带的还是画布上的原图', state.upgrades.length === 2 && state.upgrades[1]?.assist === true
|
||||
&& state.upgrades[1]?.graph?.nodes?.find((n) => n.id === 'story')?.type === 'llm', JSON.stringify(state.upgrades.map((u) => u.assist ?? null)))
|
||||
await page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
const said = flat(await preview(page).locator('[data-upgrade-assist-said]').innerText().catch(() => ''))
|
||||
check('Copilot 的总结和保留没改的代码节点写出来', said.includes('不是纯算术') && said.includes('保留没改的')
|
||||
&& said.includes('没有改成口径卡表达式'), said)
|
||||
check('已经是 Copilot 的结果:不再给「再交给 Copilot」', await preview(page).locator('[data-upgrade-assist]').count() === 0)
|
||||
await preview(page).locator('[data-upgrade-assist-said]').scrollIntoViewIfNeeded().catch(() => {})
|
||||
await shoot(page, 'upgrade-preview-assist', page.locator('section[aria-label="问题"]'))
|
||||
// 受管的图:新写出来的报告节点要直接按受管门禁的要求配,请求带 governed
|
||||
await page.evaluate(() => { const s = window.__studio.getState(); window.__studio.setState({ workflow: { ...s.workflow, status: 'governed' } }) })
|
||||
await preview(page).locator('[data-upgrade-discard]').click()
|
||||
await upgradeBox(page).locator('[data-upgrade-action]').click()
|
||||
await waitFor(page, async () => state.upgrades.length === 3)
|
||||
check('受管的图按「受管」复核', state.upgrades[2]?.level === 'governed', JSON.stringify(state.upgrades[2]?.level))
|
||||
})
|
||||
|
||||
await section('升级:预览之后画布又改过就不套用;存失败给重试保存', async () => {
|
||||
const { page, state } = await open({ id: 'pf-legacy' })
|
||||
await openProblems(page)
|
||||
await upgradeBox(page).locator('[data-upgrade-action]').click()
|
||||
await page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
await page.evaluate(() => window.__studio.getState().updateNode('caliber', { label: '周报口径2' }))
|
||||
await preview(page).locator('[data-upgrade-apply]').click()
|
||||
await preview(page).locator('[data-upgrade-stale]').waitFor({ timeout: 3000 }).catch(() => {})
|
||||
check('画布改过:说预览对不上了,不套用、不保存', await preview(page).locator('[data-upgrade-stale]').count() === 1 && state.patches.length === 0
|
||||
&& await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'story').data.nodeType) === 'llm')
|
||||
await preview(page).locator('[data-upgrade-stale]').getByRole('button', { name: '重新预览' }).click({ timeout: 2000 }).catch(() => {})
|
||||
await waitFor(page, async () => state.upgrades.length === 2 && await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1)
|
||||
check('「重新预览」按现在的画布再要一份(带着刚改的名字)', state.upgrades.length === 2
|
||||
&& state.upgrades[1]?.graph?.nodes?.find((n) => n.id === 'caliber')?.data?.label === '周报口径2'
|
||||
&& await preview(page).locator('[data-upgrade-stale]').count() === 0)
|
||||
|
||||
const bad = await open({ id: 'pf-legacy', save: 'fail' })
|
||||
await openProblems(bad.page)
|
||||
await upgradeBox(bad.page).locator('[data-upgrade-action]').click()
|
||||
await bad.page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
await preview(bad.page).locator('[data-upgrade-apply]').click()
|
||||
await preview(bad.page).locator('[data-upgrade-save-failed]').waitFor().catch(() => {})
|
||||
const why = flat(await preview(bad.page).locator('[data-upgrade-save-failed]').innerText({ timeout: 1000 }).catch(() => ''))
|
||||
check('存失败:说清没存上、升级已经在画布上,给「重试保存」', why.includes('草稿没保存上') && why.includes('数据库锁住了')
|
||||
&& why.includes('已经放到画布上') && await bad.page.evaluate(() => window.__studio.getState().dirty), why)
|
||||
bad.state.save = 'ok'
|
||||
await preview(bad.page).getByRole('button', { name: '重试保存' }).click({ timeout: 2000 }).catch(() => {})
|
||||
await waitFor(bad.page, async () => bad.state.patches.length === 2 && await preview(bad.page).count() === 0)
|
||||
check('重试保存:存上,预览收起', bad.state.patches.length === 2 && bad.state.patches[1]?.graph?.nodes?.find((n) => n.id === 'story')?.type === 'report'
|
||||
&& !(await bad.page.evaluate(() => window.__studio.getState().dirty)))
|
||||
})
|
||||
|
||||
await section('升级:没存上之后——平常的保存存上了就收起;画布和升级结果对不上时不替它存;也能放弃', async () => {
|
||||
// 三种收尾共同的起点:升级落到画布上、存失败。之后保存接口恢复
|
||||
const failedApply = async () => {
|
||||
const r = await open({ id: 'pf-legacy', save: 'fail' })
|
||||
await openProblems(r.page)
|
||||
await upgradeBox(r.page).locator('[data-upgrade-action]').click()
|
||||
await r.page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
await preview(r.page).locator('[data-upgrade-apply]').click()
|
||||
await preview(r.page).locator('[data-upgrade-save-failed]').waitFor().catch(() => {})
|
||||
r.state.save = 'ok'
|
||||
return r
|
||||
}
|
||||
const upOf = (page) => page.evaluate(() => {
|
||||
const u = window.__studio.getState().upgrade
|
||||
return u ? `${u.status}:${u.apply?.status ?? ''}` : null
|
||||
})
|
||||
const storyType = (page) => page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'story').data.nodeType)
|
||||
|
||||
// 一、平常的保存(⌘S、工具栏的「保存」都走 studio.save)
|
||||
const a = await failedApply()
|
||||
check('起点:存失败,面板写着「草稿没保存上」', await preview(a.page).locator('[data-upgrade-save-failed]').count() === 1 && a.state.patches.length === 1,
|
||||
`${await upOf(a.page)} patches=${a.state.patches.length}`)
|
||||
const saved = await a.page.evaluate(async () => {
|
||||
window.__p5up = window.__studio.getState().upgrade
|
||||
try { await window.__studio.getState().save(); return 'ok' } catch (e) { return String(e) }
|
||||
})
|
||||
await waitFor(a.page, async () => await dock(a.page).locator('[data-upgrade-save-failed]').count() === 0, 3000)
|
||||
check('平常的保存存上了:「草稿没保存上」收起,升级的状态清掉', saved === 'ok' && a.state.patches.length === 2
|
||||
&& await dock(a.page).locator('[data-upgrade-save-failed]').count() === 0 && await upOf(a.page) === null,
|
||||
`${saved} ${await upOf(a.page)} patches=${a.state.patches.length}`)
|
||||
check('……存的是画布上升级完的图,版本说明没冒充「升级为可追溯结构」', a.state.patches[1]?.graph?.nodes?.find((n) => n.id === 'story')?.type === 'report'
|
||||
&& a.state.patches[1]?.note === undefined, JSON.stringify(a.state.patches[1]?.note ?? null))
|
||||
const variants = await a.page.evaluate(async () => {
|
||||
const st = window.__studio
|
||||
const base = window.__p5up
|
||||
st.setState({ upgrade: { ...base, apply: { status: 'locked', why: '助手正在改这张工作流', unsaved: true } } })
|
||||
await st.getState().save()
|
||||
const locked = st.getState().upgrade
|
||||
st.setState({ upgrade: { ...base, apply: undefined } })
|
||||
await st.getState().save()
|
||||
const open = st.getState().upgrade
|
||||
st.getState().discardUpgrade()
|
||||
return { locked: locked ? `${locked.status}:${locked.apply?.status}` : null, open: open ? `${open.status}:${open.apply?.status ?? ''}` : null }
|
||||
})
|
||||
check('锁着没存上的,平常的保存存上了同样清掉;预览还没应用的,平常的保存不替人收起', variants.locked === null && variants.open === 'ready:',
|
||||
JSON.stringify(variants))
|
||||
|
||||
// 二、撤销了升级再点「重试保存」:存下去的是没升级的图,版本说明却写着升级,不能存
|
||||
const b = await failedApply()
|
||||
await b.page.evaluate(() => window.__studio.getState().undo())
|
||||
check('撤销了升级:画布回到升级前', await storyType(b.page) === 'llm')
|
||||
await preview(b.page).getByRole('button', { name: '重试保存' }).click({ timeout: 2000 }).catch(() => {})
|
||||
await b.page.waitForTimeout(500)
|
||||
check('画布内容和升级结果对不上:点「重试保存」不发保存请求', b.state.patches.length === 1, `patches=${b.state.patches.length}`)
|
||||
check('……改说这份预览对不上了(给「重新预览」),不再说「草稿没保存上」', await preview(b.page).locator('[data-upgrade-stale]').count() === 1
|
||||
&& await preview(b.page).locator('[data-upgrade-save-failed]').count() === 0, String(await upOf(b.page)))
|
||||
// 重做回升级完的样子、再改一处:画布上有升级,但也有升级之外的改动,同样不替它存
|
||||
const direct = await b.page.evaluate(async () => {
|
||||
const st = window.__studio
|
||||
st.getState().redo()
|
||||
const redone = st.getState().nodes.find((n) => n.id === 'story').data.nodeType
|
||||
st.setState({ upgrade: { ...st.getState().upgrade, apply: { status: 'failed', error: new Error('x') } } })
|
||||
st.getState().updateNode('caliber', { label: '周报口径2' })
|
||||
return { redone, ok: await st.getState().retryUpgradeSave(), apply: st.getState().upgrade?.apply?.status ?? null }
|
||||
})
|
||||
await b.page.waitForTimeout(300)
|
||||
check('……升级之后又改过画布的,直接调重试保存也不存', direct.redone === 'report' && direct.ok === false && direct.apply === 'stale'
|
||||
&& b.state.patches.length === 1, `${JSON.stringify(direct)} patches=${b.state.patches.length}`)
|
||||
|
||||
// 三、没存上时也能放弃:只收起预览,画布上的升级留着、不再存
|
||||
const c = await failedApply()
|
||||
const drop = preview(c.page).locator('[data-upgrade-save-failed] [data-upgrade-discard]')
|
||||
check('没存上时有「放弃」,和「重试保存」并排', await drop.count() === 1 && flat(await drop.innerText().catch(() => '')) === '放弃'
|
||||
&& await preview(c.page).locator('[data-upgrade-save-failed]').getByRole('button', { name: '重试保存' }).count() === 1)
|
||||
await c.page.evaluate(() => {
|
||||
const u = window.__studio.getState().upgrade
|
||||
window.__studio.setState({ upgrade: { ...u, apply: { status: 'locked', why: '助手正在改这张工作流', unsaved: true } } })
|
||||
})
|
||||
check('……锁着没存上时同样有「放弃」', await preview(c.page).locator('[data-upgrade-save-failed="locked"] [data-upgrade-discard]').count() === 1)
|
||||
await drop.click({ timeout: 2000 }).catch(() => {})
|
||||
await c.page.waitForTimeout(300)
|
||||
check('点「放弃」:状态清掉、预览收起;画布上的升级留着,没有再存', await upOf(c.page) === null && await preview(c.page).count() === 0
|
||||
&& c.state.patches.length === 1 && await storyType(c.page) === 'report' && await c.page.evaluate(() => window.__studio.getState().dirty),
|
||||
`${await upOf(c.page)} patches=${c.state.patches.length}`)
|
||||
check('没有运行时报错', [...a.errors, ...b.errors, ...c.errors].length === 0, [...a.errors, ...b.errors, ...c.errors].join(' | '))
|
||||
})
|
||||
|
||||
await section('升级:校验接口失败时升级块照样在(后端断开时保存和校验一起失败,「重试保存」不能跟着消失)', async () => {
|
||||
const { page, state, errors } = await open({ id: 'pf-legacy', save: 'fail' })
|
||||
await openProblems(page)
|
||||
await upgradeBox(page).locator('[data-upgrade-action]').click()
|
||||
await page.locator('[data-upgrade-preview="ready"]').waitFor()
|
||||
state.validate = 'fail'
|
||||
await page.evaluate(() => window.__studio.getState().analyzeNow())
|
||||
await waitFor(page, async () => page.evaluate(() => window.__studio.getState().analysis === 'failed'), 3000)
|
||||
check('起点:校验接口失败,面板说分析失败、给「重试」', (await dock(page).innerText()).includes('分析失败')
|
||||
&& await dock(page).getByRole('button', { name: '重试', exact: true }).count() === 1)
|
||||
check('升级预览开着时校验接口失败:升级块和预览照样在', await upgradeBox(page).count() === 1
|
||||
&& await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1
|
||||
&& await preview(page).locator('[data-upgrade-apply]').count() === 1)
|
||||
const layout = await page.evaluate(() => {
|
||||
const box = document.querySelector('#dock-problems [data-upgrade]')
|
||||
const fail = document.querySelector('#dock-problems [data-analysis-failed]')
|
||||
const scroller = box?.parentElement
|
||||
return { above: !!(box && fail && box.compareDocumentPosition(fail) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
sameScroller: !!scroller && scroller === fail?.parentElement && getComputedStyle(scroller).overflowY === 'auto' }
|
||||
})
|
||||
check('……升级块在「分析失败」上面,两块在同一个可滚动的容器里', layout.above && layout.sameScroller, JSON.stringify(layout))
|
||||
|
||||
await preview(page).locator('[data-upgrade-apply]').click()
|
||||
await preview(page).locator('[data-upgrade-save-failed]').waitFor({ timeout: 3000 }).catch(() => {})
|
||||
await waitFor(page, async () => page.evaluate(() => window.__studio.getState().analysis === 'failed'), 3000)
|
||||
check('保存和校验一起失败:「草稿没保存上」「重试保存」「放弃」照样在', await page.evaluate(() => window.__studio.getState().analysis) === 'failed'
|
||||
&& await preview(page).locator('[data-upgrade-save-failed]').count() === 1
|
||||
&& await preview(page).locator('[data-upgrade-save-failed]').getByRole('button', { name: '重试保存' }).count() === 1
|
||||
&& await preview(page).locator('[data-upgrade-save-failed] [data-upgrade-discard]').count() === 1
|
||||
&& (await dock(page).innerText()).includes('分析失败'))
|
||||
// 滚到底:「没存上」那一行和下面的「分析失败 · 重试」一屏看全
|
||||
await dock(page).locator('[data-analysis-failed]').scrollIntoViewIfNeeded().catch(() => {})
|
||||
await shoot(page, 'p5fix-offline', page.locator('section[aria-label="问题"]'))
|
||||
// 后端回来了:重试保存照常存上
|
||||
state.save = 'ok'
|
||||
state.validate = 'ok'
|
||||
await preview(page).getByRole('button', { name: '重试保存' }).click({ timeout: 2000 }).catch(() => {})
|
||||
await waitFor(page, async () => state.patches.length === 2 && await preview(page).count() === 0)
|
||||
check('后端回来之后「重试保存」存上,预览收起', state.patches.length === 2 && await preview(page).count() === 0
|
||||
&& state.patches[1]?.note === '升级为可追溯结构', `patches=${state.patches.length} ${JSON.stringify(state.patches[1]?.note ?? null)}`)
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('升级:?upgrade=1 直达:打开问题面板、开始预览,地址摘掉参数', async () => {
|
||||
const { page, state, errors } = await open({ id: 'pf-legacy', path: '/studio/pf-legacy?upgrade=1' })
|
||||
await page.locator('[data-upgrade-preview="ready"]').waitFor({ timeout: 8000 }).catch(() => {})
|
||||
await page.waitForTimeout(800)
|
||||
check('一打开就是问题面板,升级预览摆着', await dock(page).count() === 1 && await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1)
|
||||
check('只请求了一次升级接口', state.upgrades.length === 1, String(state.upgrades.length))
|
||||
check('地址里的 upgrade 摘掉了(刷新、后退不会再要一次)', !new URL(page.url()).searchParams.has('upgrade') && new URL(page.url()).pathname === '/studio/pf-legacy',
|
||||
page.url())
|
||||
check('没有自动保存', state.patches.length === 0)
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('360px 下不横向滚动', async () => {
|
||||
const { page } = await open({ width: 360, height: 780 })
|
||||
// 窄屏上「发布」收在「更多操作」里
|
||||
|
||||
+109
-1
@@ -1994,10 +1994,118 @@ await section('证据页签:没有报告的运行照实说明(none / 旧运
|
||||
fakes.delete(`GET /api/artifacts/${fxe.doc_artifact}`)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------ 没有逐段证据 → 升级这张图(可点击证据五期)
|
||||
|
||||
await section('证据页签:没有逐段证据的运行给升级横幅,跳到编排页打开升级预览', async () => {
|
||||
const UP = 'fake0evupgrade000000000000000'
|
||||
const UP_OLD = 'fake0evupgradeold00000000000'
|
||||
const UP_LEGACY = 'fake0evupgradelegacy00000000'
|
||||
const UP_NOWF = 'fake0evupgradenowf0000000000'
|
||||
const node = (id, type, x, label, config = {}) => ({ id, type, position: { x, y: 120 }, data: { label, config } })
|
||||
const wfOf = (id, name, graph) => ({ id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 5, is_template: false,
|
||||
status: 'published', published_version: 5, published_by: null, run_count: 1,
|
||||
created_at: '2026-09-20T02:00:00Z', updated_at: '2026-09-26T02:00:00Z' })
|
||||
// 旧结构:模型调用写的文字进了成果(可以升级);另一张已经是报告撰写(校验不给建议)
|
||||
const legacyGraph = { nodes: [node('in', 'input', 0, '入口'), node('fetch', 'agent', 240, '取数', { tools: ['db_query__shop'] }),
|
||||
node('story', 'llm', 480, '写周报', { prompt: '写周报' }), node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ nodes.story.text }}' }] })],
|
||||
edges: [{ id: 'e1', source: 'in', target: 'fetch' }, { id: 'e2', source: 'fetch', target: 'story' }, { id: 'e3', source: 'story', target: 'done' }] }
|
||||
const cleanGraph = { ...legacyGraph, nodes: legacyGraph.nodes.map((n) => (n.id === 'story' ? { ...n, type: 'report', data: { label: '写周报', config: { instructions: '写周报' } } } : n)) }
|
||||
const WF = wfOf('fake-wf-upgrade', '升级检查', legacyGraph)
|
||||
const WF_CLEAN = wfOf('fake-wf-upgraded', '升级检查(已升级)', cleanGraph)
|
||||
const real = await getJson('/workflows').catch(() => [])
|
||||
fakes.set('GET /api/workflows', () => ({ status: 200, json: [...real, WF, WF_CLEAN] }))
|
||||
fakes.set(`GET /api/workflows/${WF.id}`, () => ({ status: 200, json: WF }))
|
||||
fakes.set('GET /api/conversations', () => ({ status: 200, json: [{ id: 'fake-conv-upgrade', title: '', kind: 'canvas', archived: false, turn_count: 0 }] }))
|
||||
const validated = []
|
||||
fakes.set('POST /api/workflows/validate', (url, r) => {
|
||||
const graph = r.postDataJSON()?.graph ?? { nodes: [] }
|
||||
validated.push(graph.nodes.map((n) => n.id).join(','))
|
||||
const old = graph.nodes.some((n) => n.type === 'llm')
|
||||
return { status: 200, json: { ok: true, issues: old ? [{ level: 'info', code: 'evidence.upgrade_available', node_id: null, field: null, message: '可以升级为可追溯结构' }] : [] } }
|
||||
})
|
||||
fakes.set('POST /api/workflows/variables', () => ({ status: 200, json: { variables: [], issues: [] } }))
|
||||
const upgrades = []
|
||||
fakes.set('POST /api/copilot/upgrade-evidence', (url, r) => {
|
||||
const body = r.postDataJSON()
|
||||
upgrades.push(body)
|
||||
const graph = JSON.parse(JSON.stringify(body.graph))
|
||||
const story = graph.nodes.find((n) => n.id === 'story')
|
||||
if (story) { story.type = 'report'; story.data.config = { instructions: '写周报' } }
|
||||
return { status: 200, json: { graph, ops: [], notes: [], issues: [], applied: ['R2:story'], rejected: [], assist: null, ok: true,
|
||||
changes: [{ fix_id: 'R2:story', rule: 'R2', label: '把「写周报」换成报告撰写', node_id: 'story', node_title: '写周报', field: 'type', before: 'llm', after: 'report' }] } }
|
||||
})
|
||||
const none = { schema: 'agentlab.evidence/1', mode: 'none', note: '', seal: { sealed: true, ok: true }, reports: [], evidence: [], edges: [] }
|
||||
const noneAudit = { schema: 'agentlab.evidence.audit/1', mode: 'none', seal: { sealed: true, ok: true }, reports: [], groups: [], counts: {}, total: 0 }
|
||||
const wire = (id, wf, graph) => {
|
||||
evFakes(id, { output: { report: '本周销售额 45678' }, report: false, graph, audit: { ...noneAudit, mode: graph.mode } })
|
||||
fakes.set(`GET /api/runs/${id}`, () => ({ status: 200, json: { ...evRun(id, { report: '本周销售额 45678' }), workflow_id: wf.id, workflow_name: wf.name } }))
|
||||
}
|
||||
wire(UP, WF, none)
|
||||
wire(UP_OLD, WF_CLEAN, none)
|
||||
wire(UP_LEGACY, WF, { ...none, mode: 'legacy_text', legacy: { note: '猜测', fields: [] } })
|
||||
evFakes(UP_NOWF, { output: { report: '本周销售额 45678' }, report: false, graph: none, audit: noneAudit })
|
||||
const banner = () => page.locator('[data-view-pane=evidence] [data-upgrade-banner]')
|
||||
|
||||
await openEvidence(UP)
|
||||
await banner().waitFor({ timeout: 5000 }).catch(() => {})
|
||||
const text = await banner().innerText().catch(() => '')
|
||||
check('没有逐段证据(none)、图能升级:横幅「这次的报告没有逐段证据 → 升级这张图」', text.includes('这次的报告没有逐段证据')
|
||||
&& (await banner().locator('[data-upgrade-link]').innerText().catch(() => '')).trim() === '升级这张图', text)
|
||||
check('……拿工作流现在的图去校验、按那条建议认', validated.includes('in,fetch,story,done'), validated.join(' | '))
|
||||
check('……正式运行:说清升级之后要重新发布', text.includes('要重新发布'), text)
|
||||
const href = await banner().locator('[data-upgrade-link]').getAttribute('href').catch(() => '')
|
||||
check('跳转地址:编排页,带 upgrade=1', href === `/studio/${WF.id}?upgrade=1`, href)
|
||||
if (SHOTS) {
|
||||
for (const theme of ['dark', 'light']) {
|
||||
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
|
||||
await page.waitForTimeout(200)
|
||||
await page.screenshot({ path: `${SHOTS}/runs-upgrade-banner-${theme}.png` })
|
||||
}
|
||||
await page.evaluate(() => document.documentElement.removeAttribute('data-theme'))
|
||||
}
|
||||
// 画布正锁着这张工作流(正式运行在跑):不给入口,说清为什么
|
||||
await page.evaluate((wf) => {
|
||||
const s = window.__studio.getState()
|
||||
window.__studio.setState({ workflow: wf, runPhase: 'running', trace: { ...s.trace, runClass: 'formal' } })
|
||||
}, WF)
|
||||
await page.waitForTimeout(200)
|
||||
check('画布锁着这张工作流:横幅不给跳转,说清为什么', await banner().locator('[data-upgrade-link]').count() === 0
|
||||
&& (await banner().locator('[data-upgrade-banner-locked]').innerText().catch(() => '')).includes('正式运行进行中'))
|
||||
|
||||
await openEvidence(UP_LEGACY)
|
||||
await banner().waitFor({ timeout: 5000 }).catch(() => {})
|
||||
check('旧运行猜测(legacy_*)也给横幅', await banner().count() === 1)
|
||||
|
||||
const before = validated.length
|
||||
await openEvidence(UP_OLD)
|
||||
await page.waitForTimeout(500)
|
||||
check('图已经是可追溯结构(校验不给建议,老后端同样不给):没有横幅', await banner().count() === 0 && validated.length === before + 1)
|
||||
|
||||
await openEvidence(UP_NOWF)
|
||||
await page.waitForTimeout(400)
|
||||
check('没有工作流的运行(未保存的图):没有横幅,也不去校验', await banner().count() === 0 && validated.length === before + 1)
|
||||
|
||||
await openEvidence(UP)
|
||||
await banner().locator('[data-upgrade-link]').click()
|
||||
await page.waitForURL((u) => u.pathname === `/studio/${WF.id}`, { timeout: 8000 }).catch(() => {})
|
||||
await page.locator('#dock-problems [data-upgrade-preview="ready"]').waitFor({ timeout: 8000 }).catch(() => {})
|
||||
check('跳到编排页:打开问题面板、升级预览摆着', await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1
|
||||
&& (await page.locator('#dock-problems [data-upgrade-change="type"]').innerText().catch(() => '')).includes('报告撰写'))
|
||||
check('……请求的是这张工作流的图,地址摘掉了 upgrade', upgrades.length === 1 && upgrades[0]?.graph?.nodes?.length === 4
|
||||
&& new URL(page.url()).search === '', `${upgrades.length} 次 · ${page.url()}`)
|
||||
check('……只出预览,没保存', !writes.some((w) => w.startsWith('PATCH ')))
|
||||
|
||||
for (const k of [...fakes.keys()]) {
|
||||
if ([UP, UP_OLD, UP_LEGACY, UP_NOWF].some((id) => k.includes(id)) || k.includes(WF.id)) fakes.delete(k)
|
||||
}
|
||||
for (const k of ['GET /api/workflows', 'GET /api/conversations', 'POST /api/workflows/validate', 'POST /api/workflows/variables',
|
||||
'POST /api/copilot/upgrade-evidence']) fakes.delete(k)
|
||||
})
|
||||
|
||||
await section('收尾', async () => {
|
||||
// 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 POST
|
||||
// (同样被探针拦下,不落库)。赶上它们发出来之前就离开了画布的话就没有
|
||||
check('只有伪造过的写请求', writes.every((w) => /\/continue$|\/cancel$|\/copilot\/from-run$|DELETE \/api\/runs\/fake0|^POST \/api\/runs$|^POST \/api\/workflows\/(validate|variables)$/.test(w)),
|
||||
check('只有伪造过的写请求', writes.every((w) => /\/continue$|\/cancel$|\/copilot\/from-run$|DELETE \/api\/runs\/fake0|^POST \/api\/runs$|^POST \/api\/workflows\/(validate|variables)$|^POST \/api\/copilot\/upgrade-evidence$/.test(w)),
|
||||
writes.join(', '))
|
||||
check('没有未捕获的运行时错误', errors.length === 0, errors[0] ?? '')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user