可点击证据第二期:取数链路打通、问数据的答案逐段可点;发布前自动修复

一、可点击证据第二期

取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 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);关键行为做了变异验证、全部抓到。
This commit is contained in:
ThearchyHelios
2026-09-28 21:20:04 +08:00
parent 62aac187d8
commit fcdcc30d4e
74 changed files with 19372 additions and 380 deletions
+3 -2
View File
@@ -44,6 +44,7 @@ const ORDER = [
'run-states', // 节点卡的运行态
'canvas-fx', // 画布表层的运行态
'studio', // 编排页的编辑
'publish', // 发布前检查与自动修复:发布弹窗、问题面板
'chat', // 问数据
'runs', // 记录
'manage', // 数据、工具、知识、设置
@@ -53,7 +54,7 @@ const ORDER = [
// 新加的检查不在表里就按 60 秒算
const ESTIMATE = {
'run-states': 220, 'canvas-fx': 181, stream: 162, studio: 125, chat: 124, manage: 115, runs: 91,
shell: 84, 'ui-kit': 69, ui: 61, evidence: 21, guards: 10, 'canvas-layout': 4, tokens: 1, trace: 1, decode: 1,
shell: 84, 'ui-kit': 69, ui: 61, evidence: 56, publish: 31, guards: 10, 'canvas-layout': 4, tokens: 1, trace: 1, decode: 1,
}
// 量时间最多、以前并行最容易挂的:同一时刻最多跑其中一项
const EXCLUSIVE = new Set(['run-states', 'canvas-fx'])
@@ -61,7 +62,7 @@ const TIMEOUT_MS = Number(process.env.CHECK_TIMEOUT_MS ?? 15 * 60_000)
// 各检查只跑其中几段、只跑一套主题的开关。壳里留着一个 ONLY=… 就会让整套检查悄悄只跑一小截,
// 却照样报「全部通过」,所以一律不传给子进程
const FILTERS = ['CHECK_ONLY', 'ONLY', 'THEMES', 'FX_ONLY', 'STUDIO_ONLY', 'RUN_STATES_ONLY', 'UI_KIT_ONLY', 'CHECK_THEME',
'EVIDENCE_ONLY']
'EVIDENCE_ONLY', 'PUBLISH_ONLY']
// 新加的 check-*.mjs 忘了写进 ORDER 就永远不会跑:列出来提醒
const unlisted = readdirSync(HERE)
.map((f) => f.match(/^check-(.+)\.mjs$/)?.[1])
+248 -4
View File
@@ -107,6 +107,20 @@ const GRAPH = {
edges: [{ source: 'in', target: 'ag' }, { source: 'ag', target: 'out' }],
}
/**
* 问数据的答案(可点击证据第二期):input → agent → report → output,报告直接引用 agent 查过的
* 单元格、整表。文档是 compose_doc 真跑出来的,片段接口的答复带查询步骤(窗口、高亮、遮罩)
*/
const EVQ = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-query.json', import.meta.url), 'utf8'))
const EVQ_DOC = { ...EVQ.doc, run_id: 'run-evq' }
/** 整形节点按 JSON 解析失败的两种原话(backend/app/engine/nodes/io.py 的 _json_error) */
const JSON_TEMPLATE_ERROR = '模板渲染出来的不是合法 JSON(第 1 行第 934 列附近)。检查模板里的引号、逗号,字符串值要用 | json 过滤器输出'
const JSON_UPSTREAM_ERROR = '上游「查数」输出的不是合法 JSON(第 934 列附近),常见原因是字符串里有没转义的英文引号;'
+ '让模型交结构化数据请用 output_schema + cite_fields,别用整形节点解析它写的文字'
/** 同一句上游说法,节点名里带着别的规则的关键词(超时):不能被当成超时、又把「接着跑」给回来 */
const JSON_SLOW_ERROR = JSON_UPSTREAM_ERROR.replace('上游「查数」', '上游「查询超时订单」')
const EVQ_SEG = (text, nth = 0) => Object.values(EVQ.segments).filter((d) => d.segment.text === text)[nth]?.segment.id
const CONVS = {
busyA: conv('c0busya', '会话 A:跑得很久的那个', 0, TODAY_RECENT),
idleB: conv('c0idleb', '会话 B:已经答完的那个', 1, 3),
@@ -165,7 +179,15 @@ const GUARD_CONVS = {
steps: conv('c0gsteps', '护栏:写死 8 步、用满了', 1, 60 * 24 * 31),
dflt: conv('c0gdflt', '护栏:跟随默认步数', 1, 60 * 24 * 32),
}
/** 只在「问数据的证据」那段出现:库里存着一轮带证据的答案,刷新后回运行取 _evidence */
const ASK_CONVS = {
askZ: conv('c0askz', '问数据:各区域销售额和最大的一单', 1, 60 * 24 * 33),
}
const DETAIL = {
c0askz: [turn('tz9', '上周各区域的销售额,最大的一单是多少', {
answer: EVQ.output.answer, graph: GRAPH, run_id: 'run-evq',
meta: { v: 1, runId: 'run-evq', runClass: 'exploratory', runStatus: 'succeeded', evidence: true },
})],
c0gstall: [guardTurn('tg1', GRAPH8, 'stall', 'agent 连续 3 步没有拿到新信息,收尾轮也没有给出结论。')],
c0gsteps: [guardTurn('tg2', GRAPH8, 'steps', 'agent 用满了 8 步。收尾轮也没有给出结论。')],
c0gdflt: [guardTurn('tg3', GRAPH, null, 'agent 用满了 12 步还没给出结论。')],
@@ -299,6 +321,7 @@ const DETAIL = {
const EVIDENCE = JSON.parse(readFileSync(new URL('../frontend/src/run/__tests__/evidence-doc.json', import.meta.url), 'utf8'))
const RUNS = {
'run-evid': { status: 'succeeded', output: EVIDENCE.output, error: null },
'run-evq': { status: 'succeeded', output: EVQ.output, error: null },
'run-cancelled': { status: 'cancelled', output: {}, error: null },
'run-failed': { status: 'failed', output: {}, error: '查询超时' },
'run-rejected': { status: 'failed', output: {}, error: '人工驳回:数字对不上' },
@@ -337,6 +360,8 @@ const ctl = {
trashMore: false,
/** 列表里多摆「护栏」那段的三个会话(GUARD_CONVS) */
guardConvs: false,
/** 列表里多摆「问数据的证据」那段的会话(ASK_CONVS) */
askConvs: false,
/** 数据源列表取不回来 */
sourcesFail: false,
/** 数据源列表里多一个别处刚加的库 */
@@ -350,7 +375,7 @@ const resetDb = () => {
}
resetDb()
const allConvs = () => [...Object.values(CONVS), ...Object.values(TRASHED), ...(ctl.trashMore ? Object.values(TRASH_MORE) : []),
...(ctl.guardConvs ? Object.values(GUARD_CONVS) : [])]
...(ctl.guardConvs ? Object.values(GUARD_CONVS) : []), ...(ctl.askConvs ? Object.values(ASK_CONVS) : [])]
.filter((c) => !db.purged.has(c.id))
.map((c) => ({ ...c, archived: db.archived.has(c.id) }))
let runSeq = 0
@@ -435,9 +460,9 @@ async function fakeApi(route) {
const wait = ctl.reviewDelay[body().run_id]
if (wait) await new Promise((r) => setTimeout(r, wait))
// 老后端的复核不认 _evidence,照样回一次改写
if (body().run_id === 'run-evid') {
if (body().run_id === 'run-evid' || body().run_id === 'run-evq') {
return json({ verdict: 'rewritten', note: '检索降级过,结论请对照原始数据', answer: '改写后的答案:销售额大约四万多',
original: EVIDENCE.output['周报'], retry: false, severity: 'degraded',
original: body().run_id === 'run-evq' ? EVQ.output.answer : EVIDENCE.output['周报'], retry: false, severity: 'degraded',
signals: [{ kind: 'retrieval_degraded', detail: '检索退回关键词', severity: 'degraded' }] })
}
return json({ verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [] })
@@ -447,7 +472,16 @@ async function fakeApi(route) {
const conversation = String(b.input?.question ?? '')
// 发起请求根本没到后端(网断了、后端正在重启)
if (conversation.includes('启动失败') && ctl.launchFail) return route.abort('connectionrefused')
const id = conversation.includes('带证据') ? 'run-evid'
// 发起就被拒:绑定的工具在本机不存在(后端 runs.TOOL_MISSING,{detail, code} 的形状)
if (conversation.includes('工具不在')) {
return json({ detail: '绑定的工具在本机不存在:「查数」(调用工具)绑的 db_query__nope。去数据页接入,或在节点里重新选',
code: 'run_tool_missing' }, 422)
}
const id = conversation.includes('整形解析失败') ? (conversation.includes('节点名带超时') ? 'run-jsonslow'
: conversation.includes('上游') ? 'run-jsonup'
: conversation.includes('改写') ? 'run-jsonauth' : 'run-jsonerr')
: conversation.includes('问数据带证据') ? 'run-evq'
: conversation.includes('带证据') ? 'run-evid'
: conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long')
: conversation.includes('审批') ? 'run-wait'
: conversation.includes('重启') ? 'run-restart'
@@ -513,6 +547,48 @@ function script(runId, after = 0) {
end: 'succeeded',
}
}
if (runId === 'run-jsonerr' || runId === 'run-jsonup' || runId === 'run-jsonauth' || runId === 'run-jsonslow') {
// 用户真实踩到的:整形节点按 JSON 解析上游的文字失败。旧说法(让人查模板)和新说法(点名上游)各一种;
// 对照:说法被改写过的(鉴权失败)原话还得留着
const error = runId === 'run-jsonerr' ? JSON_TEMPLATE_ERROR : runId === 'run-jsonup' ? JSON_UPSTREAM_ERROR
: runId === 'run-jsonslow' ? JSON_SLOW_ERROR
: 'AuthenticationError: Error code: 401 - invalid x-api-key'
const detail = "JSONDecodeError: Expecting ',' delimiter: line 1 column 934 (char 933)\n出错位置前后的原文:…\"gmv\": 45678.5, \"note\": \"含\"⟨此处⟩促销\"…"
return {
events: [
ev(1, 'run.started', null, { nodes: 3 }),
ev(2, 'node.started', 'ag', { node_type: 'agent', label: runId === 'run-jsonslow' ? '查询超时订单' : '查数' }),
ev(3, 'node.finished', 'ag', { duration_ms: 900 }),
ev(4, 'node.started', 'tf', { node_type: 'transform', label: '解析取数结果为变量' }),
ev(5, 'node.failed', 'tf', { error, detail, duration_ms: 3 }),
ev(6, 'run.failed', null, { error, detail }),
],
end: 'failed',
}
}
if (runId === 'run-evq') {
// 问数据:agent 查库(新运行的 tool.end 多一个 query_artifact)→ 按出处抽取字段 → 报告核对 → 成果
return {
events: [
ev(1, 'run.started', null, { nodes: 4 }),
ev(2, 'node.started', 'ag', { node_type: 'agent', label: '查数' }),
ev(3, 'llm.start', 'ag', { model: 'm' }),
ev(4, 'tool.start', 'ag', { tool: 'db_query__shop', call_id: 'q1', args: { sql: 'SELECT region, SUM(amount) AS amount FROM orders GROUP BY region' } }),
ev(5, 'tool.end', 'ag', { tool: 'db_query__shop', call_id: 'q1', duration_ms: 30, artifact: 'tool-snap', query_artifact: 'query-snap', rows: 4 }),
ev(6, 'llm.end', 'ag', { model: 'm', duration_ms: 800 }),
ev(7, 'llm.start', 'ag', { model: 'm', structured: true, purpose: 'cite_fields', message_count: 6 }),
ev(8, 'llm.end', 'ag', { agent: '查数', model: 'm', purpose: 'cite_fields', duration_ms: 900 }),
ev(9, 'log', 'ag', { level: 'warn', code: 'agent_field_mismatch', fields: ['order_cnt'],
message: '有 1 个字段和查询快照对不上,已按快照取值:order_cnt 模型报 1240,快照是 1234' }),
ev(10, 'node.finished', 'ag', { duration_ms: 2100 }),
ev(11, 'node.started', 'write', { node_type: 'report', label: '写答案' }),
ev(12, 'report.checked', 'write', EVQ.report_checked),
ev(13, 'node.finished', 'write', { duration_ms: 900 }),
ev(14, 'run.finished', null, { output: EVQ.output }),
],
end: 'succeeded',
}
}
if (runId === 'run-evid') {
return {
events: [
@@ -955,6 +1031,149 @@ for (const theme of THEMES) {
await ctx.close()
})
await section('askdata', '问数据的答案带逐段证据:实时、刷新后补回、复核守卫、窄屏抽屉(可点击证据第二期)', async () => {
const routeEvidence = async (page) => {
// 文档记着的运行要和成果的运行对得上(run-evq),片段按运行认
await page.route(/\/api\/artifacts\//, (route) => route.fulfill({ json: { id: EVQ.doc_artifact, content: EVQ_DOC } }))
await page.route(/\/api\/runs\/run-evq\/evidence(\/.*)?(\?.*)?$/, (route) => {
const m = new URL(route.request().url()).pathname.match(/\/segments\/([^/]+)$/)
if (!m) return route.fulfill({ json: EVQ.graph })
const body = EVQ.segments[decodeURIComponent(m[1])]
return body ? route.fulfill({ json: body }) : route.fulfill({ status: 404, json: { detail: '没有这个片段', code: 'evidence_segment_not_found' } })
})
}
{
const { page, ctx, errors } = await open(theme)
await routeEvidence(page)
const patchesBefore = log.patches.length
await goto(page, 'c0trunc')
await send(page, '问数据带证据:各区域销售额,最大的一单是多少')
check('答完了', await page.waitForFunction(
() => window.__chat.getState().byConversation.c0trunc?.at(-1)?.phase === 'done', null, { timeout: 10000 })
.then(() => true, () => false))
const t = (await chatState(page)).byConversation.c0trunc.at(-1)
check('复核的改写没落到答案上:成果还是报告原文和 _evidence', t.output?.answer === EVQ.output.answer && !!t.output?._evidence
&& t.review?.answer === null, JSON.stringify({ keys: Object.keys(t.output ?? {}), review: t.review?.verdict }))
await page.waitForSelector('[data-evidence-doc] [data-seg]', { timeout: 6000 }).catch(() => {})
const turn = page.locator('[data-turn]').last()
check('答案是逐段可点的报告', await turn.locator('[data-evidence-doc] [data-seg]').count() > 10)
check('没有出具契约:文档自己给计数条', (await turn.locator('[data-evidence-tally]').innerText().catch(() => '')).includes('数字有出处'))
const text = await turn.innerText()
check('执行过程里有「按出处抽取字段」和字段对不上的提醒', text.includes('按出处抽取字段') && text.includes('有 1 个字段和查询快照对不上'),
text.replace(/\s+/g, ' ').slice(0, 200))
check('整表的每格都是可点的片段', await turn.locator('[data-evidence-doc] table td [data-seg]').count() === 9)
await turn.locator(`[data-seg="${EVQ_SEG('1,288')}"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 5000 }).catch(() => {})
const panel = page.locator('[data-evidence-panel]')
check('宽屏:点开数字从侧边弹出,看得到查询步骤', await panel.getAttribute('data-evidence-panel') === 'side'
&& await panel.locator('[data-ev-query]').count() === 1)
check('……被引用的格高亮,SQL 在,遮罩说明在', (await panel.locator('td[data-highlight="cell"]').innerText().catch(() => '')) === '1288'
&& (await panel.locator('[data-ev-sql]').innerText().catch(() => '')).includes('FROM orders')
&& (await panel.locator('[data-ev-mask-note]').innerText().catch(() => '')).includes('不是安全边界'))
await page.waitForTimeout(300)
await page.screenshot({ path: `${SHOTS}/askdata-evidence-${theme}.png` })
await page.keyboard.press('Escape')
await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta))
const saved = log.patches.slice(patchesBefore)
check('落库的是报告原文,meta 记下这一轮有证据', saved.filter((p) => typeof p.body.answer === 'string' && p.body.answer)
.every((p) => p.body.answer === EVQ.output.answer) && saved.map((p) => p.body.meta).filter(Boolean).at(-1)?.evidence === true)
check('没有运行时报错(实时)', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
resetDb()
ctl.askConvs = true
try {
const { page, ctx, errors } = await open(theme)
await routeEvidence(page)
const gets = log.runGets.length
await goto(page, 'c0askz')
await page.waitForSelector('[data-evidence-doc] [data-seg]', { timeout: 8000 }).catch(() => {})
check('刷新后补回:库里只有文字,回运行取回 _evidence,答案照样逐段可点',
log.runGets.slice(gets).includes('run-evq') && await page.locator('[data-evidence-doc] [data-seg]').count() > 10)
await page.locator(`[data-evidence-doc] [data-seg="${EVQ_SEG('8.7%')}"]`).click()
await page.waitForSelector('[data-evidence-panel] [data-ev-sources]', { timeout: 5000 }).catch(() => {})
const panel = page.locator('[data-evidence-panel]')
check('……点开指标:口径卡来源、输入来源、两次查询都在', (await panel.locator('[data-ev-caliber-from]').innerText().catch(() => '')).includes('销售周报')
&& await panel.locator('[data-ev-source]').count() === 2 && await panel.locator('[data-ev-query]').count() === 2)
check('没有运行时报错(刷新后)', errors.length === 0, errors[0] ?? '')
await ctx.close()
const narrow = await open(theme, { width: 800, height: 900 })
await routeEvidence(narrow.page)
await goto(narrow.page, 'c0askz')
await narrow.page.waitForSelector('[data-evidence-doc] [data-seg]', { timeout: 8000 }).catch(() => {})
await narrow.page.locator(`[data-evidence-doc] [data-seg="${EVQ_SEG('1,288')}"]`).click()
await narrow.page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 5000 }).catch(() => {})
const drawer = narrow.page.locator('[data-evidence-panel]')
const sc = await narrow.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth }))
check('窄屏:面板从底部抽出,里面是查询步骤', await drawer.getAttribute('data-evidence-panel') === 'drawer'
&& await drawer.locator('[data-ev-query]').count() === 1)
check('……整页没有横向滚动', sc.sw <= sc.vw, JSON.stringify(sc))
await narrow.page.waitForTimeout(300)
await narrow.page.screenshot({ path: `${SHOTS}/askdata-drawer-${theme}.png` })
await narrow.page.keyboard.press('Escape')
await narrow.page.waitForTimeout(200)
check('……Esc 收起抽屉', await narrow.page.locator('[data-evidence-panel]').count() === 0)
check('没有运行时报错(窄屏)', narrow.errors.length === 0, narrow.errors[0] ?? '')
await narrow.ctx.close()
} finally {
ctl.askConvs = false
}
})
await section('nodeerror', '节点报错说一遍:原因、怎么办不在展开区里再贴一遍原话(整形节点解析 JSON 失败)', async () => {
for (const [ask, said] of [['整形解析失败:旧说法', JSON_TEMPLATE_ERROR], ['整形解析失败:上游说法', JSON_UPSTREAM_ERROR],
['整形解析失败:节点名带超时', JSON_SLOW_ERROR]]) {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0trunc')
await send(page, ask)
await page.waitForFunction(() => window.__chat.getState().byConversation.c0trunc?.at(-1)?.phase === 'error', null, { timeout: 10000 })
.catch(() => {})
const row = page.locator('[data-turn]').last().locator('[data-node-id="tf"][data-step-status="failed"]').first()
await row.locator('button[aria-expanded]').first().click()
await page.waitForTimeout(250)
const shown = await row.innerText()
const head = said.split(/[。;,]/)[0]
const times = shown.split(head).length - 1
check(`「${ask}」:节点那一行把报错说一遍,展开后不再整段贴一遍原话`, times === 1, `出现 ${times} 次:${shown.replace(/\s+/g, ' ').slice(0, 240)}`)
const tail = said.split(/[;]|。/).at(-1).slice(0, 12)
check(`「${ask}」:怎么办那半句照样在`, shown.includes(tail), tail)
const resume = await page.getByRole('button', { name: /^接着跑/ }).count()
const canvas = await page.getByRole('button', { name: '在画布里打开' }).count() + await page.locator('[data-fix="canvas"]').count()
check(`「${ask}」:原样接着跑只会再失败一次,不给「接着跑」,给去画布改的入口`, resume === 0 && canvas > 0,
`接着跑 ${resume} 个 · 画布入口 ${canvas} 个`)
await row.locator('summary', { hasText: '技术细节' }).click().catch(() => {})
check(`「${ask}」:原始异常和出错位置前后的原文收在技术细节里`, (await row.innerText()).includes('JSONDecodeError')
&& (await row.innerText()).includes('出错位置前后的原文'))
if (said === JSON_UPSTREAM_ERROR) await page.screenshot({ path: `${SHOTS}/nodeerror-json-${theme}.png` })
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
const { page, ctx } = await open(theme)
await goto(page, 'c0trunc')
// 节点名是用户起的,里面带着超时、额度、连不上这些别的规则的关键词:照样认作 JSON 解析失败
const named = await page.evaluate(async (said) => {
const { explainRunError } = await import('/src/lib/explain.ts')
return ['查询超时订单', '额度查询', '连不上的库', '服务器 500 统计'].map((t) => {
const x = explainRunError(said.replace('上游「查数」', `上游「${t}」`))
return { t, title: x.title, continuable: x.continuable, fix: x.fix }
})
}, JSON_UPSTREAM_ERROR)
check('节点名里带超时 / 额度 / 连不上 / 500:照样认作「上游输出的不是合法 JSON」,不给接着跑',
named.every((x) => x.title.startsWith(`上游「${x.t}」输出的不是合法 JSON`) && x.continuable === false && x.fix === 'canvas'),
JSON.stringify(named.filter((x) => x.continuable !== false)))
await send(page, '整形解析失败:改写过的说法')
await page.waitForFunction(() => window.__chat.getState().byConversation.c0trunc?.at(-1)?.phase === 'error', null, { timeout: 10000 })
.catch(() => {})
const row = page.locator('[data-turn]').last().locator('[data-node-id="tf"][data-step-status="failed"]').first()
await row.locator('button[aria-expanded]').first().click()
await page.waitForTimeout(250)
const t = await row.innerText()
check('对照:说法改写过(鉴权没通过)时,原话还在展开区里', t.includes('模型鉴权没通过') && t.includes('invalid x-api-key'),
t.replace(/\s+/g, ' ').slice(0, 200))
await ctx.close()
})
await section('restart', '服务重启:interrupted 且没有待审批,收尾而不是转圈', async () => {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0resti')
@@ -1596,6 +1815,31 @@ for (const theme of THEMES) {
await ctx.close()
})
await section('toolmissing', '发起就被拒(绑定的工具在本机不存在):报错里给直达入口,不给接着跑', async () => {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0launu')
await send(page, '一个工具不在的问题')
const alert = page.locator('[data-turn] [data-turn-error]').first()
await alert.waitFor({ timeout: 8000 }).catch(() => {})
const text = await alert.innerText().catch(() => '')
check('标题点名那个工具', text.includes('绑定的工具「db_query__nope」在本机不存在'), text.replace(/\n/g, ' / '))
check('说清这次运行没有发起、怎么办', text.includes('没有发起') && text.includes('去数据页接入'), text.replace(/\n/g, ' / '))
const fix = alert.locator('a[data-fix="data"]')
check('报错里有直达入口「去数据页接入」(和画布上发起被拒同一套)', await fix.count() === 1
&& (await fix.innerText().catch(() => '')).includes('去数据页接入'))
check('不给「接着跑」(原样接着跑还是缺这个工具)', await page.getByRole('button', { name: '接着跑' }).count() === 0)
// 这时还没有运行 id,流里的报错块不会再拿原话认一遍:入口得在 store 里就记进这一轮的 failure,
// 刷新之后(meta 里的 failure)照样有
const stored = await page.evaluate(() => (window.__chat.getState().byConversation.c0launu ?? []).at(-1)?.failure ?? null)
check('入口记进这一轮的 failure(fix / fixTo,不可接着跑)', stored?.fix === 'tools' && stored?.fixTo === '/data'
&& stored?.continuable === false, JSON.stringify(stored))
await fix.click().catch(() => {})
await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 }).catch(() => {})
check('点了去数据页', new URL(page.url()).pathname.startsWith('/data'), page.url())
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
})
await section('guard', '放宽步数只在对症时给:停滞、预算收的尾不给,跟随默认 100 步的也不给', async () => {
resetDb()
ctl.guardConvs = true
+81
View File
@@ -1232,6 +1232,87 @@ await section('工具门控 tool.gated(工具信任三档)', async () => {
check('……不是「一条还没翻译的记录」', !bare.some((s) => s.title === '一条还没翻译的记录'))
})
await section('agent 字段按出处核对(可点击证据第二期):抽取调用单独成行、两个新警告码说人话', async () => {
const E = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1790000000 + seq, data })
// 两条警告的原话:和 backend/app/engine/nodes/llm.py 的 _field_warnings 同一个格式。先核对后端源码还是这么说的
const MISMATCH = '有 1 个字段和查询快照对不上,已按快照取值:order_cnt 模型报 1240,快照是 1234'
const UNVERIFIED = '有 2 个字段核对不了出处,记为空值(没有兜底成 0):new_users(Q1 没有列「new_users」);refund_cnt(快照取不回来)'
const FAILED = '结构化抽取没跑成:模型调用超时。3 个字段都记为空值(没有兜底成 0)'
// 抽取调用成了、但交回来的不是对象:同一句式,前面没有「结构化抽取没跑成:」
const NOT_OBJECT = '抽取结果不是一个对象。2 个字段都记为空值(没有兜底成 0)'
const llmPy = readFileSync(`${root}backend/app/engine/nodes/llm.py`, 'utf8')
check('后端发字段核对警告的格式还是夹具里这一种', llmPy.includes('"agent_field_mismatch"')
&& llmPy.includes('个字段和查询快照对不上,已按快照取值:{detail}') && llmPy.includes('模型报 {_shown(e.get(\'model_value\'))},快照是 ')
&& llmPy.includes('个字段核对不了出处,记为空值(没有兜底成 0):{detail}') && llmPy.includes('个字段都记为空值(没有兜底成 0)')
&& llmPy.includes('purpose="cite_fields"') && llmPy.includes('"抽取结果不是一个对象"') && llmPy.includes('结构化抽取没跑成:{'),
'改了 llm.py 的说法就同步改 decode.ts 的 explainLog 和这里的夹具')
const events = [
E(1, 'run.started', null, { total: 2 }),
E(2, 'node.started', 'fetch', { node_type: 'agent', label: '查数' }),
E(3, 'llm.start', 'fetch', { model: 'm1' }),
E(4, 'tool.start', 'fetch', { tool: 'db_query__shop', call_id: 'c1', args: { sql: 'SELECT SUM(amount) AS gmv FROM orders' } }),
// 新运行查库成功时 tool.end 多一个 query_artifact:照常解码成那一次查询,不另起行
E(5, 'tool.end', 'fetch', { tool: 'db_query__shop', call_id: 'c1', duration_ms: 40, artifact: 'tool-snap', query_artifact: 'query-snap',
preview: '{"columns":["gmv"],"rows":[[45678.5]]}' }),
E(6, 'llm.end', 'fetch', { model: 'm1', duration_ms: 900 }),
E(7, 'llm.start', 'fetch', { model: 'm1', structured: true, purpose: 'cite_fields', message_count: 5 }),
E(8, 'llm.end', 'fetch', { agent: '查数', model: 'm1', purpose: 'cite_fields', duration_ms: 1200, input_tokens: 900, output_tokens: 80 }),
E(9, 'log', 'fetch', { level: 'warn', code: 'agent_field_mismatch', message: MISMATCH, fields: ['order_cnt'] }),
E(10, 'log', 'fetch', { level: 'warn', code: 'agent_field_unverified', message: UNVERIFIED, fields: ['new_users', 'refund_cnt'] }),
E(11, 'node.finished', 'fetch', { duration_ms: 2400 }),
]
const all = flatten(mod.decodeRun(events))
check('新事件都有翻译', !all.some((s) => s.title === '一条还没翻译的记录'))
const llms = all.filter((s) => s.kind === 'llm')
check('循环里那次照旧叫「思考并作答」,抽取那次单独起名「按出处抽取字段」', llms.map((s) => s.title).join('|') === '思考并作答|按出处抽取字段',
llms.map((s) => s.title).join('|'))
check('抽取那次跑完了、带耗时', llms[1]?.status === 'done' && llms[1]?.meta === '1.2 s', `${llms[1]?.status} ${llms[1]?.meta}`)
const q = all.filter((s) => s.kind === 'query')
check('tool.end 多了 query_artifact:照常是一次查询,工件仍是 tool 快照', q.length === 1 && q[0].status === 'done' && q[0].artifact === 'tool-snap',
JSON.stringify(q.map((s) => [s.status, s.artifact])))
const mm = all.find((s) => s.code === 'agent_field_mismatch')
check('agent_field_mismatch 说人话:几个字段对不上、已按快照取值', mm?.title === '有 1 个字段和查询快照对不上,已按快照取值', mm?.title)
check('……点名的字段和两边的值放副标题,原话留在展开区', mm?.sub === 'order_cnt 模型报 1240,快照是 1234' && mm?.detail === MISMATCH,
`${mm?.sub} / ${mm?.detail}`)
check('……是提醒(warn),并说下游用的是哪个值', mm?.level === 'warn' && !!mm?.next?.includes('快照'), `${mm?.level} ${mm?.next}`)
const un = all.find((s) => s.code === 'agent_field_unverified')
check('agent_field_unverified 说人话:几个字段核对不了、记为空、没有兜底成 0', un?.title === '有 2 个字段核对不了出处,记为空值(没有兜底成 0)', un?.title)
check('……原因放副标题', un?.sub === 'new_users(Q1 没有列「new_users」);refund_cnt(快照取不回来)' && un?.level === 'warn', un?.sub)
check('……两条警告都挂在 agent 节点下面', all.find((s) => s.nodeId === 'fetch' && s.kind === 'node')?.children?.filter((c) => c.code?.startsWith('agent_field')).length === 2)
const failed = flatten(mod.decodeRun([
E(1, 'node.started', 'fetch', { node_type: 'agent' }),
E(2, 'llm.start', 'fetch', { model: 'm1', structured: true, purpose: 'cite_fields', message_count: 5 }),
E(3, 'llm.end', 'fetch', { agent: '查数', model: 'm1', purpose: 'cite_fields', duration_ms: 30000, error: 'TimeoutError: 模型调用超时' }),
E(4, 'log', 'fetch', { level: 'warn', code: 'agent_field_unverified', message: FAILED, fields: ['a', 'b', 'c'] }),
E(5, 'node.finished', 'fetch', { duration_ms: 31000 }),
]))
const ex = failed.find((s) => s.kind === 'llm')
check('抽取调用失败:那一行说「没跑成」,原因放展开区,是提醒不是整个节点失败', ex?.title === '按出处抽取字段没跑成' && ex?.level === 'warn'
&& !!ex?.detail?.includes('模型调用超时'), `${ex?.title} ${ex?.level} ${ex?.detail}`)
// status 是 failed 的话,这一行和「执行」那一栏的标头都画成红的失败,可节点、运行都成功了
check('……这一行是做完了的提醒(done + warn),不是 failed', ex?.status === 'done' && ex?.level === 'warn', `${ex?.status} ${ex?.level}`)
check('……节点本身照样成功', failed.find((s) => s.kind === 'node')?.status === 'done', failed.find((s) => s.kind === 'node')?.status)
const fu = failed.find((s) => s.code === 'agent_field_unverified')
check('……接着的警告说「抽取没跑成,N 个字段记为空值」', fu?.title === '按出处抽取字段没跑成,3 个字段记为空值(没有兜底成 0)'
&& fu?.sub === '模型调用超时', `${fu?.title} / ${fu?.sub}`)
const notObject = flatten(mod.decodeRun([
E(1, 'node.started', 'fetch', { node_type: 'agent' }),
E(2, 'log', 'fetch', { level: 'warn', code: 'agent_field_unverified', message: NOT_OBJECT, fields: ['a', 'b'] }),
E(3, 'node.finished', 'fetch', { duration_ms: 10 }),
])).find((s) => s.code === 'agent_field_unverified')
check('「抽取结果不是一个对象」:同样说「没跑成,N 个字段记为空值」,原因放副标题,不说「出处找不到」',
notObject?.title === '按出处抽取字段没跑成,2 个字段记为空值(没有兜底成 0)' && notObject?.sub === '抽取结果不是一个对象'
&& !notObject?.next?.includes('出处在查询结果里找不到'), `${notObject?.title} / ${notObject?.sub} / ${notObject?.next}`)
const node = mod.decodeRun([E(1, 'node.started', 'fetch', { node_type: 'agent' }), E(2, 'node.finished', 'fetch', { duration_ms: 1 })])
check('对照:普通 agent 节点不多出任何行', flatten(node).filter((s) => s.kind !== 'node').length === 0)
const odd = flatten(mod.decodeRun([E(1, 'node.started', 'x', { node_type: 'agent' }),
E(2, 'log', 'x', { level: 'warn', code: 'agent_field_mismatch', message: '字段对不上' }),
E(3, 'log', 'x', { level: 'warn', code: 'agent_field_unverified' })]))
check('别的说法(缺字段):标题照样说人话,不出 undefined、不瞎填数', odd.find((s) => s.code === 'agent_field_mismatch')?.title === '有字段和查询快照对不上,已按快照取值'
&& !odd.some((s) => /undefined|NaN/.test(`${s.title}${s.sub ?? ''}`)), odd.map((s) => s.title).join(' | '))
})
await section('术语', async () => {
// 没起名的节点退到类型名,类型名跟全站同一张表
const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }])
+636 -5
View File
@@ -24,6 +24,9 @@ const CHROME = process.env.CHROME_PATH
const SHOTS = process.env.EVIDENCE_SHOTS
const root = new URL('..', import.meta.url).pathname
const fx = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-doc.json`, 'utf8'))
// 第二期:报告直接引用查询单元格、整表,口径卡的输入核对到 agent 字段和查询快照。文档同样是
// compose_doc 真跑出来的(查询快照走 loader),片段接口的答复按方案第 5 节和 NOTES-A2 的形状拼
const fxq = JSON.parse(readFileSync(`${root}frontend/src/run/__tests__/evidence-query.json`, 'utf8'))
let failed = 0
const check = (name, cond, detail = '') => {
@@ -51,26 +54,43 @@ async function routed(page) {
const req = r.request()
const url = new URL(req.url())
if (req.method() !== 'GET') return r.abort()
const seg = url.pathname.match(/\/api\/runs\/[^/]+\/evidence\/segments\/([^/]+)$/)
const seg = url.pathname.match(/\/api\/runs\/([^/]+)\/evidence\/segments\/([^/]+)$/)
// 按运行认夹具:第二期那份(查询链)和第一期那份各有自己的片段
const set = url.pathname.includes(`/runs/${fxq.run_id}/`) ? fxq : fx
if (seg) {
hits.push(decodeURIComponent(seg[1]))
const body = fx.segments[decodeURIComponent(seg[1])]
hits.push(`${set === fxq ? 'q:' : ''}${decodeURIComponent(seg[2])}`)
const body = set.segments[decodeURIComponent(seg[2])]
return body ? r.fulfill({ json: body })
: r.fulfill({ status: 404, json: { detail: '报告里没有这个片段', code: 'evidence_segment_not_found' } })
}
if (/\/api\/runs\/[^/]+\/evidence$/.test(url.pathname)) return r.fulfill({ json: fx.graph })
if (/\/api\/runs\/[^/]+\/evidence$/.test(url.pathname)) return r.fulfill({ json: set.graph })
if (url.pathname === `/api/artifacts/${fx.doc_artifact}`) return r.fulfill({ json: { id: fx.doc_artifact, content: fx.doc } })
if (url.pathname === `/api/artifacts/${fxq.snapshots.Q3.artifact}`) {
hits.push('artifact:Q3')
// 工件接口给的是原值(遮罩只在证据接口里换):夹具里存的是换过的,这里换回「原值」
const content = structuredClone(fxq.snapshots.Q3.content)
const j = content.columns.indexOf('phone')
content.rows.forEach((row, i) => { row[j] = `RAW-PHONE-${i}` })
return r.fulfill({ json: { id: fxq.snapshots.Q3.artifact, content } })
}
return r.continue()
})
return hits
}
async function open(url, { w = 1280, h = 1000, reduced = false } = {}) {
async function open(url, { w = 1280, h = 1000, reduced = false, patchQuery = null } = {}) {
const ctx = await browser.newContext({ viewport: { width: w, height: h }, ...(reduced ? { reducedMotion: 'reduce' } : {}) })
const page = await ctx.newPage()
// 找不到元素时 8 秒就报,不等默认的 30 秒:改坏验证时一节里十几处都找不到
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const hits = await routed(page)
// 预览页直接 import 第二期夹具(vite 把 JSON 当模块给):要一份改过的文档时把那个模块换掉
if (patchQuery) {
await page.route((u) => new URL(u).pathname.endsWith('/run/__tests__/evidence-query.json'), (r) =>
r.fulfill({ contentType: 'application/javascript', body: `export default ${JSON.stringify(patchQuery(structuredClone(fxq)))}` }))
}
await page.goto(`${WEB}${url}`, { waitUntil: 'networkidle' })
await page.waitForSelector('[data-evidence-doc], [data-turn]', { timeout: 8000 })
return { ctx, page, errors, hits }
@@ -578,6 +598,444 @@ await section('fields', '成果字段取文档:别的运行写的、没记正
await r.ctx.close()
})
// ---------------------------------------------------------------------------
// 第二期:查询步骤、输入来源、整表、口径卡来源(夹具 evidence-query.json)
// ---------------------------------------------------------------------------
const Q = '#evidence-query'
const QN = '#evidence-query-narrow'
/** 夹具里的片段 id:按文字认,免得重新生成夹具后编号挪了这里全错 */
const qseg = (text, nth = 0) => Object.values(fxq.segments).filter((d) => d.segment.text === text)[nth]?.segment.id
const Q3 = fxq.snapshots.Q3.artifact
const qsteps = (text) => fxq.segments[qseg(text)].chain.filter((st) => st.step === 'query')
/** 第二期的组件预览:片段接口按 answer(segId, 夹具拷贝) 回(数字当状态码),其余照 routed */
async function probeQ(answer, opts = {}) {
const r = await open('/ui-harness.html?evidence=1', opts)
await r.page.route((u) => new URL(u).pathname.includes(`/runs/${fxq.run_id}/evidence/segments/`), (route) => {
const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop())
const body = answer(id, structuredClone(fxq.segments[id] ?? {}))
return typeof body === 'number'
? route.fulfill({ status: body, json: { detail: '伪造的错误', code: 'evidence_report_not_found' } })
: route.fulfill({ json: body })
})
return r
}
async function openQ(p, text, { box = Q, nth = 0, wait = '[data-ev-query]' } = {}) {
await p.locator(`${box} [data-seg="${qseg(text, nth)}"]`).click()
await p.waitForSelector(`[data-evidence-panel] ${wait}`, { timeout: 4000 }).catch(() => {})
await p.waitForTimeout(120)
}
/** 令牌算出来的颜色:拿一个探针元素量,不在脚本里写死色值 */
const tokenColor = (p, prop, value) => p.evaluate(([prop, value]) => {
const el = document.createElement('div')
el.style[prop] = value
document.body.appendChild(el)
const got = getComputedStyle(el)[prop]
el.remove()
return got
}, [prop, value])
await section('query', '查询步骤:SQL 带复制、被引用的行和格高亮、窗口说明、完整快照、遮罩', async () => {
await openQ(page, '1,288')
const part = panel(page).locator(`[data-ev-query="${Q3}"]`)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/evidence-query-step-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
check('点开单元格:面板里有这次查询的步骤', await part.count() === 1, String(await part.count()))
check('标题写查询编号和工具', (await part.innerText()).includes('Q3') && (await part.innerText()).includes('db_query__shop'))
check('SQL 原样摆出来', (await part.locator('[data-ev-sql]').innerText()).startsWith('SELECT order_id, region, amount, phone'))
check('SQL 带复制按钮', await part.locator('button', { hasText: '复制 SQL' }).count() === 1)
const rows = part.locator('tbody tr[data-highlight="row"]')
check('被引用的行加 data-highlight="row",只有这一行', await rows.count() === 1
&& (await rows.first().innerText()).includes('10006'), await rows.first().innerText().catch(() => ''))
const cell = part.locator('td[data-highlight="cell"]')
check('被引用的格加 data-highlight="cell",在 amount 列', await cell.count() === 1 && (await cell.innerText()) === '1288'
&& await cell.evaluate((td) => td.closest('table').querySelectorAll('th')[td.cellIndex]?.textContent === 'amount'),
await cell.innerText().catch(() => ''))
const [rowBg, cellLine] = await Promise.all([
rows.first().evaluate((el) => getComputedStyle(el).backgroundColor),
cell.evaluate((el) => getComputedStyle(el).outlineColor),
])
check('高亮只用令牌:行底是 --st-done-soft', rowBg === await tokenColor(page, 'backgroundColor', 'var(--st-done-soft)'), rowBg)
check('高亮只用令牌:格的描边是 --st-done', cellLine === await tokenColor(page, 'outlineColor', 'var(--st-done)'), cellLine)
check('只显示窗口:被引用的第 6 行加前后各 2 行,共 5 行', await part.locator('tbody tr').count() === 5)
const win = await part.locator('[data-ev-window]').innerText().catch(() => '')
check('窗口外的行写明「仅显示被引用的行及前后各 2 行」和位置', win.includes('仅显示被引用的行及前后各 2 行')
&& win.includes('第 4–8 行') && win.includes('共 12 行'), win)
const masked = await part.locator('td[data-masked]').allInnerTexts()
check('被遮罩的列(phone)每格写「已遮罩」', masked.length === 5 && masked.every((t) => t === '已遮罩'), masked.join('|'))
const note = await panel(page).locator('[data-ev-mask-note]').innerText().catch(() => '')
check('面板底部说明遮罩:列名 + 不是安全边界', note.includes('phone') && note.includes('遮罩只减少暴露,不是安全边界'), note)
check('遮罩说明在面板最后(封存那一行之后)', await panel(page).evaluate((p) => {
const note = p.querySelector('[data-ev-mask-note]')
const seal = p.querySelector('[data-ev-seal]')
return !!note && !!seal && !!(seal.compareDocumentPosition(note) & Node.DOCUMENT_POSITION_FOLLOWING)
}))
check('查询快照复验通过时不多说一句', await panel(page).locator('[data-ev-integrity]').count() === 0)
const aria = await page.locator(`${Q} [data-seg="${qseg('1,288')}"]`).getAttribute('aria-label')
check('单元格的 aria-label 写明查询、第几行、哪一列', aria === '1,288,有出处:查询 Q3 · 第 6 行 · amount', aria)
const before = harness.hits.filter((h) => h === 'artifact:Q3').length
await part.locator('[data-ev-snapshot]').click()
await page.waitForSelector('[role="dialog"] [data-artifact] tbody tr', { timeout: 4000 }).catch(() => {})
const full = await page.locator('[role="dialog"] [data-artifact] tbody tr').count()
check('「打开完整快照」复用工件查看,12 行全在', full === 12 && harness.hits.filter((h) => h === 'artifact:Q3').length === before + 1, `${full} 行`)
const fullText = await page.locator('[role="dialog"] [data-artifact]').innerText().catch(() => '')
check('……完整快照里遮罩列同样写「已遮罩」,工件接口给的原值不画出来', !fullText.includes('RAW-PHONE')
&& await page.locator('[role="dialog"] [data-artifact] td[data-masked]').count() === 12,
fullText.includes('RAW-PHONE') ? '原值漏出来了' : String(await page.locator('[role="dialog"] [data-artifact] td[data-masked]').count()))
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('在完整快照里按 Esc 只关快照,证据面板还开着', await page.locator('[role="dialog"]').count() === 0 && await panel(page).count() === 1)
await openQ(page, '华东')
const v = panel(page).locator('[data-ev-query]')
check('值片段(华东)也给查询步骤,高亮第 1 行 region 格', (await v.locator('td[data-highlight="cell"]').innerText().catch(() => '')) === '华东'
&& await v.locator('tbody tr').first().getAttribute('data-highlight') === 'row')
check('第 1 行:窗口是第 1–3 行(前面没有行可补),照实写', (await v.locator('[data-ev-window]').innerText().catch(() => ''))
.includes('第 1–3 行,共 4 行'))
check('没有遮罩列时不写遮罩说明', await panel(page).locator('[data-ev-mask-note]').count() === 0)
await page.keyboard.press('Escape')
await openQ(page, '45,678.5元')
const one = panel(page).locator('[data-ev-query]')
check('整份快照都在窗口里(1 行):不写窗口说明,完整快照照样能开', await one.count() === 1
&& await one.locator('[data-ev-window]').count() === 0 && await one.locator('[data-ev-snapshot]').count() === 1)
await page.keyboard.press('Escape')
// 被引用的两格隔得远(第 6、12 行):窗口不连续,行号那一列看得出跳过了哪几行,只标那两格
await openQ(page, '672.5元')
const gap = panel(page).locator(`[data-ev-query="${Q3}"]`)
const nums = await gap.locator('tbody tr td:first-child').allInnerTexts()
check('窗口不连续:最前面一列是快照里的行号(4–8、10–12)', nums.join(',') === '4,5,6,7,8,10,11,12', nums.join(','))
check('……窗口说明写成「第 4–8、10–12 行,共 12 行」', (await gap.locator('[data-ev-window]').innerText().catch(() => ''))
.includes('第 4–8、10–12 行,共 12 行'))
const hit2 = await gap.locator('td[data-highlight="cell"]').allInnerTexts()
check('……两格都高亮(1288、615.5)', hit2.join(',') === '1288,615.5', hit2.join(','))
await page.keyboard.press('Escape')
const exact = await probeQ((id, body) => (id === qseg('1,288') ? { ...body, chain: body.chain.map((st) => (st.step === 'query'
? { ...st, highlight: { rows: [5, 6], cols: ['amount', 'region'], cells: [[5, 'amount'], [6, 'region']] } } : st)) } : body))
await openQ(exact.page, '1,288')
const exactCells = await exact.page.locator('[data-evidence-panel] td[data-highlight="cell"]').allInnerTexts()
check('highlight 给了 cells:只标那几格,不按行 × 列全标', exactCells.join(',') === '1288,华北', exactCells.join(','))
await exact.ctx.close()
const unsealed = await probeQ((id, body) => (id === qseg('1,288') ? { ...body, chain: body.chain.map((st) => (st.step === 'query'
? { step: 'query', alias: st.alias, artifact: st.artifact, tool: st.tool, columns: [], rows: [], hash_ok: null, sealed: false,
highlight: st.highlight, note: '这份查询快照不在封存范围内的任何事件里,不能当证据展示' } : st)) } : body))
await openQ(unsealed.page, '1,288')
const un = await unsealed.page.locator('[data-evidence-panel] [data-ev-query]').innerText().catch(() => '')
check('查询快照不在封存范围里:一行都不画,照接口原话说,并用失败色写「不在封存范围内」', un.includes('不能当证据展示')
&& await unsealed.page.locator('[data-evidence-panel] [data-ev-query] [data-ev-integrity~="sealed"]').count() === 1
&& await unsealed.page.locator('[data-evidence-panel] [data-ev-query] tbody tr').count() === 0,
un.replace(/\s+/g, ' ').slice(0, 200))
// 后端这时照样带着工件 id:按钮一点就绕过证据接口把整份快照打开,还挂着「哈希已校验」
check('……不给「打开完整快照」(接口不给行,面板也不能开整份快照)',
await unsealed.page.locator('[data-evidence-panel] [data-ev-snapshot]').count() === 0)
await unsealed.ctx.close()
// 接口把遮罩列的原值带回来了(老接口、改错了的后端):列在遮罩里就一律不画原值
const leak = await probeQ((id, body) => (id === qseg('1,288') ? { ...body, chain: body.chain.map((st) => (st.step === 'query'
? { ...st, rows: st.rows.map((r) => r.map((v, j) => (st.columns[j] === 'phone' ? `RAW-PHONE-${j}` : v))) } : st)) } : body))
await openQ(leak.page, '1,288')
const leaked = await leak.page.locator('[data-evidence-panel]').innerText().catch(() => '')
check('遮罩列带回了原值也不画:表里一律写「已遮罩」', !leaked.includes('RAW-PHONE')
&& await leak.page.locator('[data-evidence-panel] td[data-masked]').count() === 5, leaked.includes('RAW-PHONE') ? '原值漏出来了' : '')
await leak.ctx.close()
const bad = await probeQ((id, body) => (id === qseg('1,288')
? { ...body, chain: body.chain.map((st) => (st.step === 'query' ? { ...st, hash_ok: false } : st)) } : body))
await openQ(bad.page, '1,288')
const said = await bad.page.locator('[data-evidence-panel] [data-ev-integrity]').innerText().catch(() => '')
const color = await bad.page.locator('[data-evidence-panel] [data-ev-integrity]').evaluate((el) => getComputedStyle(el).color).catch(() => '')
check('查询快照哈希对不上:用失败色写出来', said.includes('查询快照') && said.includes('哈希对不上')
&& color === await tokenColor(bad.page, 'color', 'var(--st-failed)'), said)
check('……哈希对不上也不给「打开完整快照」', await bad.page.locator('[data-evidence-panel] [data-ev-snapshot]').count() === 0)
await bad.ctx.close()
})
await section('sources', '输入来源:与快照一致、模型报 X 快照是 Y、没查到记为空、代码节点、点标签跳到查询', async () => {
await openQ(page, '37.0元', { wait: '[data-ev-sources]' })
const src = (path) => panel(page).locator(`[data-ev-source="${path}"]`)
const gmv = await src('vars.kpi.gmv').innerText().catch(() => '')
check('agent 字段 verified:写「与快照一致」', gmv.includes('与快照一致')
&& await src('vars.kpi.gmv').getAttribute('data-ev-source-status') === 'verified', gmv)
const ord = await src('vars.kpi.order_cnt').innerText().catch(() => '')
check('mismatch:写「模型报 1,240,快照是 1,234,已按快照取值」', ord.includes('模型报 1,240,快照是 1,234,已按快照取值'), ord)
check('写明从哪个节点的哪个字段、哪一格来', gmv.includes('fetch') && gmv.includes('Q1') && gmv.includes('第 1 行'), gmv)
await page.keyboard.press('Escape')
await openQ(page, '—', { wait: '[data-ev-sources]' })
const refund = await src('vars.kpi.refund_cnt').innerText().catch(() => '')
check('from 为 null(missing):写「没查到,记为空,没有兜底成 0」', refund.includes('没查到,记为空,没有兜底成 0'), refund)
check('缺输入的指标照样给出输入来源,说得清缺的是哪一个', await panel(page).locator('[data-ev-missing]').count() === 1
&& await panel(page).locator('[data-ev-sources] [data-ev-source]').count() === 2)
await page.keyboard.press('Escape')
await openQ(page, '—', { nth: 1, wait: '[data-ev-sources]' })
const nu = await src('vars.kpi.new_users').innerText().catch(() => '')
check('unresolved:同样写「没查到,记为空,没有兜底成 0」,并带原因', nu.includes('没查到,记为空,没有兜底成 0')
&& nu.includes('没有列「new_users」'), nu)
await page.keyboard.press('Escape')
await openQ(page, '44,000.0元', { wait: '[data-ev-sources]' })
const compute = src('nodes.adjust.total')
const ct = await compute.innerText().catch(() => '')
check('来自 code 节点(计算):写警示语,说要进口径卡', ct.includes('代码节点') && ct.includes('口径卡')
&& await compute.getAttribute('data-ev-source-role') === 'compute', ct)
const computeColor = await compute.evaluate((el) => getComputedStyle(el).borderColor).catch(() => '')
await page.keyboard.press('Escape')
await openQ(page, '7.12', { wait: '[data-ev-sources]' })
const source = src('nodes.rate.value')
const st = await source.innerText().catch(() => '')
check('来自 code 节点(取数):写明标为取数、核对不到快照', st.includes('取数') && st.includes('核对不到')
&& await source.getAttribute('data-ev-source-role') === 'source', st)
const sourceColor = await source.evaluate((el) => getComputedStyle(el).borderColor).catch(() => '')
check('计算角色比取数角色醒目:失败色描边', computeColor === await tokenColor(page, 'borderColor', 'var(--st-failed)')
&& sourceColor !== computeColor, `${computeColor} / ${sourceColor}`)
await page.keyboard.press('Escape')
await openQ(page, '8.7%', { wait: '[data-ev-sources]' })
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(250)
await page.screenshot({ path: `${SHOTS}/evidence-sources-${theme}.png` })
await panel(page).evaluate((p) => { p.querySelector('.overflow-y-auto').scrollTop = 99999 })
await page.waitForTimeout(100)
await page.screenshot({ path: `${SHOTS}/evidence-sources-queries-${theme}.png` })
await panel(page).evaluate((p) => { p.querySelector('.overflow-y-auto').scrollTop = 0 })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
const prev = qsteps('8.7%').find((q) => q.alias === 'Q4')
const chip = panel(page).locator(`button[data-ev-input="cell(nodes.pull, 0, 'gmv')"]`)
check('代入式里的输入标签是按钮', await chip.count() === 1)
await chip.click()
await page.waitForTimeout(200)
const landed = await page.evaluate((a) => {
const part = document.querySelector(`[data-evidence-panel] [data-ev-query="${a}"]`)
const css = part ? getComputedStyle(part) : null
return { inside: !!part?.contains(document.activeElement), flash: part?.hasAttribute('data-flash') ?? false,
style: css?.outlineStyle, width: css?.outlineWidth, color: css?.outlineColor }
}, prev?.artifact)
// 只有属性没有样式等于没描:目标步骤已经在视野里时,点完什么都看不出来变了
check('点输入标签:焦点跳到对应的查询步骤(Q4),并描一下(看得见的强调色描边)', landed.inside && landed.flash
&& landed.style !== 'none' && parseFloat(landed.width) >= 1 && landed.color === await tokenColor(page, 'outlineColor', 'var(--accent)'),
JSON.stringify(landed))
// 键盘:焦点落在查询步骤的标题上,得有看得见的焦点框(WCAG 2.4.7)
await page.waitForTimeout(1700)
await page.keyboard.press('Shift')
await chip.focus()
await page.keyboard.press('Enter')
await page.waitForTimeout(150)
const ring = await page.evaluate(() => {
const el = document.activeElement
const css = el ? getComputedStyle(el) : null
return { head: el?.hasAttribute('data-ev-query-head') ?? false, style: css?.outlineStyle, width: css?.outlineWidth }
})
check('……键盘按回车跳过去:焦点落在查询步骤标题上,焦点框看得见', ring.head && ring.style !== 'none' && parseFloat(ring.width) >= 1,
JSON.stringify(ring))
const go = panel(page).locator('[data-ev-source="vars.kpi.gmv"] button[data-ev-goto]')
check('输入来源那一行也能跳到查询', await go.count() === 1)
await go.click()
await page.waitForTimeout(200)
check('……跳到 Q1 那一步', await page.evaluate((a) => !!document.querySelector(`[data-evidence-panel] [data-ev-query="${a}"]`)
?.contains(document.activeElement), qsteps('8.7%').find((q) => q.alias === 'Q1')?.artifact))
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 关掉面板,焦点回到原片段', await panel(page).count() === 0 && await active(page) === qseg('8.7%'), await active(page))
})
await section('table', '整表:每格是可点的片段,方向键按行列走,值和数同一套线型', async () => {
const cells = page.locator(`${Q} table td [data-seg]`)
check('整表每个数据格都是可点的片段(3 行 × 3 列)', await cells.count() === 9, String(await cells.count()))
check('表头不是片段', await page.locator(`${Q} table th [data-seg]`).count() === 0)
const [val, num] = await Promise.all([qseg('华东', 1), qseg('18,230.5', 1)].map((id) => page.locator(`${Q} [data-seg="${id}"]`)
.evaluate((el) => { const cs = getComputedStyle(el); return `${cs.textDecorationLine}/${cs.textDecorationStyle}/${cs.textDecorationColor}` })))
check('值片段(华东)和数字片段(18,230.5)同一套线型', val === num && val.includes('solid'), `${val} vs ${num}`)
await page.locator(`${Q} [data-seg="${qseg('18,230.5', 1)}"]`).focus()
const walk = []
for (const key of ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'ArrowDown']) {
await page.keyboard.press(key)
walk.push(await active(page))
}
const want = [qseg('12,004', 1), qseg('9,876'), qseg('12,004', 1), qseg('311'), qseg('298'), qseg('1,288')]
check('表格里 ↓/↑ 按列走到下一行 / 上一行,→ 走到右边一格,出了最后一行接着往下', walk.join(',') === want.join(','),
`${walk.join(',')} ≠ ${want.join(',')}`)
await page.locator(`${Q} [data-seg="${qseg('华东', 1)}"]`).focus()
await page.keyboard.press('ArrowUp')
check('第一行按 ↑ 回到表格前面那一句', await active(page) === qseg('华东', 0), await active(page))
await page.locator(`${Q} [data-seg="${qseg('311')}"]`).focus()
await page.keyboard.press('Enter')
await page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 4000 }).catch(() => {})
const hl = await panel(page).locator('td[data-highlight="cell"]').innerText().catch(() => '')
check('回车打开这一格的查询步骤,高亮 311 这一格', hl === '311', hl)
await page.keyboard.press('Escape')
const stops = await page.locator(`${Q} [data-seg][tabindex="0"]`).count()
check('整份报告(含表格)仍只占一个 Tab 位', stops === 1, String(stops))
})
await section('caliber', '口径卡来源和升版处置', async () => {
await openQ(page, '45,678.5元', { wait: '[data-ev-caliber-from]' })
const from = await panel(page).locator('[data-ev-caliber-from]').innerText().catch(() => '')
check('指标步骤写「口径卡「周报口径」v3,来自「销售周报」v5」', from.includes('口径卡「周报口径」v3,来自「销售周报」v5'), from)
const up = await panel(page).locator('[data-ev-caliber-upgrade]').innerText().catch(() => '')
check('有升版处置时写「上游已有 v6,按『…』处置」', up.includes('上游已有 v6') && up.includes('并排双印新旧口径') && up.includes('处置'), up)
await page.keyboard.press('Escape')
// 方案第 5 节的写法(caliber_from)也认;没给工作流名、没给 policy_label 时不出 undefined
const bare = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'metric'
? { ...st, source: undefined, caliber_from: { workflow_id: 'wf-demo-weekly', workflow_version: 5 },
caliber_upgrade: { policy: 'recompute', latest: 6 } } : st)) }))
await openQ(bare.page, '45,678.5元', { wait: '[data-ev-caliber-from]' })
const t = await bare.page.locator('[data-evidence-panel] [data-ev-metric]').innerText()
check('没给工作流名:退回工作流 id,不出 undefined', t.includes('来自') && t.includes('wf-demo') && !/undefined|null/.test(t), t.replace(/\s+/g, ' ').slice(0, 160))
check('没给 policy_label:按策略代码说人话', t.includes('用新口径回算历史'), t.replace(/\s+/g, ' ').slice(0, 200))
await bare.ctx.close()
const none = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'metric'
? { ...st, source: null, caliber_from: undefined, caliber_upgrade: null } : st)) }))
await openQ(none.page, '45,678.5元', { wait: '[data-ev-expression]' })
check('本地定义的口径卡、没有升版:两行都不出', await none.page.locator('[data-evidence-panel] [data-ev-caliber-from], [data-evidence-panel] [data-ev-caliber-upgrade]').count() === 0)
await none.ctx.close()
})
await section('q-narrow', '查询步骤在 360px:栏内展开、底部抽屉都不横向滚动;减少动效', async () => {
const overflow = () => page.evaluate((sel) => {
const el = document.querySelector(sel)
return { sw: el.scrollWidth, cw: el.clientWidth }
}, QN)
await openQ(page, '1,288', { box: QN })
const inline = await page.locator(`${QN} [data-evidence-panel="inline"] [data-ev-query]`).count()
const o = await overflow()
check('窄栏里查询步骤在栏内展开', inline === 1)
check('窄栏里没有横向滚动(表格自己在框里滚)', o.sw <= o.cw, JSON.stringify(o))
const hl = await page.locator(`${QN} td[data-highlight="cell"]`).evaluate((td) => {
const box = td.closest('.overflow-x-auto')
const a = td.getBoundingClientRect()
const b = box.getBoundingClientRect()
return a.left >= b.left - 1 && a.right <= b.right + 1
}).catch(() => false)
check('被引用的格滚到看得见的位置', hl)
await page.locator(`${QN} [data-evidence-panel] button`, { hasText: '回到正文' }).click()
const small = await open('/ui-harness.html?evidence=1', { w: 360, h: 780 })
await small.page.locator(`${Q} [data-seg="${qseg('1,288')}"]`).click()
await small.page.waitForSelector('[data-evidence-panel] [data-ev-query]', { timeout: 4000 }).catch(() => {})
const s = await small.page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: innerWidth }))
check('360px 的屏幕:底部抽屉里有查询步骤', await small.page.locator('[data-evidence-panel="drawer"] [data-ev-query]').count() === 1)
check('360px 的屏幕:整页没有横向滚动', s.sw <= s.vw, JSON.stringify(s))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await small.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await small.page.waitForTimeout(250)
await small.page.screenshot({ path: `${SHOTS}/evidence-query-360-${theme}.png` })
}
}
check('没有运行时报错(360px)', small.errors.length === 0, small.errors.join(' | '))
await small.ctx.close()
const r = await open('/ui-harness.html?evidence=1', { reduced: true })
await r.page.locator(`${Q} [data-seg="${qseg('8.7%')}"]`).click()
await r.page.waitForSelector('[data-evidence-panel] [data-ev-sources]', { timeout: 4000 }).catch(() => {})
await r.page.locator(`[data-evidence-panel] button[data-ev-input="cell(nodes.pull, 0, 'gmv')"]`).click().catch(() => {})
await r.page.waitForTimeout(100)
const moving = await r.page.evaluate(() => [...(document.querySelector('[data-evidence-panel]')?.getAnimations({ subtree: true }) ?? [])]
.filter((a) => !(a instanceof CSSTransition && Number(a.effect?.getTiming().duration) <= 0.01)).length)
check('减少动效:面板和跳转都没有动画', moving === 0, String(moving))
await r.ctx.close()
})
await section('q-tolerant', '接口缺字段、取不到:降级显示,不白屏', async () => {
const miss = await probeQ(() => 404)
await openQ(miss.page, '1,288', { wait: '[data-ev-query-missing]' })
const t = await miss.page.locator('[data-evidence-panel]').innerText()
check('片段接口取不到:只显示文档里记的出处(Q3 · db_query__shop · 12 行),照实说取不到', t.includes('Q3')
&& t.includes('db_query__shop') && t.includes('12 行') && t.includes('没取到'), t.replace(/\s+/g, ' ').slice(0, 200))
check('……没有假装高亮了什么', await miss.page.locator('[data-evidence-panel] [data-highlight]').count() === 0)
check('……没有运行时报错', miss.errors.length === 0, miss.errors.join(' | '))
await miss.ctx.close()
const thin = await probeQ((id, body) => ({ ...body, redacted: undefined,
chain: (body.chain ?? []).map((st) => (st.step === 'query' ? { step: 'query', alias: st.alias, columns: st.columns, rows: st.rows } : st)) }))
await openQ(thin.page, '1,288')
const part = thin.page.locator('[data-evidence-panel] [data-ev-query]')
check('查询步骤只有列和行(老形状):照样画表,不高亮、不写窗口位置', await part.locator('tbody tr').count() === 5
&& await part.locator('[data-highlight]').count() === 0 && !(await part.innerText()).includes('undefined'))
check('……没有工件 id 就不给「打开完整快照」', await part.locator('[data-ev-snapshot]').count() === 0)
const inRange = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, highlight: { rows: [2], cols: ['amount'] } } : st)) }))
await openQ(inRange.page, '1,288')
check('highlight 的行号落在窗口里时按窗口内下标认(row_offset 之前的号)', (await inRange.page.locator('[data-evidence-panel] td[data-highlight="cell"]').innerText().catch(() => '')) === '1288')
check('没有 mask_note 就不出那一句', await inRange.page.locator('[data-evidence-panel] [data-ev-query-mask-note]').count() === 0)
check('没有运行时报错(缺字段)', thin.errors.length === 0 && inRange.errors.length === 0, [...thin.errors, ...inRange.errors].join(' | '))
await thin.ctx.close()
await inRange.ctx.close()
// 查询之后数据源改名或删掉了(E2W-6):后端按查询当时记下的遮罩处理,行照样给,另带一句 mask_note。
// 这句写在窗口说明那一行、琥珀色:现在的数据源设置已经管不到这份快照了
const MASK_NOTE = '数据源「shop」现在找不到了(改名或删掉了),按查询当时记下的遮罩处理'
const gone = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, mask_note: MASK_NOTE } : st)) }))
await openQ(gone.page, '1,288')
const goneNote = gone.page.locator('[data-evidence-panel] [data-ev-query] [data-ev-query-mask-note]')
const [goneText, goneColor, amber, sameLine] = await Promise.all([
goneNote.innerText().catch(() => ''),
goneNote.evaluate((el) => getComputedStyle(el).color).catch(() => ''),
tokenColor(gone.page, 'color', 'var(--st-waiting)'),
goneNote.evaluate((el) => el.parentElement === el.closest('[data-ev-query]')?.querySelector('[data-ev-window]')?.parentElement).catch(() => false),
])
check('数据源找不到了:mask_note 原话写出来', goneText === MASK_NOTE, goneText)
check('……琥珀色(--st-waiting),和窗口说明同一行', goneColor === amber && sameLine, `${goneColor} / ${amber} · 同一行 ${sameLine}`)
check('……行照样画、按记下的遮罩照样写「已遮罩」', await gone.page.locator('[data-evidence-panel] [data-ev-query] tbody tr').count() === 5
&& await gone.page.locator('[data-evidence-panel] [data-ev-query] td[data-masked]').count() === 5)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await gone.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await gone.page.waitForTimeout(200)
await gone.page.locator('[data-evidence-panel]').screenshot({ path: `${SHOTS}/evidence-mask-note-${theme}.png` }).catch(() => {})
}
}
check('没有运行时报错(mask_note)', gone.errors.length === 0, gone.errors.join(' | '))
await gone.ctx.close()
// 窗口被截到 50 行(window_truncated):被引用的另一格(第 41 行)不在窗口里,窗口里那一格照样标
const cut = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, window_truncated: true, total_rows: 60,
highlight: { rows: [5, 40], cols: ['amount'], cells: [[5, 'amount'], [40, 'amount']] } } : st)) }))
await openQ(cut.page, '1,288')
const cutCells = await cut.page.locator('[data-evidence-panel] td[data-highlight="cell"]').allInnerTexts()
check('窗口截断、有的被引用行不在窗口里:窗口里的那一格照样高亮,不是一格都不标', cutCells.join(',') === '1288'
&& await cut.page.locator('[data-evidence-panel] tr[data-highlight="row"]').count() === 1, cutCells.join(',') || '一格都没标')
const cutRows = await probeQ((id, body) => ({ ...body, chain: (body.chain ?? []).map((st) => (st.step === 'query'
? { ...st, window_truncated: true, highlight: { rows: [5, 40], cols: ['amount'] } } : st)) }))
await openQ(cutRows.page, '1,288')
check('……只给 rows × cols 时同样只标窗口里的那一行', (await cutRows.page.locator('[data-evidence-panel] td[data-highlight="cell"]').allInnerTexts()).join(',') === '1288')
await cut.ctx.close()
await cutRows.ctx.close()
// 解析不了的单元格引用(行越界):后端不给链。上面已经说了原因,不能再摆一块「查询的行这一次没取到」
const REASON = 'Q3 要第 99 行,但这份查询结果只有 12 行(从 0 数)'
const broken = (seg) => ({ ...seg, text: '⟦?v:Q3.r99.amount⟧', state: 'none', issue: 'unresolved_ref', ref: 'v:Q3.r99.amount',
cite: { ref: 'Q3.r99.amount', alias: 'Q3', kind: 'cell', role: 'value', status: 'unresolved', reason: REASON } })
const target = qseg('1,288')
const bad = await probeQ((id, body) => (id === target
? { ...body, segment: broken(body.segment), chain: [], note: `引用解析不了:${REASON}` } : body), {
patchQuery: (f) => {
for (const b of f.doc.blocks) for (const u of b.units ?? []) u.segments = (u.segments ?? []).map((sg) => (sg.id === target ? broken(sg) : sg))
return f
},
})
await bad.page.locator(`${Q} [data-seg="${target}"]`).click()
await bad.page.waitForSelector('[data-evidence-panel] [data-ev-reason]', { timeout: 4000 }).catch(() => {})
await bad.page.waitForTimeout(150)
const said = await bad.page.locator('[data-evidence-panel]').innerText().catch(() => '')
check('解析不了的单元格引用:说清原因(行越界)', said.includes(REASON), said.replace(/\s+/g, ' ').slice(0, 200))
check('……不再摆「查询 Q3 · 这一次没取到」,那像是接口出了错', await bad.page.locator('[data-evidence-panel] [data-ev-query-missing]').count() === 0
&& !said.includes('没取到'), said.replace(/\s+/g, ' ').slice(0, 200))
check('没有运行时报错(截断、解析不了)', cut.errors.length === 0 && bad.errors.length === 0, [...cut.errors, ...bad.errors].join(' | '))
await bad.ctx.close()
})
await section('pinned', '没有 _evidence 的旧回答:和 Markdown.tsx 改动前逐字一样', async () => {
// markdown-pinned.json 里的 html 是改动前(HEAD b418c0d)的 Markdown.tsx 渲染的。重新生成:把
// `git show <旧提交>:frontend/src/run/Markdown.tsx` 临时放进 src/run/,另起一个页面逐个渲染
@@ -678,6 +1136,179 @@ await section('studio', '报告撰写节点:画布认得、检查器能配(R
await ctx.close()
})
await section('config', '配置项:agent 的 cite_fields、code 的证据角色、口径卡钉住上游(caliber_from + upgrade_policy)', async () => {
// 假的工作流和上游,读写都在浏览器层拦下:GET 回假的,写一律 409,不落库
const node = (id, type, x, label, config = {}) => ({ id, type, position: { x, y: 80 }, data: { label, config } })
const graph = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('fetch', 'agent', 240, '查数', { prompt: '查本周销售额', tools: ['db_query__shop'] }),
node('calc', 'code', 480, '换算', { language: 'python', code: 'print(1)' }),
node('caliber', 'metrics', 720, '周报口径', { caliber: '周报口径', caliber_version: 'v2',
metrics: [{ id: 'gmv', name: '销售额', unit: '元', expression: 'vars.kpi.gmv' }] }),
node('sub', 'subgraph', 960, '方法卡', { workflow_id: 'fx-weekly', input: {} }),
],
edges: [['in', 'fetch'], ['fetch', 'calc'], ['calc', 'caliber'], ['caliber', 'sub']].map(([source, target]) => ({ id: `${source}-${target}`, source, target })),
}
const stamp = { tags: [], is_template: false, status: 'draft', published_version: null, run_count: 0,
created_at: '2026-09-26T00:00:00Z', updated_at: '2026-09-26T00:00:00Z', description: '' }
const wf = { id: 'fx-config', name: '配置项检查', graph, version: 1, ...stamp }
const upstream = { id: 'fx-weekly', name: '销售周报', graph: { nodes: [], edges: [] }, version: 6, ...stamp, status: 'published', published_version: 6 }
const pinnedGraph = { nodes: [
node('caliber', 'metrics', 0, '周报口径', { caliber: '周报口径', caliber_version: 'v3',
metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }, { id: 'aov', name: '客单价', expression: 'vars.kpi.gmv / vars.kpi.order_cnt' }] }),
node('write', 'report', 240, '写周报', {}),
], edges: [] }
const versions = [5, 6].map((v) => ({ id: `v${v}`, version: v, note: v === 6 ? '改了客单价口径' : '', published: v === 6 }))
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const p = await ctx.newPage()
p.setDefaultTimeout(8000)
const errors = []
p.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await p.route(/\/api\/workflows(\?.*)?$/, (r) => (r.request().method() === 'GET' ? json(r, [wf, upstream]) : json(r, { detail: '检查脚本不写库' }, 409)))
await p.route(/\/api\/workflows\/fx-config(\/.*)?(\?.*)?$/, (r) =>
(r.request().method() === 'GET' && !new URL(r.request().url()).pathname.endsWith('/versions') ? json(r, wf) : json(r, [])))
await p.route(/\/api\/workflows\/fx-weekly\/versions(\/\d+)?$/, (r) => {
const m = new URL(r.request().url()).pathname.match(/\/versions\/(\d+)$/)
return m ? json(r, { ...versions.find((v) => v.version === Number(m[1])), workflow_id: 'fx-weekly', graph: pinnedGraph }) : json(r, versions)
})
await p.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (r) =>
(r.request().method() === 'GET' ? json(r, []) : json(r, { id: 'fx-conv', kind: 'canvas', title: '', turns: [] })))
await p.route(/\/api\/runs(\/.*)?(\?.*)?$/, (r) => (r.request().method() === 'GET' ? r.continue() : json(r, { detail: '不发起运行' }, 409)))
await p.goto(`${WEB}/studio/fx-config`, { waitUntil: 'networkidle' })
await p.waitForFunction(() => window.__studio?.getState().workflow?.id === 'fx-config', null, { timeout: 15000 })
await p.waitForTimeout(500)
const cfg = (id) => p.evaluate((id) => window.__studio.getState().nodes.find((n) => n.id === id)?.data.config, id)
const advanced = async () => {
const t = p.getByRole('button', { name: /高级选项/ })
if ((await t.getAttribute('aria-expanded')) !== 'true') await t.click()
}
await p.evaluate(() => window.__studio.getState().select('fetch'))
await advanced()
const cite = p.locator('[data-field="cite_fields"]')
check('agent 有「按出处核对字段」开关,没写 Schema 时用不了并说清差什么', await cite.locator('input[type="checkbox"]').isDisabled()
&& (await cite.innerText()).includes('先在上面写「结构化输出 Schema」'), (await cite.innerText()).replace(/\s+/g, ' ').slice(0, 120))
check('……说明会多一次抽取调用', (await cite.innerText()).includes('多一次抽取调用'))
check('output_schema 的说明写明要开 cite_fields 才生效', (await p.locator('[data-field="output_schema"]').innerText()).includes('按出处核对字段'))
await p.evaluate(() => {
const s = window.__studio.getState()
const n = s.nodes.find((x) => x.id === 'fetch')
s.updateNode('fetch', { config: { ...n.data.config, output_schema: { type: 'object', properties: { gmv: { type: 'number' } } } } })
})
await p.waitForTimeout(150)
check('写了 Schema:开关可用', !(await cite.locator('input[type="checkbox"]').isDisabled()) && await cite.locator('[id$="-off"]').count() === 0)
await cite.locator('input[type="checkbox"]').check()
check('……打开后写进 config.cite_fields', (await cfg('fetch'))?.cite_fields === true)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(200)
await p.screenshot({ path: `${SHOTS}/evidence-config-agent-${theme}.png` })
}
}
// 开着开关又把 Schema 清掉:不能锁死在「开」上,得还能关
await p.evaluate(() => {
const s = window.__studio.getState()
const { output_schema: _drop, ...rest } = s.nodes.find((x) => x.id === 'fetch').data.config
s.updateNode('fetch', { config: rest })
})
await p.waitForTimeout(150)
check('开着又清掉 Schema:开关不锁死,还能关', !(await cite.locator('input[type="checkbox"]').isDisabled())
&& await cite.getAttribute('data-disabled') === null && await cite.locator('[id$="-off"]').count() === 0)
await cite.locator('input[type="checkbox"]').uncheck({ timeout: 2000 }).catch(() => {})
check('……关掉后 config.cite_fields 不再为真;关掉之后才锁上', !(await cfg('fetch'))?.cite_fields
&& await cite.locator('input[type="checkbox"]').isDisabled())
await p.evaluate(() => window.__studio.getState().select('calc'))
await advanced()
const role = p.locator('[data-field="evidence_role"]')
const opts = await role.locator('option').allInnerTexts()
check('code 有「证据角色」下拉:计算(默认)/ 取数', opts.join('|') === '计算(默认)|取数', opts.join('|'))
check('……说明报告不能直接引用代码节点的产出', (await role.innerText()).includes('报告不能直接引用代码节点的产出'))
await role.locator('select').selectOption('source')
check('……选「取数」写进 config.evidence_role', (await cfg('calc'))?.evidence_role === 'source')
await p.evaluate(() => window.__studio.getState().select('caliber'))
const from = p.locator('[data-field="caliber_from"]')
check('口径卡默认「在这里定义」,本地的名称、版本、指标都在', await from.locator('[data-caliber-from="local"]').count() === 1
&& await p.locator('[data-field="metrics"]').count() === 1 && await p.locator('[data-field="upgrade_policy"]').count() === 0)
await from.getByRole('radio', { name: '钉住别的工作流里的口径卡' }).click()
await p.waitForTimeout(150)
check('切到钉住:本地定义收起(后端也说它们不生效),升版处置出来', await p.locator('[data-field="metrics"]').count() === 0
&& await p.locator('[data-field="caliber"]').count() === 0 && await p.locator('[data-field="upgrade_policy"]').count() === 1)
const wfOpts = await from.locator('select').first().locator('option').allInnerTexts()
check('工作流下拉复用目录,不列自己', wfOpts.some((o) => o.includes('销售周报')) && !wfOpts.some((o) => o.includes('配置项检查')), wfOpts.join('|'))
await from.locator('select').first().selectOption('fx-weekly')
await p.waitForSelector('[data-field="caliber_from"] select[id$="-version"] option[value="5"]', { timeout: 4000 }).catch(() => {})
await from.locator('select[id$="-version"]').selectOption('5')
await p.waitForSelector('[data-field="caliber_from"] [data-caliber-preview]', { timeout: 4000 }).catch(() => {})
const c = await cfg('caliber')
check('选了工作流和版本:那一版只有一张口径卡时直接钉上,写成 {workflow_id, workflow_version(数), node_id}',
JSON.stringify(c?.caliber_from) === JSON.stringify({ workflow_id: 'fx-weekly', workflow_version: 5, node_id: 'caliber' }), JSON.stringify(c?.caliber_from))
check('……本地的 metrics、caliber 不留在配置里', !('metrics' in (c ?? {})) && !('caliber' in (c ?? {})), JSON.stringify(Object.keys(c ?? {})))
const preview = await from.locator('[data-caliber-preview]').innerText().catch(() => '')
check('……预览钉住的那张卡:口径、版本、几个指标', preview.includes('周报口径') && preview.includes('v3') && preview.includes('2 个指标')
&& preview.includes('aov'), preview.replace(/\s+/g, ' '))
const newer = await from.locator('[data-caliber-newer]').innerText().catch(() => '')
check('上游已有更新的版本:提醒正式运行前要声明升版处置', newer.includes('上游已有 v6') && newer.includes('升版处置'), newer)
const pol = await p.locator('[data-field="upgrade_policy"] option').allInnerTexts()
check('升版处置三选一,和子工作流同一套说法', pol.length === 4 && pol[0].includes('挡住正式运行') && pol.some((o) => o.includes('并排双印新旧口径')), pol.join('|'))
await p.locator('[data-field="upgrade_policy"] select').selectOption('dual')
check('……写进 config.upgrade_policy', (await cfg('caliber'))?.upgrade_policy === 'dual')
const card = await p.locator('.react-flow__node[data-id="caliber"]').innerText().catch(() => '')
check('画布卡片写「钉住上游口径卡 v5」,不再写「0 个指标」', card.includes('钉住上游口径卡 v5') && !card.includes('0 个指标'), card.replace(/\s+/g, ' ').slice(0, 120))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(200)
await p.screenshot({ path: `${SHOTS}/evidence-config-caliber-${theme}.png` })
}
}
await from.getByRole('radio', { name: '在这里定义' }).click()
await p.waitForTimeout(150)
const back = await cfg('caliber')
check('切回「在这里定义」:去掉 caliber_from 和 upgrade_policy,给回一条空指标', !('caliber_from' in back) && !('upgrade_policy' in back)
&& Array.isArray(back.metrics) && back.metrics.length === 1 && await p.locator('[data-field="metrics"]').count() === 1, JSON.stringify(back))
// 子工作流钉住版本后的升版处置(E2W-7):和口径卡的是同一个下拉、同一套文案
await p.evaluate(() => window.__studio.getState().select('sub'))
await p.waitForTimeout(150)
check('子工作流没钉版本(跟随最新):不出升版处置', await p.locator('[data-field="upgrade_policy"]').count() === 0)
const subVersion = p.locator('[data-field="workflow_id"] select[id$="-version"]')
await p.waitForSelector('[data-field="workflow_id"] select[id$="-version"] option[value="5"]', { timeout: 4000 }).catch(() => {})
await subVersion.selectOption('5')
await p.waitForSelector('[data-field="upgrade_policy"]', { timeout: 3000 }).catch(() => {})
const subPol = await p.locator('[data-field="upgrade_policy"] option').allInnerTexts()
check('钉了 v5:升版处置出来,选项和口径卡的那个一字不差', subPol.length === 4 && JSON.stringify(subPol) === JSON.stringify(pol), subPol.join('|'))
check('……紧跟在「工作流」后面', await p.evaluate(() => {
const fields = [...document.querySelectorAll('[data-field]')].map((el) => el.getAttribute('data-field'))
return fields[fields.indexOf('workflow_id') + 1] === 'upgrade_policy'
}))
const subHelp = await p.locator('[data-field="upgrade_policy"]').innerText()
check('……说明换成子工作流这一侧:和钉住别处的口径卡同一套规则', subHelp.includes('和钉住别处的口径卡是同一套规则')
&& !subHelp.includes('和子工作流的升版处置'), subHelp.replace(/\s+/g, ' ').slice(0, 120))
const subNewer = await p.locator('[data-field="workflow_id"] [data-subgraph-newer]').innerText().catch(() => '')
check('上游已有 v6:提醒要声明升版处置,和口径卡同一句', subNewer === newer, subNewer)
await p.locator('[data-field="upgrade_policy"] select').selectOption('recompute')
check('……写进子工作流的 config.upgrade_policy', (await cfg('sub'))?.upgrade_policy === 'recompute')
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await p.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await p.waitForTimeout(200)
await p.screenshot({ path: `${SHOTS}/evidence-config-subgraph-${theme}.png` })
}
}
await subVersion.selectOption('')
await p.waitForTimeout(150)
const unpinned = await cfg('sub')
check('改回跟随最新:upgrade_policy 一起拿掉、下拉收起', !('upgrade_policy' in unpinned) && !('workflow_version' in unpinned)
&& await p.locator('[data-field="upgrade_policy"]').count() === 0, JSON.stringify(unpinned))
check('没有运行时报错(配置项)', errors.length === 0, errors.join(' | '))
await ctx.close()
})
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
+104
View File
@@ -915,6 +915,110 @@ await section('数据 · 数据库', async () => {
await close()
})
await section('数据 · 遮罩列(证据面板的 mask_columns)', async () => {
// 假库:两张表,phone 已经遮上了。挑列从已探查的结构里读;写请求全部在浏览器里接住
const base = {
id: 'check-manage-mask', name: 'zz_mask', kind: 'postgres', host: '10.0.0.7', port: null, database: 'shop',
username: 'reader', options: { schema: 'sales', mask_columns: ['phone'] }, readonly: true, description: '检查脚本的假库',
enabled: true, password_masked: '', has_password: false, table_count: 2, schema_synced_at: ago(3600_000), cached_schema: 'sales',
schema_error: '', available_schemas: [], tools: ['db_query__zz_mask'],
last_checked_at: null, last_check_ok: null, last_latency_ms: null, last_error: null,
}
const upload = {
...base, id: 'check-manage-upmask', name: 'zz_upload_mask', kind: 'sqlite', host: null, username: null,
database: '/tmp/agentlab/uploads/tables/zz_upload_mask.db', options: { query_timeout_s: '30' }, table_count: 1,
tools: ['db_query__zz_upload_mask'],
}
let cur = base
let up = upload
const columns = {
'sales.orders': ['order_id', 'amount', 'phone'],
'sales.customers': ['id', 'customer_id', 'email', 'phone'],
orders_upload: ['order_id', 'phone'],
}
const tableJson = (t) => ({ table: t, found: true, kind: 'table', comment: null, detail: '',
columns: (columns[t] ?? []).map((name) => ({ name, type: 'TEXT' })) })
const { page, sent, errors, natives, close } = await open('/data/databases', {
handlers: [
[/^GET \/datasources$/, (route) => json([...sources, cur, up])(route)],
[/^GET \/datasources\/check-manage-mask\/schema$/, (route, { url }) => json(url.searchParams.get('table')
? tableJson(url.searchParams.get('table')) : { tables: ['sales.orders', 'sales.customers'], summary: '', synced_at: ago(0) })(route)],
[/^GET \/datasources\/check-manage-upmask\/schema$/, (route, { url }) => json(url.searchParams.get('table')
? tableJson(url.searchParams.get('table')) : { tables: ['orders_upload'], summary: '', synced_at: ago(0) })(route)],
[/^POST \/datasources\/check-manage-mask\/test$/, delayed(200, { ok: true, elapsed_ms: 42, url: 'x' })],
[/^PATCH \/datasources\/check-manage-mask$/, (route, { body }) => { cur = { ...cur, ...body }; return json(cur)(route) }],
[/^PATCH \/datasources\/check-manage-upmask$/, (route, { body }) => { up = { ...up, ...body }; return json(up)(route) }],
],
})
const card = page.locator(`[data-source="${base.name}"]`)
check('卡片上写着遮罩了几列', (await card.locator('[data-mask-columns]').innerText().catch(() => '')).includes('遮罩 1 列')
&& await card.locator('[data-mask-columns]').getAttribute('data-mask-columns') === 'phone')
await card.getByRole('button', { name: /测连接/ }).click()
await until(() => card.locator('[data-health]').first().innerText().then((t) => /已连通/.test(t)), 5000)
await card.getByRole('button', { name: '编辑' }).click()
const ed = dialog(page)
const field = ed.locator('[data-field="mask_columns"]')
check('编辑框里有「遮罩的列」,已遮的 phone 是一个标签', await field.count() === 1
&& await field.locator('[data-mask-chip="phone"]').count() === 1)
const boundary = await field.locator('[data-mask-boundary]').innerText().catch(() => '')
check('写明遮罩只减少暴露、不是安全边界', boundary.includes('遮罩只减少暴露,不是安全边界'), boundary)
check('高级连接参数里不再另摆一个遮罩的文本框(只有这一处)', await ed.locator('#ds-adv-mask_columns').count() === 0)
const before = sent.filter((r) => r.key === 'GET /datasources/check-manage-mask/schema').length
await ed.locator('#ds-mask-columns').focus()
const listed = await until(async () => {
const v = await ed.locator('#ds-mask-columns-list option').evaluateAll((els) => els.map((e) => e.value))
return v.length ? v : null
}, 5000)
const reads = sent.filter((r) => r.key === 'GET /datasources/check-manage-mask/schema').length - before
check('聚焦输入框才去读已探查的结构,候选是各表的列(已遮的 phone 不再列)', !!listed && listed.includes('email')
&& listed.includes('amount') && !listed.includes('phone') && reads === 3, `${JSON.stringify(listed)} · ${reads} 个请求`)
const hint = await field.innerText()
check('……说清是从已探查的几张表里挑的', hint.includes('从已探查的 2 张表里挑'), hint.replace(/\s+/g, ' ').slice(0, 160))
await ed.locator('#ds-mask-columns').fill('email')
await ed.locator('#ds-mask-columns').press('Enter')
await ed.locator('#ds-mask-columns').pressSequentially('id_card')
check('逐字敲 id_card 途中碰上列名 id 不会提前加成标签', await field.locator('[data-mask-chip="id"]').count() === 0
&& (await ed.locator('#ds-mask-columns').inputValue()) === 'id_card')
await ed.locator('#ds-mask-columns').press('Enter')
await ed.locator('#ds-mask-columns').fill('EMAIL')
await ed.locator('#ds-mask-columns').press('Enter')
await field.getByRole('button', { name: '不再遮罩 phone' }).click()
const chips = await field.locator('[data-mask-chip]').evaluateAll((els) => els.map((e) => e.getAttribute('data-mask-chip')))
check('回车加标签、× 去掉、大小写不同的同名列不重复加', chips.join(',') === 'email,id_card', chips.join(','))
if (SHOTS) await shot(page, 'datasource-mask-columns')
await ed.getByRole('button', { name: '保存' }).click()
await page.waitForTimeout(500)
const saved = sent.filter((r) => r.key === 'PATCH /datasources/check-manage-mask').at(-1)
check('保存:options.mask_columns 是列名列表,schema 原样带上', JSON.stringify(saved?.body?.options?.mask_columns) === '["email","id_card"]'
&& saved?.body?.options?.schema === 'sales', JSON.stringify(saved?.body?.options ?? {}))
check('……改遮罩列不算改连接:刚测的结果还作数', /已连通/.test(await card.locator('[data-health]').first().innerText()))
check('……卡片跟着变成遮罩 2 列', (await card.locator('[data-mask-columns]').innerText().catch(() => '')).includes('遮罩 2 列'))
await card.getByRole('button', { name: '编辑' }).click()
await dialog(page).locator('[data-field="mask_columns"]').getByRole('button', { name: '不再遮罩 email' }).click()
await dialog(page).locator('[data-field="mask_columns"]').getByRole('button', { name: '不再遮罩 id_card' }).click()
await dialog(page).getByRole('button', { name: '保存' }).click()
await page.waitForTimeout(400)
const cleared = sent.filter((r) => r.key === 'PATCH /datasources/check-manage-mask').at(-1)
check('全部去掉:options 里不留 mask_columns 这个键', !!cleared && !('mask_columns' in (cleared.body?.options ?? {}))
&& await card.locator('[data-mask-columns]').count() === 0, JSON.stringify(cleared?.body?.options ?? {}))
await goto(page, '/data/tables')
const uc = page.locator(`[data-source="${upload.name}"]`)
await uc.locator('[data-edit-mask]').click()
const ud = dialog(page)
await ud.locator('#ds-mask-columns').fill('phone')
await ud.locator('#ds-mask-columns').press('Enter')
await ud.getByRole('button', { name: '保存' }).click()
await page.waitForTimeout(400)
const upSaved = sent.filter((r) => r.key === 'PATCH /datasources/check-manage-upmask').at(-1)
check('传上来的表没有编辑框:卡片上「设遮罩列」单独改,其余 options 原样带上',
JSON.stringify(upSaved?.body) === JSON.stringify({ options: { query_timeout_s: '30', mask_columns: ['phone'] } }), JSON.stringify(upSaved?.body ?? {}))
check('……卡片写着遮罩 1 列', (await uc.locator('[data-mask-columns]').innerText().catch(() => '')).includes('遮罩 1 列'))
check('没有原生对话框', natives.length === 0, natives.join(' | '))
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('数据 · 表格', async () => {
const fake = {
source: {
+825
View File
@@ -0,0 +1,825 @@
// 发布前检查与自动修复的检查:发布弹窗打开就列问题、逐条修(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)
+22
View File
@@ -498,6 +498,28 @@ await section('失败:指到节点、原因、下一步', async () => {
check('跑的是这次运行时的图快照', Array.isArray(startBody?.graph?.nodes) && startBody.graph.nodes.length > 0 && !startBody.workflow_id)
fakes.delete('POST /api/runs')
// 补上之后发起被拒:绑定的工具在本机不存在(422 run_tool_missing)。和画布上发起被拒同一套,
// 报错里给直达入口(数据源工具去数据页),不是只有一句 toast
await page.goto(`${WEB}/runs/${missingInput.id}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-banner=failed] [data-action=rerun]').waitFor()
fakes.set('POST /api/runs', () => ({ status: 422, json: {
detail: '绑定的工具在本机不存在:「查数」(调用工具)绑的 db_query__nope。去数据页接入,或在节点里重新选', code: 'run_tool_missing' } }))
await page.locator('[data-run-banner=failed] [data-action=rerun]').click()
const refused = page.getByRole('dialog')
await refused.waitFor()
await refused.locator('input, textarea').first().fill('检查用的主题')
await refused.getByRole('button', { name: '重新运行' }).click()
const toData = page.getByRole('button', { name: '去数据页接入' })
await toData.waitFor({ timeout: 5000 }).catch(() => {})
check('补上重新运行被拒(工具不在本机):报错里有「去数据页接入」', await toData.count() === 1)
const said = await page.getByText('绑定的工具「db_query__nope」在本机不存在').first().innerText().catch(() => '')
check('……点名那个工具,说清这次运行没有发起', said.includes('db_query__nope') && said.includes('没有发起'), said.slice(0, 120))
check('……人还在这条运行上', new URL(page.url()).pathname === `/runs/${missingInput.id}`, page.url())
await toData.click().catch(() => {})
await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 }).catch(() => {})
check('……点了去数据页', new URL(page.url()).pathname.startsWith('/data'), page.url())
fakes.delete('POST /api/runs')
// 正式运行缺输入:只能跑「当前」发布的版本。之后又发布过新版的话,带着原来
// 的版本号会被后端 409(「vN 不是当前发布版本」),所以不带,并在弹窗里说清跑哪一版
const published = (await getJson('/workflows')).find((w) => (w.published_version ?? 0) > 1)
+96
View File
@@ -245,6 +245,9 @@ async function open({ width = 1440, height = 900, path = '/studio/st-main', pref
}
return route.fallback()
})
// 发布前检查和自动修复是只读的 POST:这里按老后端(没有这两个接口)答,发布弹窗照原样。
// 这两个接口本身的行为在 check-publish 里查
await page.route(/\/api\/workflows\/st-[a-z]+\/(publish-check|autofix)$/, (route) => json(route, { detail: 'Not Found' }, 404))
await page.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (route) => {
const req = route.request()
const url = new URL(req.url())
@@ -2024,6 +2027,99 @@ await section('删掉眼前这张', async () => {
await solo.ctx.close()
})
// ================================================================ E2W
await section('发起就被拒(工具不存在)给修复入口;整形节点解析上游失败,入口指到上游(E2W-4/5)', async () => {
const { ctx, page, errors } = await open()
// lib/explain 的纯函数:按页面自己加载的那一份取
const x = await page.evaluate(async () => {
const m = await window.__appImport('/src/lib/explain.ts')
const e = (msg, code) => Object.assign(new Error(msg), code ? { code } : {})
return {
data: m.explainRunError('绑定的工具在本机不存在:「查数」(调用工具)绑的 db_query__nope;「团队」的成员「研究员」绑的 db_schema__nope。去数据页接入,或在节点里重新选'),
custom: m.explainRunError('绑定的工具在本机不存在:「查数」(Agent)绑的 lookup_order。去工具页接入,或在节点里重新选'),
mcp: m.explainRunError('绑定的工具在本机不存在:「查数」(Agent)绑的 mcp:shop/query。去工具页接入,或在节点里重新选'),
coded: m.explainStartError(e('发起不了:这几个工具不在了', 'run_tool_missing')),
other: m.explainStartError(e('那张工作流不在了', 'workflow_gone')),
up: m.explainRunError('上游「快速回答」输出的不是合法 JSON(第 12 列附近),常见原因是字符串里有没转义的英文引号;让模型交结构化数据请用 output_schema + cite_fields'),
pointed: m.explainRunError('模板渲染出来的不是合法 JSON(第 1 行第 20 列附近)。模板里 {{ vars.result }} 是模型写的文字,放进 JSON 要写成 {{ vars.result | json }}(外面不要再加引号)'),
empty: m.explainRunError('模板渲染出来的不是合法 JSON(第 1 行第 20 列附近)。模板里 {{ vars.x }} 取出来是空的,这里缺一个值:检查路径写对没有、前面的节点有没有产出'),
old: m.explainRunError('模板渲染出来的不是合法 JSON(第 1 行第 20 列附近)。检查模板里的引号、逗号,字符串值要用 | json 过滤器输出'),
}
})
check('工具不存在:数据源工具(db_query__ / db_schema__)指向数据页,不给接着跑',
x.data.fixTo === '/data' && x.data.fixLabel === '去数据页接入' && x.data.continuable === false, JSON.stringify(x.data).slice(0, 160))
check('……自定义工具指向工具页的自定义工具,一个工具时标题点名', x.custom.fixTo === '/tools/custom' && x.custom.fixLabel === '去工具页接入'
&& x.custom.continuable === false && x.custom.title === '绑定的工具「lookup_order」在本机不存在', `${x.custom.fixTo} ${x.custom.title}`)
check('……MCP 工具指向工具页的 MCP 接入', x.mcp.fixTo === '/tools/mcp', x.mcp.fixTo)
check('发起报错认机读码 run_tool_missing(话改了也认),别的码不认', x.coded?.continuable === false && x.coded?.fixTo === '/tools'
&& x.other === null, JSON.stringify({ coded: x.coded?.fixTo, other: x.other }))
check('上游写坏了 JSON:入口指到上游「快速回答」(fixNode),不指整形节点', x.up.fixNode === '快速回答' && x.up.fix === 'canvas' && !x.up.continuable, x.up.fixNode)
check('模板里 {{ 开头(后端给了准确改法):不再追加「改用 output_schema」', !x.pointed.action.includes('output_schema')
&& x.pointed.action.includes('| json') && !x.empty.action.includes('output_schema'), `${x.pointed.action} | ${x.empty.action}`)
check('……老的笼统说法照旧追加那一句', x.old.action.includes('output_schema + cite_fields'), x.old.action)
// 画布上发起:POST /runs 回 422 run_tool_missing,报错里给「去数据页接入」,点了就去
const DETAIL = '绑定的工具在本机不存在:「查询销量」(调用工具)绑的 db_query__nope。去数据页接入,或在节点里重新选'
await page.route(/\/api\/runs$/, (route) => (route.request().method() === 'POST'
? route.fulfill({ status: 422, contentType: 'application/json', body: JSON.stringify({ detail: DETAIL, code: 'run_tool_missing' }) })
: route.fallback()))
// 这张图有一处「还没选工具」会把运行按钮置灰:等校验落定后清掉,只看发起被拒这一步
await waitAnalysis(page)
await S(page, () => window.__studio.setState({ issues: [] }))
await page.waitForTimeout(150)
await page.locator('[data-run-control] button[aria-label="运行"]').click()
await page.locator('[role="dialog"][aria-label="探索运行"]').locator('textarea, input').first().fill('x')
await page.locator('[role="dialog"][aria-label="探索运行"] button.btn-primary').click()
const fixBtn = page.getByRole('button', { name: '去数据页接入' })
await fixBtn.waitFor({ timeout: 4000 }).catch(() => {})
check('发起被拒:报错里有「去数据页接入」', await fixBtn.count() === 1)
const toastText = await page.locator('text=绑定的工具「db_query__nope」在本机不存在').first().innerText().catch(() => '')
check('……标题点名那个工具,原因里说这次运行没有发起', toastText.includes('db_query__nope') && toastText.includes('没有发起'), toastText.slice(0, 120))
check('……没有「接着跑」', await page.getByRole('button', { name: '接着跑' }).count() === 0)
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}/studio-run-tool-missing-${theme}.png`, clip: { x: 300, y: 40, width: 900, height: 240 } })
}
}
await fixBtn.click().catch(() => {})
await page.waitForTimeout(400)
check('……点了去数据页', new URL(page.url()).pathname.startsWith('/data'), page.url())
await ctx.close()
// 整形节点解析上游的文字失败:轮次顶上的入口打开上游「快速回答」的设置,不是「汇总」
const run = await open()
const UP = '上游「快速回答」输出的不是合法 JSON(第 12 列附近),常见原因是字符串里有没转义的英文引号;让模型交结构化数据请用 output_schema + cite_fields'
await S(run.page, (msg) => {
const st = window.__studio
const t = Date.now() / 1000 - 5
st.setState({ run: { id: 'st-run-json', workflow_id: 'st-main', workflow_name: '__studio_check__', status: 'queued',
input: {}, output: {}, error: null, usage: {}, run_class: 'exploratory', version: null } })
// 真实的 node.started 带着节点名和类型:流里的步骤按名字认上游
const list = [['run.started', null, {}], ['node.started', 'answer', { label: '快速回答', node_type: 'llm' }],
['node.finished', 'answer', { duration_ms: 3 }], ['node.started', 'sum', { label: '汇总', node_type: 'transform' }],
['node.failed', 'sum', { error: msg, duration_ms: 2 }], ['run.failed', null, { error: msg, node_id: 'sum' }]]
list.forEach((e, i) => st.getState().applyEvent({ seq: i + 1, type: e[0], node_id: e[1], data: e[2], ts: t + i * 0.2 }))
}, UP)
const turnFix = run.page.locator('[data-turn-error] [data-fix="canvas"]')
await turnFix.waitFor({ timeout: 4000 }).catch(() => {})
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await run.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await run.page.waitForTimeout(200)
await run.page.locator('[data-turn-error]').first().screenshot({ path: `${SHOTS}/studio-upstream-json-fix-${theme}.png` }).catch(() => {})
}
}
const label = await turnFix.innerText().catch(() => '')
check('轮次报错的入口:打开上游「快速回答」的设置', label.includes('打开「快速回答」的设置'), label)
await turnFix.click().catch(() => {})
await run.page.waitForTimeout(300)
check('……点了选中上游节点(不是整形节点)', await S(run.page, () => window.__studio.getState().selectedId) === 'answer')
check('没有运行时报错', errors.length === 0 && run.errors.length === 0, [...errors, ...run.errors].slice(0, 2).join(' | '))
await run.ctx.close()
})
check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`)
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))