mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:01:59 +00:00
可点击证据第二期:取数链路打通、问数据的答案逐段可点;发布前自动修复
一、可点击证据第二期
取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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('、'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user