mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
38addf8f5d
接后端两个提交(docs/design/orchestration-round/00-总方案.md §2、§3): - canvas/nodeDefs:会被人驳回的节点多一个「驳回」出口(id rejected,和后端 REJECT_HANDLE 同一个,中性色):人工节点三种 模式、双盲复算一律给;沙箱代码只在审批策略是每次审批时给;调用工具选了工具、审批策略不是从不审批时给(数据源的查询 只读,只在明说每次审批时给)。出口跟着配置算:改成不会被驳回时,studio 原有的出口同步把连在驳回出口上的线收掉;补充输入 改成审批时普通出口的线跟到「批准」 - run/trace:node.finished 带 outcome=rejected 时这一步收成 rejected(exited,记下转到哪几步),下一次执行开始时清掉; 子工作流里的步骤不另算一路并行 - run/derive:接了驳回出口的关卡按出口路由(驳回出口 / 其余是普通出口,同 compiler._make_router);走了驳回出口的算 「送出了数据」,下游照常排队、跑完,普通出口那一侧未到达;退回修改时两条出口都算走过;withSubSteps 把子工作流里的 步骤排在子工作流那一步后面(泳道、航迹的节点列表) - run/decode:走了驳回出口的那一步状态 rejected、副标题写谁驳回、备注、转到哪一步;终态写「完成(途经驳回)」和途经的 那几步(run.finished 的 rejected_via),没有途经的一字不变;子工作流里的步骤名接上子工作流的名字 - run/useNodeView:回放时「走了驳回出口」只说被驳回的那一刻 - canvas/NodeCard、pages/runs/TracePane:走了驳回出口的节点卡写「被驳回,转到「X」」,航迹节点卡写出口和转到哪,关键时刻 多一条「「审批」被驳回,转到「X」」、结局写「运行完成(途经驳回)」 - run/EvidencePanel、types:输入来源写明经哪个子工作流的哪个出口字段;子工作流里的节点(「sub/…」,画布上没有)照证据接口 给的名字写(node_label、subgraph_label),悬停写 id;停在子工作流入口的写「子工作流的运行输入」 - lib/terms:REJECTED_TEXT 加出口、走出口那一步、途经驳回的说法;EVIDENCE_TEXT 加经子工作流的说法 检查脚本:check-decode(驳回出口的步骤、航迹、走过的边、跑到一半、批准走普通出口;子工作流步骤名)、check-run-states (reject-exit:卡片状态与剪影、槽里写转到哪、驳回边走过批准边没走、运行成功)、check-studio(各类节点的出口、能连能画、 改配置收掉驳回线、改模式线跟过去)、check-evidence(subgraph:输入来源写明经哪个子工作流、子图节点写名字)
1649 lines
109 KiB
JavaScript
1649 lines
109 KiB
JavaScript
// 节点卡运行态的检查:每种状态 × 三档缩放 × 亮暗两套主题,灌事件、量尺寸、数动画、截图。
|
||
//
|
||
// 不真跑模型:沙箱没有密钥,而且真跑撞不上"三个人同时在跑、循环跑到第 3 轮、
|
||
// 另一支停在审批上"这种同一瞬间。dev 构建把 store 挂在 window.__studio 上,这里
|
||
// 直接往 applyEvent 里灌事件——走的是页面上那一份航迹 / NodeCard,只有事件来源
|
||
// 是脚本。工作流、审批、校验这些接口全部用 page.route 伪造,不写任何库。
|
||
//
|
||
// 守的是这几件事:
|
||
// - 运行期间卡片高度不变(流式输出、协作矩阵、遥测都不许把卡片撑高,缩放换档也不许)
|
||
// - 等待、完成和各种终态没有常驻动画;系统关了动效时画布上一个动画都没有
|
||
// - 每种状态的剪影(StatusBadge 的 data-shape)互不相同:去掉颜色也认得出
|
||
// - 遥测槽、协作矩阵、循环进度、出具印章、去审批这些真的长出来了
|
||
// - 回放(replayAt)时卡片、矩阵、印章都回到那一刻,不拿终值冒充;排队、阻断
|
||
// 这些推导状态和循环容器「两轮之间」也按那一刻算
|
||
// - 协作矩阵的表头不和底部打架:并行的一轮交回一部分、全部交回等调度时都不说「串行」
|
||
// - 去审批浮层键盘可达;出口密的分支节点标签不压别人的线;往上拐的出口线不穿过自己的
|
||
// 标签(放不下就截短);精简档读数够大
|
||
// - 校验角标只在编辑态上卡:运行中、结果还挂着时不和状态抢,信号档不给 warning 反向放大
|
||
// - 收场要说人话:协作团队用完轮数(判失败 / 降档交付)写清「用完 N 轮未完成」、点出
|
||
// 一次都没被派到的成员,收尾判定不另算一轮;模型把工具调用写成文字的失败写原因,
|
||
// 不是半句原话;工具超出后端声明的时限直说「已超出 Ns 上限」;校验靠修复才过要提一句
|
||
//
|
||
// 跑之前前后端都得起着(./scripts/dev.sh),默认连 5273 / 8000。对别的实例(比如一份
|
||
// 沙箱拷贝)跑时带上地址:
|
||
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-run-states.mjs
|
||
// 截图落在 RUN_STATES_SHOTS(默认 /tmp/agentlab-run-states)。
|
||
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 API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
|
||
const CHROME = process.env.CHROME_PATH
|
||
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
const SHOTS = process.env.RUN_STATES_SHOTS ?? '/tmp/agentlab-run-states'
|
||
// 截图的像素密度:要看清卡片里 11px 的字时设成 2
|
||
const DSF = Number(process.env.RUN_STATES_DSF ?? 1) || 1
|
||
mkdirSync(SHOTS, { recursive: true })
|
||
// 只跑其中几段(逗号分隔:themes, interact, team, endings, replay, history, issues, exits, rejected, reject-exit, reduced),调样式时省时间。
|
||
// RUN_STATES_SKIP 反过来,跳过这几段:check-all 把这个检查拆成几份分到不同的道上,最后一份用它
|
||
// 兜住「其余全部」,以后新加的段不用改 check-all 也跑得到
|
||
const ONLY = (process.env.RUN_STATES_ONLY ?? '').split(',').filter(Boolean)
|
||
const SKIP = (process.env.RUN_STATES_SKIP ?? '').split(',').filter(Boolean)
|
||
const want = (part) => (!ONLY.length || ONLY.includes(part)) && !SKIP.includes(part)
|
||
/**
|
||
* 一段一段地跑:某一段里等待超时、元素找不到,只记成这一段失败,关掉它开的浏览器,
|
||
* 接着跑下一段——不让一处卡住把后面几百项一起吞掉。段标题在各段自己里面打
|
||
*/
|
||
const opened = new Set()
|
||
async function section(part, fn) {
|
||
if (!want(part)) return
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`「${part}」这一段中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
} finally {
|
||
for (const b of opened) await b.close().catch(() => {})
|
||
opened.clear()
|
||
}
|
||
}
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 图
|
||
|
||
const agent = (name, description) => ({ name, description, system: name, tools: [] })
|
||
|
||
const GRAPH = {
|
||
nodes: [
|
||
{ id: 'in', type: 'input', data: { label: '工单参数', config: { fields: [{ name: 'line' }, { name: 'week' }] } } },
|
||
{ id: 'retr', type: 'retrieve', data: { label: '检索停机记录', config: { collection: 'mes_docs', limit: 20 } } },
|
||
{ id: 'sql', type: 'tool', data: { label: 'MES 取数', config: { tool: 'db_query' } } },
|
||
{ id: 'skip', type: 'agent', data: { label: '背景检索', config: { prompt: '补充行业背景', skip_if: 'vars.cached' } } },
|
||
{ id: 'team', type: 'supervisor', data: { label: '根因分析团队', config: {
|
||
goal: '定位停机根因', max_rounds: 4, max_parallel: 3,
|
||
agents: [agent('数据员', '拉明细'), agent('工艺员', '对工艺参数'), agent('审校', '核对数字')],
|
||
} } },
|
||
{ id: 'join', type: 'transform', data: { label: '证据汇总', config: { expression: 'merge(retr, skip)' } } },
|
||
{ id: 'loop', type: 'loop', data: { label: '逐线复核', config: { mode: 'foreach', items: 'vars.lines', max_iterations: 10 } } },
|
||
{ id: 'body', type: 'llm', data: { label: '单线结论', config: { prompt: '{{ item }} 的停机归因' } } },
|
||
{ id: 'gate', type: 'branch', data: { label: '质量门', config: { mode: 'expression', cases: [
|
||
{ key: 'pass', condition: 'vars.ok', label: '通过' },
|
||
{ key: 'fail', condition: 'not vars.ok', label: '不通过' },
|
||
] } } },
|
||
{ id: 'human', type: 'human', data: { label: '班长审批', config: { mode: 'approve', title: '签发前人工确认' } } },
|
||
{ id: 'out', type: 'output', data: { label: '周报出具', config: { contract: true, fields: [{ name: '停机周报' }] } } },
|
||
{ id: 'fix', type: 'output', data: { label: '补数工单', config: { fields: [{ name: '缺口清单' }] } } },
|
||
],
|
||
edges: [
|
||
{ source: 'in', target: 'retr' }, { source: 'in', target: 'sql' }, { source: 'in', target: 'skip' },
|
||
{ source: 'retr', target: 'team' }, { source: 'retr', target: 'join' }, { source: 'skip', target: 'join' },
|
||
{ source: 'sql', target: 'loop' },
|
||
{ source: 'loop', target: 'body', sourceHandle: 'body' }, { source: 'body', target: 'loop' },
|
||
{ source: 'loop', target: 'gate', sourceHandle: 'done' },
|
||
{ source: 'team', target: 'gate' }, { source: 'join', target: 'gate' },
|
||
{ source: 'gate', target: 'human', sourceHandle: 'pass' }, { source: 'gate', target: 'fix', sourceHandle: 'fail' },
|
||
{ source: 'human', target: 'out', sourceHandle: 'approved' }, { source: 'human', target: 'fix', sourceHandle: 'rejected' },
|
||
],
|
||
}
|
||
const NODE_IDS = GRAPH.nodes.map((n) => n.id)
|
||
const WF_ID = 'wcard-run-states'
|
||
const RUN_ID = 'wcard-run-0001'
|
||
|
||
// ---------------------------------------------------------------- 事件
|
||
//
|
||
// 事件的 ts 要贴着"现在":store 用实时事件里最小的 (本机时间 − ts) 估服务端时钟
|
||
// 偏差,ts 若整体落在一分钟前,计时器会被当成时钟偏差吃掉、显示 00:00。所以每个
|
||
// 场景最后一条事件的 ts 取灌入那一刻,前面的按间隔往回推。
|
||
|
||
function script(steps) {
|
||
// steps: [dtSeconds, type, node_id, data],dt 是距上一条的间隔
|
||
const total = steps.reduce((acc, s) => acc + s[0], 0)
|
||
return (now) => {
|
||
let t = now / 1000 - total
|
||
return steps.map(([dt, type, node_id, data = {}], i) => {
|
||
t += dt
|
||
return { seq: i + 1, type, node_id, data, ts: t }
|
||
})
|
||
}
|
||
}
|
||
|
||
const loopRound = (k, bodyMs, withTokens = false) => [
|
||
[0.1, 'node.started', 'loop', {}],
|
||
[0.05, 'edge.taken', 'loop', { branch: 'body', iteration: k - 1, total: 5, mode: 'foreach' }],
|
||
[0.05, 'node.finished', 'loop', { duration_ms: 3, preview: { __decision__: 'body' } }],
|
||
[0.1, 'node.started', 'body', { iteration: k }],
|
||
...(withTokens ? [] : [
|
||
[bodyMs / 1000, 'llm.end', 'body', { model: 'claude-sonnet', input_tokens: 820, output_tokens: 240, cost_usd: 0.002 }],
|
||
[0.02, 'node.finished', 'body', { duration_ms: bodyMs, preview: { text: '换模具等待' } }],
|
||
]),
|
||
]
|
||
|
||
const HEAD = [
|
||
[0, 'run.started', null, { nodes: NODE_IDS.length }],
|
||
[0.2, 'node.started', 'in', {}],
|
||
[0.1, 'node.finished', 'in', { duration_ms: 4, preview: { line: 'L3' } }],
|
||
[0.05, 'node.skipped', 'skip', { reason: '满足跳过条件:vars.cached' }],
|
||
[0.05, 'node.started', 'retr', {}],
|
||
[0.02, 'node.started', 'sql', {}],
|
||
[0.05, 'tool.start', 'sql', { tool: 'db_query', args: { sql: 'select …' } }],
|
||
[1.8, 'tool.end', 'sql', { tool: 'db_query', preview: '{"row_count": 128}' }],
|
||
[0.05, 'node.finished', 'sql', { duration_ms: 1920, preview: { rows: new Array(5).fill(0) } }],
|
||
[2.4, 'node.finished', 'retr', { duration_ms: 4300, preview: { hits: new Array(20).fill(0) } }],
|
||
]
|
||
|
||
const TEAM_ROUND0 = [
|
||
[0.1, 'node.started', 'team', {}],
|
||
[0.05, 'agent.route.start', 'team', { round: 0 }],
|
||
[3.2, 'agent.route.end', 'team', { round: 0, duration_ms: 3200, agents: ['数据员', '工艺员', '审校'], parallel: 3, done: false, reason: '三个方向互不依赖' }],
|
||
[0.02, 'log', 'team', { level: 'info', round: 0, agents: ['数据员', '工艺员', '审校'], done: false, parallel: 3, reason: '三个方向互不依赖', message: '调度 → 数据员、工艺员、审校' }],
|
||
[0.02, 'agent.step.start', 'team', { agent: '数据员', instruction: '拉 L3 本周停机明细', round: 0, parallel: 3 }],
|
||
[0.02, 'agent.step.start', 'team', { agent: '工艺员', instruction: '对照换模工艺参数', round: 0, parallel: 3 }],
|
||
[0.02, 'agent.step.start', 'team', { agent: '审校', instruction: '核对口径', round: 0, parallel: 3 }],
|
||
[0.3, 'tool.start', 'team', { tool: 'db_query', agent: '数据员' }],
|
||
]
|
||
|
||
/** A · 进行中:并行、循环第 3 项、流式输出、排队、跳过、未运行 */
|
||
const LIVE = script([
|
||
...HEAD,
|
||
...TEAM_ROUND0,
|
||
[2.2, 'agent.step.end', 'team', { agent: '审校', duration_ms: 2500, round: 0, parallel: 3, preview: '口径一致' }],
|
||
...loopRound(1, 1400), ...loopRound(2, 1600), ...loopRound(3, 0, true),
|
||
[0.4, 'llm.token', 'body', { delta: 'L3 线本周停机 14 次,其中换模等待占 9 次,平均 18 分钟;' }],
|
||
[0.2, 'llm.token', 'body', { delta: '与上周相比换模时长上升 22%,主要集中在夜班……' }],
|
||
])
|
||
|
||
/** B · 等待审批:上游都完成,人工审批停着 */
|
||
const TEAM_DONE = [
|
||
[4.1, 'agent.step.end', 'team', { agent: '审校', duration_ms: 4100, round: 0, parallel: 3, preview: '口径一致' }],
|
||
[3.0, 'agent.step.end', 'team', { agent: '工艺员', duration_ms: 7100, round: 0, parallel: 3, preview: '换模参数偏高' }],
|
||
[1.9, 'agent.step.end', 'team', { agent: '数据员', duration_ms: 9000, round: 0, parallel: 3, preview: '128 行' }],
|
||
[0.05, 'tool.end', 'team', { tool: 'db_query', agent: '数据员', preview: 'ok' }],
|
||
[0.05, 'agent.route.start', 'team', { round: 1 }],
|
||
[2.1, 'agent.route.end', 'team', { round: 1, duration_ms: 2100, agents: [], parallel: 0, done: true, reason: '证据足够' }],
|
||
[0.02, 'log', 'team', { level: 'info', round: 1, agents: [], done: true, reason: '证据足够', message: '调度 → 结束协作' }],
|
||
[0.1, 'llm.end', 'team', { agent: '调度者', model: 'claude-sonnet', input_tokens: 5200, output_tokens: 900, cost_usd: 0.02 }],
|
||
[0.05, 'node.finished', 'team', { duration_ms: 17500, preview: { text: '根因:换模等待' } }],
|
||
]
|
||
const LOOP_DONE = [
|
||
...loopRound(1, 1400), ...loopRound(2, 1600), ...loopRound(3, 1500), ...loopRound(4, 1300), ...loopRound(5, 1700),
|
||
[0.1, 'node.started', 'loop', {}],
|
||
[0.05, 'edge.taken', 'loop', { branch: 'done', iteration: 5, total: 5, mode: 'foreach' }],
|
||
[0.05, 'node.finished', 'loop', { duration_ms: 2, preview: { __decision__: 'done' } }],
|
||
]
|
||
const TO_GATE = [
|
||
[0.1, 'node.started', 'join', {}],
|
||
[0.1, 'node.finished', 'join', { duration_ms: 12, preview: { merged: true } }],
|
||
[0.1, 'node.started', 'gate', {}],
|
||
[0.05, 'edge.taken', 'gate', { branch: 'pass', reason: '命中条件:vars.ok' }],
|
||
[0.05, 'node.finished', 'gate', { duration_ms: 3, preview: { __decision__: 'pass' } }],
|
||
[0.1, 'node.started', 'human', {}],
|
||
[0.05, 'human.requested', 'human', { node_id: 'human', title: '签发前人工确认', mode: 'approve' }],
|
||
[0.05, 'run.interrupted', 'human', { payload: { node_id: 'human', title: '签发前人工确认' } }],
|
||
]
|
||
const WAITING = script([...HEAD, ...TEAM_ROUND0, ...TEAM_DONE, ...LOOP_DONE, ...TO_GATE, [134, 'log', null, { level: 'info', message: '心跳' }]])
|
||
|
||
/** C · 失败:团队里一个成员报错,节点失败;并行的循环被收成取消,下游阻断 */
|
||
const FAILED = script([
|
||
...HEAD, ...TEAM_ROUND0,
|
||
[2.2, 'agent.step.end', 'team', { agent: '审校', duration_ms: 2500, round: 0, parallel: 3, preview: '口径一致' }],
|
||
...loopRound(1, 1400), ...loopRound(2, 0, true),
|
||
[0.6, 'log', 'team', { level: 'warn', code: 'node_retry', message: '重试' }],
|
||
[4.0, 'agent.step.end', 'team', { agent: '工艺员', duration_ms: 7200, round: 0, parallel: 3, error: 'timeout', preview: '' }],
|
||
[0.1, 'node.failed', 'team', { duration_ms: 12400, error: '工艺员 30 秒没有回应,模型调用超时。可调大超时后从此处继续运行' }],
|
||
[0.1, 'run.failed', null, { error: '「根因分析团队」失败', node_id: 'team', timing: { wall_ms: 16000, active_ms: 16000, wait_ms: 0 } }],
|
||
])
|
||
|
||
/** D · 成功:全程完成,没走的一侧未到达,成果节点落出具印章 */
|
||
const SUCCEEDED = script([
|
||
...HEAD, ...TEAM_ROUND0, ...TEAM_DONE, ...LOOP_DONE, ...TO_GATE,
|
||
[3, 'human.resolved', 'human', { approved: true, actor: '李工' }],
|
||
[0.05, 'run.resumed', null, { actor: '李工' }],
|
||
[0.05, 'node.started', 'human', { resumed: true }],
|
||
[0.05, 'edge.taken', 'human', { branch: 'approved' }],
|
||
[0.05, 'node.finished', 'human', { duration_ms: 4, preview: { __decision__: 'approved' } }],
|
||
[0.1, 'node.started', 'out', {}],
|
||
[0.1, 'issuance', 'out', { tier: 'formal', missing_required: [], missing_expected: [], unmatched: 0, metrics_checked: 3, matched_numbers: 7 }],
|
||
[0.05, 'node.finished', 'out', { duration_ms: 310, preview: { 停机周报: '…' } }],
|
||
[0.05, 'run.finished', null, { output: {}, usage: { input_tokens: 21000, output_tokens: 3200, cost_usd: 0.05 }, duration_ms: 40000, timing: { wall_ms: 180000, active_ms: 40000, wait_ms: 140000 } }],
|
||
])
|
||
|
||
/** E · 取消 / 服务重启挂起 */
|
||
const CANCELLED = script([...HEAD, ...TEAM_ROUND0, ...loopRound(1, 1400), ...loopRound(2, 0, true),
|
||
[1.5, 'run.cancelled', null, { timing: { wall_ms: 12000, active_ms: 12000, wait_ms: 0 } }]])
|
||
const SUSPENDED = script([...HEAD, ...TEAM_ROUND0, ...loopRound(1, 1400), ...loopRound(2, 0, true),
|
||
[1.5, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务重启' }]])
|
||
|
||
// ---------------------------------------------------------------- 页面
|
||
|
||
async function layout() {
|
||
const res = await fetch(`${API}/copilot/layout`, {
|
||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ graph: GRAPH }),
|
||
})
|
||
if (!res.ok) throw new Error(`排版接口 ${res.status}`)
|
||
return res.json()
|
||
}
|
||
|
||
const LAID = await layout()
|
||
const WORKFLOW = {
|
||
id: WF_ID, name: '__run_states__', description: '', graph: LAID, tags: [], version: 1,
|
||
is_template: false, status: 'draft',
|
||
}
|
||
const APPROVAL = {
|
||
id: 'wcard-approval', run_id: RUN_ID, node_id: 'human', mode: 'approve', title: '签发前人工确认',
|
||
payload: { message: '本周 L3 停机周报:换模等待是主因,建议夜班加一名换模工。' }, status: 'pending', response: {},
|
||
created_at: new Date().toISOString(), workflow_name: '__run_states__', node_label: '班长审批', run_status: 'interrupted',
|
||
}
|
||
|
||
let issues = []
|
||
// 开发服务器热更新会把页面整个重载,或者只把 store 模块换掉(状态清空、页面
|
||
// 不导航),之后的断言全部落空。数一下,失败时好分清是卡片的问题还是别处恰好
|
||
// 在改代码
|
||
let reloads = 0
|
||
const lost = async (page) => {
|
||
const ok = await page.evaluate((id) => window.__studio?.getState().run?.id === id, RUN_ID).catch(() => false)
|
||
if (!ok) reloads++
|
||
return !ok
|
||
}
|
||
|
||
async function open({ theme = 'dark', reduced = false, workflow = WORKFLOW } = {}) {
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
opened.add(browser)
|
||
const ctx = await browser.newContext({
|
||
viewport: { width: 1440, height: 900 },
|
||
deviceScaleFactor: DSF,
|
||
colorScheme: theme,
|
||
reducedMotion: reduced ? 'reduce' : 'no-preference',
|
||
})
|
||
await ctx.addInitScript((t) => { try { localStorage.setItem('agentlab.theme', t) } catch { /* 无痕窗口 */ } }, theme)
|
||
const page = await ctx.newPage()
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
|
||
page.on('console', (m) => { if (m.type() === 'error' && !/404|Failed to load resource/.test(m.text())) errors.push(m.text()) })
|
||
|
||
// 主题:设置接口里存的偏好会在启动时覆盖本地的,这里把它改成要测的那一套
|
||
await page.route('**/api/settings', async (route) => {
|
||
if (route.request().method() !== 'GET') return route.continue()
|
||
const res = await route.fetch()
|
||
const body = await res.json().catch(() => ({}))
|
||
await route.fulfill({ response: res, json: { ...body, ui: { ...(body.ui ?? {}), theme } } })
|
||
})
|
||
await page.route(`**/api/workflows/${workflow.id}`, (route) => route.fulfill({ json: workflow }))
|
||
await page.route('**/api/workflows/validate', (route) => route.fulfill({ json: { ok: !issues.length, issues } }))
|
||
await page.route('**/api/workflows/variables', (route) => route.fulfill({ json: { variables: [], issues: [] } }))
|
||
await page.route('**/api/conversations**', (route) => route.fulfill({
|
||
json: route.request().method() === 'GET' ? [] : { id: 'wcard-conv', kind: 'canvas', title: '', turns: [] },
|
||
}))
|
||
await page.route('**/api/approvals**', (route) => route.fulfill({ json: [APPROVAL] }))
|
||
|
||
await page.goto(`${WEB}/studio/${workflow.id}`, { waitUntil: 'networkidle' })
|
||
page.on('framenavigated', (f) => { if (f === page.mainFrame()) reloads++ })
|
||
await page.waitForFunction((n) => document.querySelectorAll('.react-flow__node').length === n, workflow.graph.nodes.length)
|
||
await page.waitForTimeout(700)
|
||
await fit(page)
|
||
return { browser, page, errors }
|
||
}
|
||
|
||
async function fit(page) {
|
||
await page.locator('.react-flow__controls-fitview').click()
|
||
await page.waitForTimeout(350)
|
||
}
|
||
|
||
async function zoomOf(page) {
|
||
// 页面刚被热更新重载时画布还没挂上:等它回来,别在这里崩掉、把重载提示也吞了
|
||
await page.waitForSelector('.react-flow__viewport', { timeout: 15000 })
|
||
return page.evaluate(() => {
|
||
const m = getComputedStyle(document.querySelector('.react-flow__viewport')).transform
|
||
return m && m !== 'none' ? new DOMMatrix(m).a : 1
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 缩放到某一档。档位由画布(FlowCanvas)按缩放写在 .react-flow 上;还没写的话
|
||
* 这里按同一套阈值补上,好让卡片这一侧的检查不依赖画布那边的进度。
|
||
*/
|
||
async function setLod(page, lod) {
|
||
await fit(page)
|
||
const target = { full: 0.62, compact: 0.5, signal: 0.3 }[lod]
|
||
for (let i = 0; i < 12; i++) {
|
||
const z = await zoomOf(page)
|
||
if (lod === 'full' ? z >= 0.6 : lod === 'compact' ? z < 0.58 && z >= 0.37 : z < 0.33) break
|
||
await page.locator(z > target ? '.react-flow__controls-zoomout' : '.react-flow__controls-zoomin').click()
|
||
await page.waitForTimeout(220)
|
||
}
|
||
await park(page)
|
||
const z = await zoomOf(page)
|
||
const own = await page.evaluate(({ lod, z }) => {
|
||
const root = document.querySelector('.react-flow')
|
||
if (root.dataset.lod && root.dataset.lodBy !== 'check') return root.dataset.lod
|
||
root.dataset.lod = lod
|
||
root.dataset.lodBy = 'check'
|
||
root.style.setProperty('--zoom', String(z))
|
||
return null
|
||
}, { lod, z })
|
||
await page.waitForTimeout(150)
|
||
return { zoom: z, lod: own ?? lod, byCanvas: own != null }
|
||
}
|
||
|
||
/**
|
||
* 鼠标挪到画布顶上的空白处。点完左下角的缩放按钮,指针正停在时间轴上,
|
||
* 时间轴会把悬停的那一行当成「右栏悬停」,给某张卡挂上聚焦环
|
||
*/
|
||
async function park(page) {
|
||
const pane = await page.locator('.react-flow').boundingBox()
|
||
await page.mouse.move(pane.x + pane.width / 2, pane.y + 24)
|
||
}
|
||
|
||
/**
|
||
* 精简档的主读数。它只在 compact 档露出来,别的档是 visibility:hidden——innerText 这时取到
|
||
* 空串。LOD 切换、换数据后的重画都可能晚一拍:等它露出来、有字(给了 ready 就等 ready 成立),
|
||
* 最多 3 秒,到点就按当时读到的算(真回归照样判错,只是不再因为早读了一拍取到空文本)
|
||
*/
|
||
async function lodRead(page, id, ready = (t) => t !== '') {
|
||
const sel = `.react-flow__node[data-id="${id}"] .nc-lod-read`
|
||
const read = () => page.evaluate((q) => {
|
||
const el = document.querySelector(q)
|
||
return el && getComputedStyle(el).visibility === 'visible' ? el.innerText.trim() : ''
|
||
}, sel)
|
||
const end = Date.now() + 3000
|
||
for (;;) {
|
||
const text = await read().catch(() => '')
|
||
if (ready(text) || Date.now() > end) return text
|
||
await page.waitForTimeout(80)
|
||
}
|
||
}
|
||
|
||
async function feed(page, events, extra = {}) {
|
||
await page.evaluate(({ events, runId, extra }) => {
|
||
const st = window.__studio
|
||
st.getState().clearRun()
|
||
st.setState({
|
||
run: { id: runId, workflow_id: 'wcard-run-states', workflow_name: '__run_states__', status: 'running',
|
||
input: {}, output: extra.output ?? {}, error: null, usage: {}, run_class: extra.runClass ?? 'exploratory' },
|
||
runSnapshot: null,
|
||
})
|
||
for (const e of events) st.getState().applyEvent(extra.replay ? { ...e, replay: true } : e)
|
||
}, { events, runId: RUN_ID, extra })
|
||
}
|
||
|
||
/** 每张卡:状态、剪影、高度(offsetHeight 不受缩放影响) */
|
||
const cards = (page) => page.evaluate(() => Object.fromEntries(
|
||
[...document.querySelectorAll('.react-flow__node')].map((el) => {
|
||
const card = el.querySelector('.nc')
|
||
const badge = card?.querySelector('.nc-head [data-shape]')
|
||
return [el.dataset.id, {
|
||
state: card?.dataset.state,
|
||
shape: badge?.getAttribute('data-shape') ?? null,
|
||
h: card?.offsetHeight ?? 0,
|
||
text: card?.querySelector('.nc-tele')?.innerText ?? '',
|
||
}]
|
||
})))
|
||
|
||
/**
|
||
* 画布里还在跑的动画,按所在卡片的状态归类。getAnimations 会连已经播完、
|
||
* 停在最后一帧的也给出来,只算 playState 为 running 的
|
||
*/
|
||
const runningAnimations = (page, { transitions = true } = {}) => page.evaluate((transitions) => {
|
||
const out = {}
|
||
const names = new Set()
|
||
for (const a of document.getAnimations()) {
|
||
if (a.playState !== 'running') continue
|
||
if (!transitions && a instanceof CSSTransition) continue
|
||
const el = a.effect?.target
|
||
if (!el || !el.closest?.('.react-flow')) continue
|
||
const card = el.closest('.nc')
|
||
const key = card ? card.dataset.state : el.closest('.react-flow__edge') ? 'edge' : 'other'
|
||
out[key] = (out[key] ?? 0) + 1
|
||
if (card) names.add(a.animationName ?? a.transitionProperty)
|
||
}
|
||
return { ...out, names: [...names] }
|
||
}, transitions)
|
||
const countOf = (anim) => Object.entries(anim).reduce((acc, [k, v]) => (k === 'names' ? acc : acc + v), 0)
|
||
|
||
async function shot(page, name) {
|
||
const path = `${SHOTS}/${name}.png`
|
||
await page.locator('.react-flow').screenshot({ path })
|
||
return path
|
||
}
|
||
|
||
/** 只截几张卡(外扩一圈):看卡片里的字用,整张画布截图里它们太小 */
|
||
async function cardShot(page, ids, name) {
|
||
const boxes = (await Promise.all(ids.map((id) => page.locator(`.react-flow__node[data-id="${id}"]`).boundingBox())))
|
||
.filter(Boolean)
|
||
if (!boxes.length) return null
|
||
const x = Math.min(...boxes.map((b) => b.x)) - 16
|
||
const y = Math.min(...boxes.map((b) => b.y)) - 16
|
||
const w = Math.max(...boxes.map((b) => b.x + b.width)) + 16 - x
|
||
const h = Math.max(...boxes.map((b) => b.y + b.height)) + 16 - y
|
||
const path = `${SHOTS}/${name}.png`
|
||
await page.screenshot({ path, clip: { x: Math.max(0, x), y: Math.max(0, y), width: w, height: h } })
|
||
return path
|
||
}
|
||
|
||
const SCENARIOS = [
|
||
{ key: 'live', events: LIVE, expect: { team: 'running', loop: 'running', body: 'running', join: 'queued', skip: 'skipped', gate: 'idle', in: 'done' } },
|
||
{ key: 'waiting', events: WAITING, expect: { human: 'waiting', body: 'done', loop: 'done', team: 'done', out: 'idle' } },
|
||
{ key: 'failed', events: FAILED, expect: { team: 'failed', gate: 'blocked', out: 'blocked', body: 'cancelled' } },
|
||
{ key: 'succeeded', events: SUCCEEDED, expect: { out: 'done', fix: 'unreached', human: 'done' }, extra: { runClass: 'formal' } },
|
||
{ key: 'cancelled', events: CANCELLED, expect: { team: 'cancelled', body: 'cancelled' } },
|
||
{ key: 'suspended', events: SUSPENDED, expect: { team: 'suspended', body: 'suspended' } },
|
||
]
|
||
|
||
// ---------------------------------------------------------------- 亮暗两套
|
||
|
||
const shapesByState = {}
|
||
const shots = []
|
||
|
||
for (const theme of want('themes') ? ['dark', 'light'] : []) await section('themes', async () => {
|
||
console.log(`\n=== ${theme === 'dark' ? '暗色' : '亮色'}主题 ===`)
|
||
const { browser, page, errors } = await open({ theme })
|
||
|
||
await setLod(page, 'full')
|
||
const idle = await cards(page)
|
||
const baseH = Object.fromEntries(Object.entries(idle).map(([id, c]) => [id, c.h]))
|
||
check('编辑态每张卡都画出来了', Object.values(idle).every((c) => c.h > 60), JSON.stringify(baseH))
|
||
check('编辑态没有状态剪影(正常态安静)', Object.values(idle).every((c) => !c.shape))
|
||
|
||
// 校验标记:error 和 warning 都上卡
|
||
issues = [
|
||
{ level: 'error', node_id: 'body', message: '引用了不存在的变量 item.name' },
|
||
{ level: 'warning', node_id: 'join', message: '产出者排在后面,这里取到空值' },
|
||
]
|
||
await page.evaluate((iss) => window.__studio.setState({ issues: iss }), issues)
|
||
await page.waitForTimeout(150)
|
||
const marks = await page.evaluate(() => ({
|
||
error: document.querySelector('.react-flow__node[data-id="body"] .nc-issue .is-error')?.textContent,
|
||
warning: document.querySelector('.react-flow__node[data-id="join"] .nc-issue .is-warning')?.textContent,
|
||
slot: document.querySelector('.react-flow__node[data-id="join"] .nc-tele')?.innerText,
|
||
}))
|
||
check('error 角标上卡', !!marks.error, marks.error)
|
||
check('warning 角标同样上卡', !!marks.warning, marks.warning)
|
||
check('编辑态遥测槽写出问题原文', (marks.slot ?? '').includes('取到空值'), marks.slot)
|
||
shots.push(await shot(page, `${theme}-edit-issues`))
|
||
issues = []
|
||
await page.evaluate(() => window.__studio.setState({ issues: [] }))
|
||
|
||
for (const sc of SCENARIOS) {
|
||
await setLod(page, 'full')
|
||
await feed(page, sc.events(Date.now()), sc.extra)
|
||
await page.waitForTimeout(1300) // 等一次性的抖动、扫光播完
|
||
const now = await cards(page)
|
||
for (const [id, want] of Object.entries(sc.expect)) {
|
||
check(`${sc.key}: ${id} 是 ${want}`, now[id]?.state === want, now[id]?.state)
|
||
}
|
||
for (const c of Object.values(now)) {
|
||
if (c.state && c.shape) (shapesByState[c.state] ??= new Set()).add(c.shape)
|
||
}
|
||
const grew = Object.entries(now).filter(([id, c]) => c.h !== baseH[id])
|
||
check(`${sc.key}: 卡片高度和编辑态一致`, grew.length === 0,
|
||
grew.map(([id, c]) => `${id} ${baseH[id]}→${c.h}`).join(', '))
|
||
|
||
const anim = await runningAnimations(page)
|
||
delete anim.names
|
||
const idleStates = ['waiting', 'done', 'failed', 'skipped', 'cancelled', 'suspended', 'blocked', 'unreached', 'queued', 'idle']
|
||
const lingering = idleStates.filter((s) => anim[s]).map((s) => `${s}×${anim[s]}`)
|
||
check(`${sc.key}: 非运行中的卡片没有常驻动画`, lingering.length === 0, lingering.join(', ') || JSON.stringify(anim))
|
||
|
||
if (sc.key === 'live') {
|
||
const tele = now.body.text
|
||
check('流式输出替换摘要位', (await page.locator('.react-flow__node[data-id="body"] .nc-stream').innerText()).includes('夜班'))
|
||
check('运行中走着计时', /\d\d:\d\d/.test(tele), tele)
|
||
const t1 = await page.locator('.react-flow__node[data-id="body"] .nc-tele-main').innerText()
|
||
await page.waitForTimeout(600)
|
||
const t2 = await page.locator('.react-flow__node[data-id="body"] .nc-tele-main').innerText()
|
||
check('计时器在走', t1 !== t2, `${t1} → ${t2}`)
|
||
check('循环写出 3/5 与分段进度', now.loop.text.includes('3/5')
|
||
&& await page.locator('.react-flow__node[data-id="loop"] .nc-tele .nc-seg i').count() === 5, now.loop.text)
|
||
check('循环体写出执行次数 ×3', (await page.locator('.react-flow__node[data-id="body"] .nc-head').innerText()).includes('×3'))
|
||
const matrix = await page.locator('.react-flow__node[data-id="team"] .team-matrix').innerText()
|
||
check('矩阵有调度者一行', matrix.includes('调度者'), matrix.split('\n').slice(0, 3).join(' / '))
|
||
check('先交回的成员先变绿', await page.locator('.react-flow__node[data-id="team"] .team-row[data-member-status="done"]').count() === 1)
|
||
// 三人并行交回一个:仍是并行的一轮,人数按派出去的算,另说还有几个在跑
|
||
check('本轮并行中的说法', matrix.includes('本轮 3 名成员并行 · 2 名执行中') && !matrix.includes('串行'), matrix.split('\n')[0])
|
||
check('底部测量前写「—」', /—\s*$/.test(matrix.trim()), matrix.split('\n').slice(-1)[0])
|
||
check('跳过写出理由', now.skip.text.includes('满足跳过条件:vars.cached'), now.skip.text)
|
||
check('排队写出在等什么', now.join.text.includes('排队'), now.join.text)
|
||
check('运行中的卡片有光弧', await page.locator('.nc.node-running .fx-halo').count() > 0)
|
||
const halo = await page.evaluate(() => {
|
||
const el = document.querySelector('.nc.node-running .fx-halo')
|
||
return el ? getComputedStyle(el).backgroundImage : ''
|
||
})
|
||
const accent = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--accent').trim())
|
||
const rgb = await page.evaluate((c) => { const d = document.createElement('div'); d.style.color = c; document.body.append(d); const v = getComputedStyle(d).color; d.remove(); return v }, accent)
|
||
check('光弧用运行态色(不是类型色)', halo.includes(rgb.replace('rgb(', '').replace(')', '').split(', ').join(', ')), rgb)
|
||
}
|
||
if (sc.key === 'waiting') {
|
||
check('等待写出已等时长', /已等待\s*\d\d:\d\d/.test(now.human.text), now.human.text)
|
||
const btn = page.locator('.react-flow__node[data-id="human"] .nc-approve')
|
||
check('等待节点上有「去审批」', await btn.count() === 1)
|
||
// 药丸画出来 16px 高,按钮本身撑满遥测槽:药丸上沿、下沿再往外 2px 仍点得到。
|
||
// 先取景到这个节点再量(换档之后它可能在窗口外);也要先量再点:点的时候
|
||
// Playwright 会把按钮滚进视野,画布容器跟着挪,之后按钮可能出窗口
|
||
await page.evaluate(() => window.__studio.getState().focusNode('human'))
|
||
await page.waitForTimeout(700)
|
||
await park(page)
|
||
const reach = await btn.evaluate((el) => {
|
||
const k = el.getBoundingClientRect().height / el.offsetHeight
|
||
const r = el.getBoundingClientRect()
|
||
const x = r.left + r.width / 3
|
||
const hit = (y) => document.elementFromPoint(x, y)
|
||
const miss = [r.top + 0.5 * k, r.bottom - 0.5 * k].map(hit).filter((h) => h !== el)
|
||
return { h: el.offsetHeight, ok: el.offsetHeight >= 20 && !miss.length, miss: miss.map((h) => `${h?.tagName}.${h?.className}`).join(' ') }
|
||
})
|
||
check('去审批的点击区比画出来的药丸大', reach.ok, `按钮高 ${reach.h}px(药丸 16px)${reach.miss ? ` · 点到了 ${reach.miss}` : ''}`)
|
||
await btn.click()
|
||
await page.waitForTimeout(500)
|
||
const pop = page.locator('.nc-pop')
|
||
check('去审批就地打开审批卡', await pop.count() === 1 && (await pop.innerText()).includes('批准'),
|
||
(await pop.innerText().catch(() => '')).split('\n')[0])
|
||
check('点审批卡不会选中节点', await page.evaluate(() => window.__studio.getState().selectedId) == null)
|
||
check('画布上的审批卡不再重复工作流名', !(await pop.innerText()).includes('__run_states__'))
|
||
const aria = await btn.evaluate((el) => ({ pop: el.getAttribute('aria-haspopup'), ctl: el.getAttribute('aria-controls') }))
|
||
check('去审批声明弹出的是对话框', aria.pop === 'dialog' && !!aria.ctl
|
||
&& await page.evaluate((id) => !!document.getElementById(id)?.classList.contains('nc-pop'), aria.ctl), JSON.stringify(aria))
|
||
// 键盘:打开就进到浮层里,Tab 在里面转圈,走不到下一个节点上
|
||
const inside = () => page.evaluate(() => !!document.activeElement?.closest('.nc-pop'))
|
||
check('打开后焦点在审批卡里', await inside(), await page.evaluate(() => document.activeElement?.outerHTML.slice(0, 60)))
|
||
let stayed = true
|
||
for (let i = 0; i < 8; i++) { await page.keyboard.press('Tab'); stayed &&= await inside() }
|
||
await page.keyboard.press('Shift+Tab'); stayed &&= await inside()
|
||
check('Tab 在审批卡里转圈', stayed)
|
||
const hasButtons = await page.evaluate(() => ['批准', '驳回'].every((t) =>
|
||
[...document.querySelectorAll('.nc-pop button')].some((b) => b.textContent.includes(t))))
|
||
check('键盘够得着批准 / 驳回', hasButtons)
|
||
shots.push(await shot(page, `${theme}-waiting-approve`))
|
||
// 焦点在浮层的输入框里按 Esc:以前被浮层根的 stopPropagation 截走,收不起来
|
||
await page.locator('.nc-pop input').first().focus()
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(200)
|
||
check('焦点在浮层里时 Esc 收起审批卡', await pop.count() === 0)
|
||
check('收起后焦点回到「去审批」', await page.evaluate(() => document.activeElement?.classList.contains('nc-approve')))
|
||
// 焦点还在按钮上时的 Esc 走 document 那一路
|
||
await btn.click()
|
||
await page.waitForTimeout(300)
|
||
await btn.focus()
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(200)
|
||
check('焦点在按钮上时 Esc 也收起', await pop.count() === 0)
|
||
// 悬停联动(右栏悬停这一步)不能把等待的琥珀加粗盖掉
|
||
await page.evaluate(() => window.__studio.getState().setHoveredNode('human'))
|
||
await page.waitForTimeout(150)
|
||
const weight = await page.evaluate(() => {
|
||
const d = document.createElement('div'); d.style.color = 'var(--st-waiting)'; document.body.append(d)
|
||
const amber = getComputedStyle(d).color; d.remove()
|
||
return { amber, shadow: getComputedStyle(document.querySelector('.react-flow__node[data-id="human"] .nc')).boxShadow }
|
||
})
|
||
check('悬停不盖掉等待的琥珀加粗', weight.shadow.includes(weight.amber) && weight.shadow.split('rgb').length > 4,
|
||
weight.shadow.slice(0, 90))
|
||
await page.evaluate(() => window.__studio.getState().setHoveredNode(null))
|
||
}
|
||
if (sc.key === 'failed') {
|
||
check('失败写出错误摘要', now.team.text.includes('没有回应'), now.team.text)
|
||
check('阻断写出理由', now.gate.text.includes('上游失败'), now.gate.text)
|
||
check('重试次数上卡', (await page.locator('.react-flow__node[data-id="team"] .nc-head').innerText()).includes('↻1'))
|
||
}
|
||
if (sc.key === 'cancelled') {
|
||
check('取消的循环写出停在第几项', now.loop.text.includes('2/5')
|
||
&& await page.locator('.react-flow__node[data-id="loop"] .nc-seg i.is-cur').count() === 0, now.loop.text)
|
||
}
|
||
if (sc.key === 'succeeded') {
|
||
const stamp = page.locator('.react-flow__node[data-id="out"] .nc-stamp')
|
||
check('成果节点落出具印章', await stamp.count() === 1 && (await stamp.innerText()).includes('完整出具'),
|
||
await stamp.innerText().catch(() => ''))
|
||
check('没走的一侧未到达', now.fix.text.includes('本次未执行'), now.fix.text)
|
||
check('命中的出口亮起、落空的压暗',
|
||
await page.locator('.react-flow__node[data-id="gate"] .nc-exit.is-hit').count() === 1
|
||
&& await page.locator('.react-flow__node[data-id="gate"] .nc-exit.is-miss').count() >= 1)
|
||
}
|
||
|
||
for (const lod of ['full', 'compact', 'signal']) {
|
||
const got = await setLod(page, lod)
|
||
if (await lost(page)) console.log(` ! ${sc.key}/${lod}: 运行状态丢了(热更新?),这一档的结果不可信`)
|
||
const h = await cards(page)
|
||
const moved = Object.entries(h).filter(([id, c]) => c.h !== baseH[id])
|
||
check(`${sc.key}/${lod}: 换档不改卡片尺寸`, moved.length === 0,
|
||
moved.map(([id, c]) => `${id} ${baseH[id]}→${c.h}`).join(', ') || `zoom ${got.zoom.toFixed(2)}${got.byCanvas ? '' : '(档位由脚本补写)'}`)
|
||
if (lod === 'signal' && (sc.key === 'failed' || sc.key === 'waiting')) {
|
||
const pin = await page.evaluate(() => {
|
||
const el = document.querySelector('.nc-pin')
|
||
if (!el) return null
|
||
const r = el.getBoundingClientRect()
|
||
return { vis: getComputedStyle(el).visibility, font: parseFloat(getComputedStyle(el).fontSize), h: r.height }
|
||
})
|
||
check(`${sc.key}/signal: 异常节点挂出反向缩放的牌子`, !!pin && pin.vis === 'visible' && pin.h >= 12 && pin.h <= 26,
|
||
pin ? `屏幕高 ${pin.h.toFixed(1)}px` : '没有牌子')
|
||
}
|
||
if (lod === 'compact') {
|
||
const vis = await page.evaluate(() => getComputedStyle(document.querySelector('.nc-lod')).visibility)
|
||
check(`${sc.key}/compact: 精简卡替身可见`, vis === 'visible')
|
||
if (sc.key === 'live') {
|
||
const brief = await lodRead(page, 'team')
|
||
check('live/compact: 协作矩阵收成一行摘要(读数在前)', /^\d\/\d 执行中 · 第 1 轮/.test(brief), brief)
|
||
// 精简档的读数是这一档的重点:屏幕上不小于 9px,档内最低的 0.33 倍也一样
|
||
// (回滞让精简档一直留到 0.33)。--zoom 临时改成 0.33 量一遍再改回去
|
||
const fonts = await page.evaluate((z) => {
|
||
const root = document.querySelector('.react-flow')
|
||
const read = document.querySelector('.react-flow__node[data-id="body"] .nc-lod-read')
|
||
const title = document.querySelector('.react-flow__node[data-id="body"] .nc-lod-title')
|
||
const lod = read.closest('.nc-lod')
|
||
const size = (el) => parseFloat(getComputedStyle(el).fontSize)
|
||
const now = { read: size(read) * z, title: size(title) * z }
|
||
const was = root.style.getPropertyValue('--zoom')
|
||
root.style.setProperty('--zoom', '0.33')
|
||
const low = { read: size(read) * 0.33, title: size(title) * 0.33, fits: lod.scrollHeight <= lod.clientHeight + 1 }
|
||
root.style.setProperty('--zoom', was)
|
||
return { now, low }
|
||
}, got.zoom)
|
||
check('live/compact: 读数屏幕字号不小于 9px(当前缩放与 0.33 倍)',
|
||
fonts.now.read >= 9 && fonts.low.read >= 9 && fonts.low.title >= 11 && fonts.low.fits,
|
||
`当前 ${fonts.now.read.toFixed(1)} / 标题 ${fonts.now.title.toFixed(1)}px · 0.33 倍 ${fonts.low.read.toFixed(1)} / ${fonts.low.title.toFixed(1)}px${fonts.low.fits ? '' : ' · 撑出了卡片'}`)
|
||
const bodyRead = await lodRead(page, 'body')
|
||
check('live/compact: 读数不再重复状态字', /^\d\d:\d\d/.test(bodyRead), bodyRead)
|
||
}
|
||
}
|
||
if (sc.key === 'succeeded' && lod !== 'full') {
|
||
const stampH = await page.evaluate(() => document.querySelector('.react-flow__node[data-id="out"] .nc-stamp')?.getBoundingClientRect().height ?? 0)
|
||
check(`succeeded/${lod}: 印章反向缩放,远景也读得出档位`, stampH >= 14 && stampH <= 28, `${stampH.toFixed(1)}px`)
|
||
}
|
||
if (lod === 'signal') {
|
||
const glyph = await page.evaluate(() => {
|
||
const el = document.querySelector('.react-flow__node[data-id="in"] .nc-sig svg')
|
||
return el ? el.getBoundingClientRect().height : 0
|
||
})
|
||
check(`${sc.key}/signal: 居中剪影在屏幕上够大`, glyph >= 13 && glyph <= 24, `${glyph.toFixed(1)}px`)
|
||
}
|
||
shots.push(await shot(page, `${theme}-${sc.key}-${lod}`))
|
||
}
|
||
}
|
||
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 剪影
|
||
|
||
await section('themes', async () => {
|
||
console.log('\n=== 剪影 ===')
|
||
const pairs = Object.entries(shapesByState).map(([s, set]) => [s, [...set]])
|
||
const owner = {}
|
||
let clash = ''
|
||
for (const [state, shapes] of pairs) {
|
||
if (shapes.length !== 1) clash ||= `${state} 有 ${shapes.join('/')}`
|
||
for (const sh of shapes) {
|
||
if (owner[sh] && owner[sh] !== state) clash ||= `${owner[sh]} 和 ${state} 都是 ${sh}`
|
||
owner[sh] = state
|
||
}
|
||
}
|
||
const covered = Object.keys(shapesByState).sort()
|
||
const all = ['blocked', 'cancelled', 'done', 'failed', 'idle', 'queued', 'running', 'skipped', 'suspended', 'unreached', 'waiting']
|
||
check('每种状态都在卡片上出现过', all.every((s) => covered.includes(s)), covered.join(' '))
|
||
check('每种状态的剪影互不相同', !clash, clash || pairs.map(([s, sh]) => `${s}=${sh}`).join(' '))
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 历史灌入
|
||
|
||
await section('interact', async () => {
|
||
console.log('\n=== 选中、聚焦、Copilot 新节点、一次性时刻 ===')
|
||
const { browser, page, errors } = await open({ theme: 'dark' })
|
||
await setLod(page, 'full')
|
||
const cls = (id) => page.evaluate((id) => document.querySelector(`.react-flow__node[data-id="${id}"] .nc`).className, id)
|
||
|
||
await page.evaluate(() => window.__studio.getState().select('gate'))
|
||
await page.waitForTimeout(150)
|
||
check('选中态以 selectedId 为准', (await cls('gate')).includes('nc-selected'))
|
||
const ring = await page.evaluate(() => getComputedStyle(document.querySelector('.nc.nc-selected')).boxShadow)
|
||
check('选中用外圈(隔一圈底色),不是只换边框色', /,/.test(ring) && ring !== 'none', ring.slice(0, 60))
|
||
await page.evaluate(() => window.__studio.getState().select(null))
|
||
|
||
await page.evaluate(() => window.__studio.getState().setHoveredNode('body'))
|
||
await page.waitForTimeout(150)
|
||
check('右栏悬停:对应节点亮聚焦环', (await cls('body')).includes('nc-hover'))
|
||
check('右栏悬停:其余节点退下去', (await cls('gate')).includes('nc-dim'))
|
||
await page.evaluate(() => window.__studio.getState().setHoveredNode(null))
|
||
|
||
await page.evaluate(() => window.__studio.getState().focusNode('join'))
|
||
await page.waitForTimeout(80)
|
||
check('取景请求:落点节点荡一圈', await page.locator('.react-flow__node[data-id="join"] .fx-focus').count() === 1)
|
||
|
||
// Copilot 紫框:编辑态有,运行态一来就让位
|
||
await page.evaluate(() => window.__studio.setState({ copilotNew: ['body', 'gate'] }))
|
||
await page.waitForTimeout(100)
|
||
const purple = await page.evaluate(() => getComputedStyle(document.querySelector('.react-flow__node[data-id="gate"] .nc')).borderTopColor)
|
||
// 刚放上来的节点被选中:选中圈和紫色描边叠在一起,不是紫色把选中圈盖掉
|
||
await page.evaluate(() => window.__studio.getState().select('gate'))
|
||
await page.waitForTimeout(150)
|
||
const picked = await page.evaluate(() => {
|
||
const d = document.createElement('div'); d.style.color = 'var(--accent)'; document.body.append(d)
|
||
const accent = getComputedStyle(d).color; d.style.color = 'var(--copilot)'
|
||
const violet = getComputedStyle(d).color; d.remove()
|
||
const shadow = getComputedStyle(document.querySelector('.react-flow__node[data-id="gate"] .nc')).boxShadow
|
||
return { accent, violet, shadow }
|
||
})
|
||
check('Copilot 新节点选中后有选中圈', picked.shadow.includes(picked.accent), picked.shadow.slice(0, 90))
|
||
await page.evaluate(() => window.__studio.getState().select(null))
|
||
await feed(page, LIVE(Date.now()))
|
||
await page.evaluate(() => window.__studio.setState({ copilotNew: ['body', 'gate'] }))
|
||
await page.waitForTimeout(200)
|
||
const colors = await page.evaluate(() => {
|
||
const b = getComputedStyle(document.querySelector('.react-flow__node[data-id="body"] .nc')).borderTopColor
|
||
const d = document.createElement('div'); d.style.color = 'var(--st-running)'; document.body.append(d)
|
||
const run = getComputedStyle(d).color; d.remove()
|
||
return { b, run }
|
||
})
|
||
check('编辑态的 Copilot 新节点是紫框', purple !== colors.run && purple !== 'rgb(35, 42, 58)', purple)
|
||
check('运行态压过 Copilot 紫框', colors.b === colors.run, `${colors.b} / ${colors.run}`)
|
||
await page.evaluate(() => window.__studio.setState({ copilotNew: [] }))
|
||
|
||
// 一次性时刻:根元素写上 data-moment,卡片侧播一次、播完就停
|
||
const moment = async (kind, events, states) => {
|
||
await feed(page, events(Date.now()), { replay: true })
|
||
await page.waitForTimeout(300)
|
||
await page.evaluate((k) => { document.querySelector('.react-flow').dataset.moment = k }, kind)
|
||
await page.waitForTimeout(90)
|
||
const during = await runningAnimations(page, { transitions: false })
|
||
const hit = states.reduce((acc, st) => acc + (during[st] ?? 0), 0)
|
||
await page.waitForTimeout(2200)
|
||
const after = await runningAnimations(page, { transitions: false })
|
||
const left = states.reduce((acc, st) => acc + (after[st] ?? 0), 0)
|
||
await page.evaluate(() => { delete document.querySelector('.react-flow').dataset.moment })
|
||
check(`时刻 ${kind}:卡片上播了(${states.join('/')})`, hit > 0, during.names.join(', '))
|
||
check(`时刻 ${kind}:播完就停`, left === 0, JSON.stringify(after))
|
||
}
|
||
// 探索运行的印章带「不归档」;正式运行进行中画布只读,端口不可连
|
||
const gapOutput = { _issuance: { tier: 'degraded', matched_numbers: 4, metrics_checked: 0, gaps: ['指标集为空'], missing_required: [], unmatched_numbers: [] } }
|
||
const degraded = SUCCEEDED(Date.now()).map((e) => (e.type === 'issuance' ? { ...e, data: { ...e.data, tier: 'degraded' } } : e))
|
||
await feed(page, degraded, { runClass: 'exploratory' })
|
||
// run.finished 带的 output 会把运行上的覆盖掉:灌完再放完整的出具明细
|
||
await page.evaluate((output) => {
|
||
const st = window.__studio
|
||
st.setState({ run: { ...st.getState().run, output } })
|
||
}, gapOutput)
|
||
await page.waitForTimeout(200)
|
||
const stamp = page.locator('.react-flow__node[data-id="out"] .nc-stamp')
|
||
const stampText = await stamp.innerText().catch(() => '')
|
||
check('探索运行的印章写明不归档', stampText.includes('不归档') && stampText.includes('降档出具'), stampText.replace(/\n/g, ' '))
|
||
const stampInfo = await stamp.evaluate((el) => ({
|
||
tag: el.tagName, role: el.getAttribute('role'), tab: el.tabIndex, cursor: getComputedStyle(el).cursor, title: el.title,
|
||
})).catch(() => null)
|
||
// 它是一枚章,不是按钮:不进 Tab 序列,悬停前没有手形
|
||
check('印章不是个按钮', !!stampInfo && stampInfo.tag !== 'BUTTON' && stampInfo.role === 'img'
|
||
&& stampInfo.tab < 0 && stampInfo.cursor !== 'pointer', JSON.stringify(stampInfo && { ...stampInfo, title: undefined }))
|
||
check('印章悬停写出「校验未全部完成」', (stampInfo?.title ?? '').includes('校验未全部完成:指标集为空'), (stampInfo?.title ?? '').split('\n').join(' / '))
|
||
// 右栏看得见时点它是去看完整判定的捷径:广播 agentlab:goto-issuance,右栏在对话层时自己切过去
|
||
await page.evaluate(() => {
|
||
window.__issuance = []
|
||
window.addEventListener('agentlab:goto-issuance', (e) => window.__issuance.push(e.detail))
|
||
})
|
||
await stamp.hover()
|
||
const cursor = await stamp.evaluate((el) => getComputedStyle(el).cursor)
|
||
await stamp.click()
|
||
const went = await page.evaluate(() => window.__issuance)
|
||
check('右栏看得见:悬停成了链接,点它请右栏摆出出具横幅', cursor === 'pointer' && went.length === 1 && went[0].runId === RUN_ID,
|
||
`${cursor} · ${JSON.stringify(went)}`)
|
||
// 右栏停在对话层时横幅不在页面上:还是能点,右栏接手——切到运行层、滚到横幅、焦点落上去
|
||
await page.locator('[data-assistant-panel] button[title="回到和助手的对话"]').click({ timeout: 2000 }).catch(() => {})
|
||
await page.waitForTimeout(250)
|
||
const bannerGone = await page.locator('[data-issuance-banner]').count() === 0
|
||
await stamp.hover()
|
||
const chatCursor = await stamp.evaluate((el) => getComputedStyle(el).cursor)
|
||
await stamp.click()
|
||
await page.waitForFunction(() => document.activeElement?.closest('[data-issuance-banner]'), null, { timeout: 3000 }).catch(() => {})
|
||
const landed = await page.evaluate(() => {
|
||
const b = document.querySelector('[data-issuance-banner]')
|
||
return { banner: !!b, focus: !!document.activeElement?.closest('[data-issuance-banner]'), flash: b?.dataset.flash ?? null }
|
||
})
|
||
check('右栏在对话层:印章照样是链接,点了切到运行层、落到出具横幅上', bannerGone && chatCursor === 'pointer'
|
||
&& landed.banner && landed.focus && landed.flash === 'focus',
|
||
`${bannerGone ? '' : '(准备:横幅还在页面上)'}${chatCursor} · ${JSON.stringify(landed)}`)
|
||
// 回指明细带口径(issuance.matched[].caliber):同一个指标换过口径,看得出这次按的是哪一版
|
||
await page.evaluate(() => {
|
||
const st = window.__studio
|
||
const out = st.getState().run.output
|
||
st.setState({ run: { ...st.getState().run, output: { ...out, _issuance: { ...out._issuance,
|
||
matched: [{ token: '12.4%', metric: '毛利率', caliber: '月度口径 @ v2' }, { token: '1,204', metric: '订单数' }] } } } })
|
||
})
|
||
await page.waitForTimeout(150)
|
||
const matchedTitle = await stamp.getAttribute('title')
|
||
check('印章悬停列出追溯明细和口径', (matchedTitle ?? '').includes('12.4% → 毛利率(月度口径 @ v2)')
|
||
&& (matchedTitle ?? '').includes('1,204 → 订单数'), (matchedTitle ?? '').split('\n').join(' / '))
|
||
check('运行结束后端口恢复可连', await page.evaluate(() =>
|
||
document.querySelector('.react-flow__node[data-id="gate"] .react-flow__handle')?.classList.contains('connectable')))
|
||
// 探索运行进行中也不能连线:端口跟着 React Flow 的 connectable 走
|
||
await feed(page, LIVE(Date.now()), { runClass: 'exploratory' })
|
||
await page.waitForTimeout(200)
|
||
const explore = await page.evaluate(() => {
|
||
const h = document.querySelector('.react-flow__node[data-id="gate"] .react-flow__handle')
|
||
return h ? { connectable: h.classList.contains('connectable'), pe: getComputedStyle(h).pointerEvents, op: getComputedStyle(h).opacity } : null
|
||
})
|
||
check('探索运行进行中:端口不给「还能连」的暗示', !!explore && !explore.connectable && explore.pe === 'none' && Number(explore.op) < 0.5,
|
||
JSON.stringify(explore))
|
||
await feed(page, LIVE(Date.now()), { runClass: 'formal' })
|
||
await page.waitForTimeout(200)
|
||
const lock = await page.evaluate(() => {
|
||
const root = document.querySelector('.react-flow')
|
||
const h = document.querySelector('.react-flow__node[data-id="gate"] .react-flow__handle')
|
||
return { cls: root.dataset.runClass, phase: root.dataset.runPhase, pe: h ? getComputedStyle(h).pointerEvents : null }
|
||
})
|
||
check('正式运行进行中:卡片端口不可连', lock.pe === 'none', JSON.stringify(lock))
|
||
|
||
await moment('success', SUCCEEDED, ['done'])
|
||
await moment('failed', FAILED, ['blocked'])
|
||
await moment('start', LIVE, ['idle', 'done', 'queued', 'skipped'])
|
||
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 协作矩阵的措辞
|
||
|
||
await section('team', async () => {
|
||
console.log('\n=== 协作矩阵:并行的一轮交回一部分、全部交回等调度 ===')
|
||
const { browser, page, errors } = await open({ theme: 'dark' })
|
||
await setLod(page, 'full')
|
||
const ROUND = [...HEAD, ...TEAM_ROUND0,
|
||
[2.2, 'agent.step.end', 'team', { agent: '审校', duration_ms: 2500, round: 0, parallel: 3, preview: 'ok' }],
|
||
[1.0, 'agent.step.end', 'team', { agent: '工艺员', duration_ms: 3500, round: 0, parallel: 3, preview: 'ok' }],
|
||
]
|
||
const matrixOf = () => page.evaluate(() => {
|
||
const m = document.querySelector('.react-flow__node[data-id="team"] .team-matrix')
|
||
const flat = (sel) => (m?.querySelector(sel)?.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||
return { head: flat('.team-head'), foot: flat('.team-foot') }
|
||
})
|
||
// 两个交回、一个还在跑:以前表头按"此刻在跑几个"说成「串行推进」
|
||
await feed(page, script(ROUND)(Date.now()))
|
||
await page.waitForTimeout(250)
|
||
const two = await matrixOf()
|
||
check('三人并行交回两个:表头仍是并行', two.head.includes('本轮 3 名成员并行') && two.head.includes('1 名执行中') && !two.head.includes('串行'),
|
||
two.head)
|
||
// 三个都交回、下一次调度还没开始(老后端没有 route 事件时这段能有十几秒):
|
||
// 底部已经写「并行节省」,表头不能说「串行」
|
||
await feed(page, script([...ROUND,
|
||
[1.0, 'agent.step.end', 'team', { agent: '数据员', duration_ms: 4500, round: 0, parallel: 3, preview: 'ok' }],
|
||
])(Date.now()))
|
||
await page.waitForTimeout(250)
|
||
const all = await matrixOf()
|
||
check('三人都交回、等下一次调度:表头不说串行', !all.head.includes('串行') && all.head.includes('已交回'), all.head)
|
||
check('三人都交回:底部报省下的时间,和表头不打架', all.foot.includes('并行节省') && !all.head.includes('串行'), `${all.head} | ${all.foot}`)
|
||
await setLod(page, 'compact')
|
||
const brief = await lodRead(page, 'team')
|
||
check('精简档摘要不写「0/3 执行中」', !brief.includes('0/') && brief.includes('已交回'), brief)
|
||
shots.push(await shot(page, 'dark-team-all-back'))
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 收场:用完轮数、写成文字的工具调用、超时
|
||
|
||
await section('endings', async () => {
|
||
// 一张小图:协作团队两轮上限、三个成员(「定稿员」一次都派不到);一个 agent 节点把
|
||
// 工具调用写成了文字;一个工具节点查询超时;一个校验节点靠修复才过
|
||
const graph = await (await fetch(`${API}/copilot/layout`, {
|
||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ graph: {
|
||
nodes: [
|
||
{ id: 'in', type: 'input', data: { label: '入口', config: { fields: [{ name: 'week' }] } } },
|
||
{ id: 'team', type: 'supervisor', data: { label: '复盘小组', config: {
|
||
goal: '汇总本周订单异常', max_rounds: 2, max_parallel: 2,
|
||
agents: [agent('检索员', '拉订单明细'), agent('分析员', '找异常原因'), agent('定稿员', '写结论')],
|
||
} } },
|
||
{ id: 'ask', type: 'agent', data: { label: '订单查询', config: { prompt: '查 orders 本周异常', tools: ['db_query__shop'] } } },
|
||
{ id: 'q', type: 'tool', data: { label: '取订单', config: { tool: 'db_query__shop' } } },
|
||
{ id: 'chk', type: 'validate', data: { label: '结构校验', config: { repair_with_llm: true, max_retries: 2 } } },
|
||
{ id: 'out', type: 'output', data: { label: '周报', config: { fields: [{ name: 'report' }] } } },
|
||
],
|
||
edges: [
|
||
{ source: 'in', target: 'team' }, { source: 'in', target: 'ask' }, { source: 'in', target: 'q' },
|
||
{ source: 'q', target: 'chk' }, { source: 'team', target: 'out' }, { source: 'ask', target: 'out' },
|
||
{ source: 'chk', target: 'out' },
|
||
],
|
||
} }),
|
||
})).json()
|
||
const workflow = { ...WORKFLOW, id: 'wcard-endings', name: '__endings__', graph }
|
||
|
||
const MARKUP = '模型以文本形式输出了工具调用的原始标记,未实际调用工具,这一步没有查询到任何数据。常见原因:节点未绑定工具,或模型、服务不支持工具调用。请在画布中为该节点绑定所需工具;如已绑定仍出现此问题,请换用支持工具调用的模型'
|
||
// 成员把工具调用写成文字、纠正之后还是这样时,这一步的失败原因(后端 multi.py MEMBER_MARKUP_FAILED)
|
||
const MEMBER_MARKUP_FAILED = '模型以文本形式输出了工具调用的原始标记,未实际调用工具,这一步没有查询到任何数据(常见原因:该成员未绑定工具,或模型、服务不支持工具调用)'
|
||
const EXHAUSTED = '协作团队用完 2 轮仍未完成:还没有定稿。一次都没被派到的成员:定稿员。先看成员是否绑定了所需工具,再调大「最多轮数」;也可将「用完轮数时」改为「降档交付」'
|
||
const START = [
|
||
[0, 'run.started', null, { nodes: 6 }],
|
||
[0.1, 'node.started', 'in', {}],
|
||
[0.05, 'node.finished', 'in', { duration_ms: 3, preview: { week: '2026-W39' } }],
|
||
]
|
||
// 两轮都派了、调度者始终没说完成;第一轮的检索员把工具调用写成了文字,这一步记失败
|
||
const ROUNDS = [
|
||
[0.05, 'node.started', 'team', {}],
|
||
[0.02, 'agent.route.start', 'team', { round: 0 }],
|
||
[1.2, 'agent.route.end', 'team', { round: 0, duration_ms: 1200, agents: ['检索员'], parallel: 1, done: false, reason: '先拉订单明细' }],
|
||
[0.02, 'agent.step.start', 'team', { agent: '检索员', instruction: '拉本周订单', round: 0, parallel: 1 }],
|
||
[2.0, 'agent.step.end', 'team', { agent: '检索员', duration_ms: 2000, round: 0, parallel: 1, preview: '<tool_call>{"name": "db_query__shop"}</tool_call>',
|
||
failed: true, error: MEMBER_MARKUP_FAILED }],
|
||
[0.02, 'agent.route.start', 'team', { round: 1 }],
|
||
[1.0, 'agent.route.end', 'team', { round: 1, duration_ms: 1000, agents: ['分析员'], parallel: 1, done: false, reason: '换人分析' }],
|
||
[0.02, 'agent.step.start', 'team', { agent: '分析员', instruction: '找异常原因', round: 1, parallel: 1 }],
|
||
[1.5, 'agent.step.end', 'team', { agent: '分析员', duration_ms: 1500, round: 1, parallel: 1, preview: '缺数据,无法判断' }],
|
||
]
|
||
// 轮数用完之后的收尾判定:只判定、不派活,round 是上限(从 0 数就是「第 3 轮」)
|
||
const closing = (done, reason) => [
|
||
[0.02, 'agent.route.start', 'team', { round: 2, closing: true }],
|
||
[0.9, 'agent.route.end', 'team', { round: 2, closing: true, duration_ms: 900, agents: [], parallel: 0, done, reason }],
|
||
[0.05, 'llm.end', 'team', { agent: '调度者', model: 'm', input_tokens: 900, output_tokens: 120, cost_usd: 0.001 }],
|
||
]
|
||
const FAIL = script([...START,
|
||
[0.02, 'node.started', 'ask', {}],
|
||
...ROUNDS, ...closing(false, '还没有定稿'),
|
||
[0.02, 'log', 'ask', { level: 'warn', code: 'tool_markup_leak', message: '模型以文本形式输出了工具调用的原始标记,未实际调用工具' }],
|
||
[0.02, 'node.failed', 'ask', { duration_ms: 6800, error: MARKUP }],
|
||
[0.05, 'node.failed', 'team', { duration_ms: 6800, error: EXHAUSTED }],
|
||
[0.05, 'run.failed', null, { error: '「复盘小组」失败', node_id: 'team', timing: { wall_ms: 7000, active_ms: 7000, wait_ms: 0 } }],
|
||
])
|
||
const DEGRADE = script([...START,
|
||
[0.02, 'node.started', 'ask', {}],
|
||
// 第一次查询超出声明的 2s 时限被放弃,第二次查到了;收尾时模型还想要工具
|
||
[0.05, 'tool.start', 'ask', { tool: 'db_query__shop', call_id: 'c1', timeout_s: 2 }],
|
||
[3.0, 'tool.error', 'ask', { tool: 'db_query__shop', call_id: 'c1', duration_ms: 3000, timed_out: true, error: '查询超过 2 秒没有返回,已停止等待(数据库可能仍在执行,连接会在后台回收)。请添加 WHERE 条件或 LIMIT 缩小范围后重试' }],
|
||
[0.05, 'tool.start', 'ask', { tool: 'db_query__shop', call_id: 'c2', timeout_s: 2 }],
|
||
[0.4, 'tool.end', 'ask', { tool: 'db_query__shop', call_id: 'c2', duration_ms: 400, preview: '{"row_count": 12}' }],
|
||
[0.1, 'log', 'ask', { level: 'warn', code: 'tool_markup_leak',
|
||
message: '模型以文本形式输出了工具调用(<tool_call>{"name": "db_query__shop"…),未实际调用工具:已达步数上限,收尾时仍试图调用工具,未能给出结论' }],
|
||
[0.05, 'llm.end', 'ask', { agent: '订单查询', model: 'm', input_tokens: 1200, output_tokens: 300, cost_usd: 0.002 }],
|
||
[0.02, 'node.finished', 'ask', { duration_ms: 3700, preview: { text: '本周异常 12 单' } }],
|
||
[0.02, 'node.started', 'q', {}],
|
||
[0.02, 'tool.start', 'q', { tool: 'db_query__shop', call_id: 'c3', timeout_s: 30 }],
|
||
[0.3, 'tool.end', 'q', { tool: 'db_query__shop', call_id: 'c3', duration_ms: 300, preview: '{"row_count": 12}' }],
|
||
[0.02, 'node.finished', 'q', { duration_ms: 320, preview: { row_count: 12 } }],
|
||
[0.02, 'node.started', 'chk', {}],
|
||
[0.4, 'llm.end', 'chk', { model: 'm', purpose: 'repair', duration_ms: 400, input_tokens: 300, output_tokens: 40, cost_usd: 0.0005, calls: 1 }],
|
||
[0.02, 'log', 'chk', { level: 'warn', code: 'repair_invented', message: '修复时出现了原文没有的值:total_count=0' }],
|
||
[0.4, 'llm.end', 'chk', { model: 'm', purpose: 'repair', duration_ms: 400, input_tokens: 300, output_tokens: 40, cost_usd: 0.0005, calls: 1 }],
|
||
[0.02, 'node.finished', 'chk', { duration_ms: 850, preview: { total_count: 12 } }],
|
||
...ROUNDS, ...closing(false, '还没有定稿'),
|
||
[0.02, 'log', 'team', { level: 'warn', code: 'team_exhausted', message: '协作团队用完 2 轮仍未完成:还没有定稿。一次都没被派到的成员:定稿员。按降档交付:成果取自成员最后的回复,并非调度者认可的结论' }],
|
||
[0.02, 'node.finished', 'team', { duration_ms: 6800, preview: { exhausted: true, exhausted_reason: '还没有定稿', never_dispatched: '[1 项]', text: '缺数据,无法判断' } }],
|
||
[0.05, 'node.started', 'out', {}],
|
||
[0.05, 'node.finished', 'out', { duration_ms: 4, preview: { report: '…' } }],
|
||
[0.05, 'run.finished', null, { output: {}, usage: {}, duration_ms: 9000, timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }],
|
||
])
|
||
// 进行中:收尾判定还没出结论;工具节点的查询已经超出声明的 2s
|
||
const LIVE_JUDGING = script([...START,
|
||
...ROUNDS,
|
||
[0.02, 'agent.route.start', 'team', { round: 2, closing: true }],
|
||
[0.02, 'node.started', 'q', {}],
|
||
[0.02, 'tool.start', 'q', { tool: 'db_query__shop', call_id: 'c9', timeout_s: 2 }],
|
||
[3.2, 'log', null, { level: 'info', message: '心跳' }],
|
||
])
|
||
// 收尾判定说完成了:正常交付,表头说清是轮数用完之后判的,不点名谁没被派到
|
||
const CLOSED_OK = script([...START,
|
||
...ROUNDS, ...closing(true, '材料够了,可以收尾'),
|
||
[0.05, 'node.finished', 'team', { duration_ms: 6800, preview: { text: '结论:…' } }],
|
||
[0.05, 'run.finished', null, { output: {}, usage: {}, duration_ms: 7000, timing: { wall_ms: 7000, active_ms: 7000, wait_ms: 0 } }],
|
||
])
|
||
const askLlm = (out) => [0.05, 'llm.end', 'ask', { agent: '订单查询', model: 'm', input_tokens: 600, output_tokens: out, cost_usd: 0.001 }]
|
||
// 同一个节点执行两次:第一次的收场不能挂到第二次上。团队用完轮数判失败 → 调大轮数
|
||
// 接着跑(后端给失败的节点标 resumed,但这是重新执行;前两轮在断点里,不重发)→ 派到
|
||
// 定稿员、调度者判完成。agent 第一次收尾时还想调工具,第二次(循环体每一轮也是这样)干干净净
|
||
const RERUN = script([...START,
|
||
[0.02, 'node.started', 'ask', {}],
|
||
[0.3, 'log', 'ask', { level: 'warn', code: 'tool_markup_leak',
|
||
message: '模型以文本形式输出了工具调用(<tool_call>{"name": "db_query__shop"…),未实际调用工具:已达步数上限,收尾时仍试图调用工具,未能给出结论' }],
|
||
askLlm(300),
|
||
[0.02, 'node.finished', 'ask', { duration_ms: 400, preview: { text: '本周异常 12 单' } }],
|
||
...ROUNDS, ...closing(false, '还没有定稿'),
|
||
[0.05, 'node.failed', 'team', { duration_ms: 6800, error: EXHAUSTED }],
|
||
[0.05, 'run.failed', null, { error: '「复盘小组」失败', node_id: 'team', timing: { wall_ms: 7000, active_ms: 7000, wait_ms: 0 } }],
|
||
[2.0, 'run.resumed', null, { from: ['team'], message: '从「复盘小组」继续运行,保留前面 2 个节点的结果' }],
|
||
[0.02, 'run.started', null, { nodes: 6, resumed: true }],
|
||
[0.05, 'node.started', 'team', { resumed: true }],
|
||
[0.02, 'agent.route.start', 'team', { round: 2 }],
|
||
[0.8, 'agent.route.end', 'team', { round: 2, duration_ms: 800, agents: ['定稿员'], parallel: 1, done: false, reason: '派定稿员写结论' }],
|
||
[0.02, 'agent.step.start', 'team', { agent: '定稿员', instruction: '写结论', round: 2, parallel: 1 }],
|
||
[1.2, 'agent.step.end', 'team', { agent: '定稿员', duration_ms: 1200, round: 2, parallel: 1, preview: '结论:…' }],
|
||
[0.02, 'agent.route.start', 'team', { round: 3 }],
|
||
[0.6, 'agent.route.end', 'team', { round: 3, duration_ms: 600, agents: [], parallel: 0, done: true, reason: '已经定稿' }],
|
||
[0.05, 'node.finished', 'team', { duration_ms: 2700, preview: { text: '结论:…' } }],
|
||
[0.05, 'node.started', 'ask', {}],
|
||
askLlm(240),
|
||
[0.3, 'node.finished', 'ask', { duration_ms: 320, preview: { text: '本周异常 9 单' } }],
|
||
[0.05, 'node.started', 'out', {}],
|
||
[0.05, 'node.finished', 'out', { duration_ms: 4, preview: { report: '…' } }],
|
||
[0.05, 'run.finished', null, { output: {}, usage: {}, duration_ms: 12000, timing: { wall_ms: 12000, active_ms: 10000, wait_ms: 0 } }],
|
||
])
|
||
// 写成文字、被提醒之后重答了:成果是重答的那一次,不是「收尾时仍想调用工具」那种不完整。
|
||
// 团队里检索员提醒后重答了;分析员提醒后还这样,这一步记失败,不能算进「纠正过」
|
||
const NUDGE = script([...START,
|
||
[0.02, 'node.started', 'ask', {}],
|
||
[0.3, 'log', 'ask', { level: 'warn', code: 'tool_markup_leak',
|
||
message: '模型以文本形式输出了工具调用(<tool_call>{"name": "db_query__shop"…),未实际调用工具,已要求模型重试一次' }],
|
||
[0.02, 'tool.start', 'ask', { tool: 'db_query__shop', call_id: 'n1', timeout_s: 30 }],
|
||
[0.3, 'tool.end', 'ask', { tool: 'db_query__shop', call_id: 'n1', duration_ms: 300, preview: '{"row_count": 12}' }],
|
||
askLlm(280),
|
||
[0.02, 'node.finished', 'ask', { duration_ms: 700, preview: { text: '本周异常 12 单' } }],
|
||
[0.02, 'node.started', 'team', {}],
|
||
[0.02, 'agent.route.start', 'team', { round: 0 }],
|
||
[0.8, 'agent.route.end', 'team', { round: 0, duration_ms: 800, agents: ['检索员', '分析员'], parallel: 2, done: false, reason: '先拉明细、再找原因' }],
|
||
[0.02, 'agent.step.start', 'team', { agent: '检索员', instruction: '拉本周订单', round: 0, parallel: 2 }],
|
||
[0.01, 'agent.step.start', 'team', { agent: '分析员', instruction: '找异常原因', round: 0, parallel: 2 }],
|
||
[0.4, 'log', 'team', { level: 'warn', code: 'tool_markup_leak',
|
||
message: '检索员以文本形式输出了工具调用(<tool_call>{"name": "db_query__shop"…),未实际调用工具,已要求模型重试一次' }],
|
||
[0.2, 'log', 'team', { level: 'warn', code: 'tool_markup_leak',
|
||
message: '分析员以文本形式输出了工具调用(<tool_call>{"name": "db_query__shop"…),未实际调用工具,已要求模型重试一次' }],
|
||
[0.6, 'agent.step.end', 'team', { agent: '检索员', duration_ms: 1200, round: 0, parallel: 2, preview: '拉到 12 单' }],
|
||
[0.3, 'agent.step.end', 'team', { agent: '分析员', duration_ms: 1500, round: 0, parallel: 2, preview: '',
|
||
failed: true, error: MEMBER_MARKUP_FAILED }],
|
||
[0.02, 'agent.route.start', 'team', { round: 1 }],
|
||
[0.6, 'agent.route.end', 'team', { round: 1, duration_ms: 600, agents: [], parallel: 0, done: true, reason: '明细够了' }],
|
||
[0.05, 'node.finished', 'team', { duration_ms: 2500, preview: { text: '结论:…' } }],
|
||
[0.05, 'run.finished', null, { output: {}, usage: {}, duration_ms: 3500, timing: { wall_ms: 3500, active_ms: 3500, wait_ms: 0 } }],
|
||
])
|
||
/** 改团队节点的配置(运行之后调过「最多轮数」、或者压根没配)。值给 null 就是删掉这一项 */
|
||
const setTeamConfig = (page, patch) => page.evaluate((patch) => {
|
||
const st = window.__studio
|
||
const node = st.getState().nodes.find((n) => n.id === 'team')
|
||
const config = { ...node.data.config, ...patch }
|
||
for (const [k, v] of Object.entries(patch)) if (v === null) delete config[k]
|
||
st.setState({ nodes: st.getState().nodes.map((n) => (n.id === 'team' ? { ...n, data: { ...n.data, config } } : n)) })
|
||
}, patch)
|
||
|
||
const at = (page, id) => page.locator(`.react-flow__node[data-id="${id}"]`)
|
||
const flat = (t) => (t ?? '').replace(/\s+/g, ' ').trim()
|
||
const matrixOf = (page) => page.evaluate(() => {
|
||
const m = document.querySelector('.react-flow__node[data-id="team"] .team-matrix')
|
||
const right = m?.querySelector('.team-head-right')
|
||
const row = (name) => [...(m?.querySelectorAll('.team-row') ?? [])].find((r) => r.textContent.includes(name))
|
||
const member = (name) => {
|
||
const r = row(name)
|
||
return r ? { status: r.dataset.memberStatus, text: r.innerText.replace(/\s+/g, ' ').trim(), title: r.title } : null
|
||
}
|
||
return {
|
||
head: (m?.querySelector('.team-head')?.textContent ?? '').replace(/\s+/g, ' ').trim(),
|
||
headTitle: m?.querySelector('.team-head')?.getAttribute('title') ?? '',
|
||
right: right?.textContent ?? '', tone: right?.classList.contains('is-err') ? 'err' : right?.classList.contains('is-warn') ? 'warn' : '',
|
||
dispatch: (m?.querySelector('.team-row-dispatch')?.textContent ?? '').replace(/\s+/g, ' ').trim(),
|
||
a: member('检索员'), b: member('分析员'), c: member('定稿员'),
|
||
}
|
||
})
|
||
const note = (page, id) => page.evaluate((id) => {
|
||
const el = document.querySelector(`.react-flow__node[data-id="${id}"] .nc-tele-note`)
|
||
return el ? { text: el.textContent.trim(), title: el.title, warn: el.classList.contains('is-warn') } : null
|
||
}, id)
|
||
|
||
for (const theme of ['dark', 'light']) {
|
||
console.log(`\n=== ${theme === 'dark' ? '暗色' : '亮色'}:收场说人话(用完轮数、工具调用写成文字、超时、修复) ===`)
|
||
const { browser, page, errors } = await open({ theme, workflow })
|
||
await setLod(page, 'full')
|
||
const base = Object.fromEntries(Object.entries(await cards(page)).map(([id, c]) => [id, c.h]))
|
||
|
||
// ---- 判失败:用完轮数(默认)
|
||
await feed(page, FAIL(Date.now()))
|
||
await page.waitForTimeout(900)
|
||
let c = await cards(page)
|
||
let m = await matrixOf(page)
|
||
const teamNote = await note(page, 'team')
|
||
check(`${theme}: 团队用完轮数判失败,卡片写「用完 2 轮仍未完成」`, c.team.state === 'failed'
|
||
&& teamNote?.text === '协作团队用完 2 轮仍未完成', JSON.stringify(teamNote?.text))
|
||
check(`${theme}: 悬停写清理由和处理建议`, (teamNote?.title ?? '').includes('还没有定稿') && (teamNote?.title ?? '').includes('处理建议'),
|
||
flat(teamNote?.title))
|
||
check(`${theme}: 收尾判定不另算一轮(表头仍是第 2 轮 / 上限 2)`, m.head.startsWith('第 2 轮 / 上限 2'), m.head)
|
||
check(`${theme}: 表头说「用完 2 轮 · 未完成」,失败上红`, m.right === '用完 2 轮 · 未完成' && m.tone === 'err', `${m.right} · ${m.tone}`)
|
||
check(`${theme}: 调度者那一行写「收尾判定」`, m.dispatch.includes('收尾判定'), m.dispatch)
|
||
check(`${theme}: 一次都没被派到的成员点出来`, m.c?.status === 'never' && m.c.text.includes('未分派')
|
||
&& m.headTitle.includes('从未被分派任务的成员:定稿员'), JSON.stringify(m.c))
|
||
check(`${theme}: 成员把工具调用写成文字:格子是失败,悬停写原因`, m.a?.status === 'failed'
|
||
&& (m.a.title ?? '').includes('原因:模型以文本形式输出了工具调用的原始标记'), JSON.stringify(m.a))
|
||
const askNote = await note(page, 'ask')
|
||
check(`${theme}: agent 把工具调用写成文字而失败:卡片写原因,不是半句原话`, c.ask.state === 'failed'
|
||
&& askNote?.text === '模型未实际调用工具' && (askNote?.title ?? '').includes('处理建议'), JSON.stringify(askNote))
|
||
const grew = Object.entries(c).filter(([id, x]) => x.h !== base[id])
|
||
check(`${theme}: 失败收场不撑高卡片`, grew.length === 0, grew.map(([id, x]) => `${id} ${base[id]}→${x.h}`).join(', '))
|
||
shots.push(await shot(page, `${theme}-endings-failed`), await cardShot(page, ['team', 'ask'], `${theme}-endings-failed-cards`))
|
||
|
||
// ---- 降档交付、收尾时还想调工具、查询超时、修复才过
|
||
await feed(page, DEGRADE(Date.now()))
|
||
await page.waitForTimeout(900)
|
||
c = await cards(page)
|
||
m = await matrixOf(page)
|
||
const degr = await note(page, 'team')
|
||
const chip = await at(page, 'team').locator('.nc-head .nc-chip-warn', { hasText: '降档' }).count()
|
||
check(`${theme}: 降档交付:卡片写「用完 2 轮未完成 · 降档交付」,琥珀色、带「降档」标`, c.team.state === 'done'
|
||
&& degr?.text === '用完 2 轮未完成 · 降档交付' && degr.warn && chip === 1, JSON.stringify({ degr, chip }))
|
||
check(`${theme}: 降档交付:表头琥珀、点出没被派到的成员`, m.right === '用完 2 轮 · 未完成' && m.tone === 'warn'
|
||
&& m.c?.status === 'never', `${m.right} · ${m.tone} · ${m.c?.status}`)
|
||
const askDone = await note(page, 'ask')
|
||
check(`${theme}: 收尾时还想调工具:跑完了但琥珀色提一句`, c.ask.state === 'done' && askDone?.text === '步数用尽时仍在请求调用工具' && askDone.warn,
|
||
JSON.stringify(askDone))
|
||
const tools = await at(page, 'ask').locator('.nc-tools').evaluate((el) => ({ bad: el.classList.contains('is-bad'), title: el.title }))
|
||
.catch(() => null)
|
||
check(`${theme}: 超时的那次调用算失败,悬停写「超时(上限 2 秒)」`, !!tools?.bad && tools.title.includes('超时(上限 2 秒)'), JSON.stringify(tools))
|
||
const chkNote = await note(page, 'chk')
|
||
check(`${theme}: 校验靠修复才过:写「修复 2 次后通过」,有一次凑数被作废就上琥珀`, chkNote?.text === '修复 2 次后通过'
|
||
&& chkNote.warn && chkNote.title.includes('total_count=0'), JSON.stringify(chkNote))
|
||
shots.push(await shot(page, `${theme}-endings-degraded`), await cardShot(page, ['team', 'ask', 'q'], `${theme}-endings-degraded-cards`))
|
||
// 校验节点在右边,常被右栏挡住:取景过去再截
|
||
await page.evaluate(() => window.__studio.getState().focusNode('chk'))
|
||
await page.waitForTimeout(600)
|
||
shots.push(await cardShot(page, ['chk'], `${theme}-endings-degraded-validate`))
|
||
|
||
// 回放到收尾判定还没出结论的那一刻:不提前降档、不提前点名
|
||
const judging = await page.evaluate(() => {
|
||
const segs = window.__studio.getState().trace.nodes.team.segments.filter((s) => s.kind === 'dispatch')
|
||
return segs[segs.length - 1].start + 300
|
||
})
|
||
await page.evaluate((t) => window.__studio.getState().setReplayAt(t), judging)
|
||
await page.waitForTimeout(300)
|
||
m = await matrixOf(page)
|
||
const early = await at(page, 'team').locator('.nc-head .nc-chip-warn', { hasText: '降档' }).count()
|
||
check(`${theme}: 回放到收尾判定中:表头写「调度者正在判定」,不提前降档、不点名`, m.right.includes('调度者正在判定') && early === 0
|
||
&& m.c?.status !== 'never', `${m.right} · 降档标 ${early} · ${m.c?.status}`)
|
||
await page.evaluate(() => window.__studio.getState().setReplayAt(null))
|
||
|
||
// ---- 进行中:收尾判定中;查询超出声明的时限
|
||
await feed(page, LIVE_JUDGING(Date.now()))
|
||
await page.waitForTimeout(600)
|
||
m = await matrixOf(page)
|
||
check(`${theme}: 进行中的收尾判定:表头写「轮数用完 · 调度者正在判定」,不是新的一轮`,
|
||
m.right === '轮数用完 · 调度者正在判定' && m.head.startsWith('第 2 轮 / 上限 2') && m.dispatch.includes('判定'), `${m.head} | ${m.dispatch}`)
|
||
const over = await at(page, 'q').locator('.nc-over').innerText().catch(() => '')
|
||
const overTitle = await at(page, 'q').locator('.nc-tools').getAttribute('title').catch(() => '') ?? ''
|
||
// 这句话整个落在卡片里,不被右边沿切掉
|
||
const overFits = await page.evaluate(() => {
|
||
const el = document.querySelector('.react-flow__node[data-id="q"] .nc-over')
|
||
const card = el?.closest('.nc')
|
||
if (!el || !card) return false
|
||
return el.getBoundingClientRect().right <= card.getBoundingClientRect().right - 2
|
||
})
|
||
check(`${theme}: 查询超出声明的时限:直说「已超出 2s 上限」,工具名在悬停里`, over === '已超出 2s 上限' && overTitle.includes('db_query__shop 进行中(上限 2 秒)') && overFits, `${over} · ${flat(overTitle)}${overFits ? '' : ' · 超出了卡片'}`)
|
||
shots.push(await shot(page, `${theme}-endings-live`), await cardShot(page, ['team', 'q'], `${theme}-endings-live-cards`))
|
||
await setLod(page, 'compact')
|
||
const brief = await lodRead(page, 'team', (t) => t === '轮数用完 · 收尾判定中')
|
||
check(`${theme}: 精简档:收尾判定中写清是判定,不写「第 3 轮」`, brief === '轮数用完 · 收尾判定中', brief)
|
||
// 收尾判定认事件里的 closing,不拿配置里的「最多轮数」比:没配(后端按缺省值跑)、
|
||
// 运行之后调大了(接着跑之前、回放历史运行),照样认得出是在判定
|
||
await setTeamConfig(page, { max_rounds: null })
|
||
await page.waitForTimeout(150)
|
||
const briefBare = await lodRead(page, 'team', (t) => t === '轮数用完 · 收尾判定中')
|
||
await setLod(page, 'full')
|
||
m = await matrixOf(page)
|
||
check(`${theme}: 没配最多轮数:照样认出收尾判定(精简档、表头、调度者那一行)`, briefBare === '轮数用完 · 收尾判定中'
|
||
&& m.right === '轮数用完 · 调度者正在判定' && m.dispatch.includes('判定'), `${briefBare} | ${m.right} | ${m.dispatch}`)
|
||
await setTeamConfig(page, { max_rounds: 4 })
|
||
await page.waitForTimeout(150)
|
||
m = await matrixOf(page)
|
||
check(`${theme}: 运行之后调大了最多轮数:照样认出收尾判定`, m.right === '轮数用完 · 调度者正在判定'
|
||
&& m.dispatch.includes('判定'), `${m.right} | ${m.dispatch}`)
|
||
await setTeamConfig(page, { max_rounds: 2 })
|
||
|
||
// ---- 收尾判定说完成了
|
||
await feed(page, CLOSED_OK(Date.now()))
|
||
await page.waitForTimeout(600)
|
||
m = await matrixOf(page)
|
||
check(`${theme}: 收尾判定已完成:表头说清、安静、不点名`, m.right === '轮数用完 · 判定已完成' && m.tone === ''
|
||
&& m.head.startsWith('第 2 轮 / 上限 2') && m.c?.status !== 'never', `${m.head} · ${m.tone} · ${m.c?.status}`)
|
||
check(`${theme}: 悬停写完整判定`, m.headTitle.includes('轮数用完 · 调度者判定:已完成') && m.headTitle.includes('材料够了'), flat(m.headTitle))
|
||
|
||
// ---- 同一个节点执行第二次:上一次的收场不挂到这一次上。照真实流程先把最多轮数调大
|
||
// 再接着跑:回放到第一次时「用完 2 轮」得按那一次的原话,不按现在的配置写成 4
|
||
await setTeamConfig(page, { max_rounds: 4 })
|
||
// 接着跑、团队重新执行而调度者还没开口:reduceTeam 刚把上一次清空(rounds 为空)。矩阵要画成
|
||
// 「还没有数据」,和第一次执行刚开始时一模一样,不写「等待调度者」「已收尾」(3C REQ-17)
|
||
const rr = RERUN(Date.now())
|
||
const teamStarts = rr.map((e, i) => (e.type === 'node.started' && e.node_id === 'team' ? i : -1)).filter((i) => i >= 0)
|
||
await feed(page, rr.slice(0, teamStarts[1] + 1))
|
||
await page.waitForTimeout(250)
|
||
const resetM = await matrixOf(page)
|
||
await feed(page, rr.slice(0, teamStarts[0] + 1))
|
||
await page.waitForTimeout(250)
|
||
const freshM = await matrixOf(page)
|
||
check(`${theme}: 继续运行、团队重新执行还没派活:矩阵和第一次刚开始时一样(还没有数据)`,
|
||
teamStarts.length >= 2 && resetM.head === freshM.head && resetM.right === freshM.right
|
||
&& !/等待调度者|已收尾|共 0 轮/.test(resetM.head), `${resetM.head} ‖ ${freshM.head}`)
|
||
await feed(page, RERUN(Date.now()))
|
||
await page.waitForTimeout(700)
|
||
const quietEnding = async (label) => {
|
||
const cs = await cards(page)
|
||
const mx = await matrixOf(page)
|
||
const never = await at(page, 'team').locator('[data-member-status="never"]').count()
|
||
const chips = await at(page, 'team').locator('.nc-head .nc-chip-warn', { hasText: '降档' }).count()
|
||
const tn = await note(page, 'team')
|
||
const an = await note(page, 'ask')
|
||
check(`${theme}: ${label}:团队表头不挂上一次的「判定未完成」、安静、不点名`, cs.team.state === 'done' && mx.tone === ''
|
||
&& !mx.right.includes('未完成') && !mx.headTitle.includes('未完成') && never === 0 && chips === 0 && !tn?.warn,
|
||
`${mx.right} · 语气「${mx.tone}」· 未分派 ${never} · 降档标 ${chips} · ${JSON.stringify(tn)}`)
|
||
check(`${theme}: ${label}:agent 这一次干干净净,写产出量,不写上一次的「步数用尽时仍在请求调用工具」`, cs.ask.state === 'done'
|
||
&& !!an && !an.warn && /token/.test(an.text), JSON.stringify(an))
|
||
}
|
||
await quietEnding('调大轮数继续运行成功、agent 再执行一次')
|
||
shots.push(await cardShot(page, ['team', 'ask'], `${theme}-endings-rerun-cards`))
|
||
const execs = await page.evaluate(() => {
|
||
const n = window.__studio.getState().trace.nodes
|
||
const runs = (id) => n[id].segments.filter((s) => s.kind === 'run')
|
||
return { teamRuns: runs('team').length, askRuns: runs('ask').length,
|
||
first: runs('team')[0].end + 300, second: runs('ask').at(-1).end + 10 }
|
||
})
|
||
check(`${theme}: 夹具确实是两次执行(团队、agent 各两段)`, execs.teamRuns === 2 && execs.askRuns === 2, JSON.stringify(execs))
|
||
await page.evaluate((t) => window.__studio.getState().setReplayAt(t), execs.second)
|
||
await page.waitForTimeout(300)
|
||
await quietEnding('回放到第二次执行里')
|
||
// 回放到第一次失败的那一刻:说的是那一次的收场,不因为后来成功了就不说
|
||
await page.evaluate((t) => window.__studio.getState().setReplayAt(t), execs.first)
|
||
await page.waitForTimeout(300)
|
||
m = await matrixOf(page)
|
||
const firstTeam = await note(page, 'team')
|
||
const firstAsk = await note(page, 'ask')
|
||
check(`${theme}: 回放到第一次失败那一刻:团队写那一次的「用完 2 轮仍未完成」,表头红、点名定稿员`,
|
||
firstTeam?.text === '协作团队用完 2 轮仍未完成' && m.right === '用完 2 轮 · 未完成' && m.tone === 'err' && m.c?.status === 'never',
|
||
`${JSON.stringify(firstTeam?.text)} · ${m.right} · ${m.tone} · ${m.c?.status}`)
|
||
check(`${theme}: 回放到第一次执行:agent 写那一次的「步数用尽时仍在请求调用工具」`, firstAsk?.text === '步数用尽时仍在请求调用工具' && firstAsk.warn,
|
||
JSON.stringify(firstAsk))
|
||
await page.evaluate(() => window.__studio.getState().setReplayAt(null))
|
||
await setTeamConfig(page, { max_rounds: 2 })
|
||
|
||
// ---- 写成文字、被提醒之后重答了:不是「收尾时仍想调用工具」
|
||
await feed(page, NUDGE(Date.now()))
|
||
await page.waitForTimeout(700)
|
||
const askNudge = await note(page, 'ask')
|
||
check(`${theme}: agent 写成文字被提醒后重答了:写「纠正过一次工具调用」,不说成果可能不完整`,
|
||
askNudge?.text === '纠正过一次工具调用' && askNudge.warn && askNudge.title.includes('经提醒后已重新作答')
|
||
&& !askNudge.title.includes('可能不完整'), JSON.stringify(askNudge))
|
||
const teamNudge = await note(page, 'team')
|
||
check(`${theme}: 协作成员提醒后重答了:团队卡同样写「纠正过一次」,提醒后仍失败的那位不算进去`,
|
||
teamNudge?.text === '纠正过一次工具调用' && teamNudge.warn && teamNudge.title.includes('检索员')
|
||
&& !teamNudge.title.includes('分析员'), JSON.stringify(teamNudge))
|
||
shots.push(await cardShot(page, ['team', 'ask'], `${theme}-endings-nudge-cards`))
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
}
|
||
})
|
||
|
||
await section('replay', async () => {
|
||
console.log('\n=== 回放:卡片回到游标那一刻 ===')
|
||
const { browser, page, errors } = await open({ theme: 'dark' })
|
||
await setLod(page, 'full')
|
||
await feed(page, SUCCEEDED(Date.now()), { replay: true })
|
||
await page.waitForTimeout(300)
|
||
// 游标放在三个成员都派出去、最快的那个还没交回的时候
|
||
const at = await page.evaluate(() => {
|
||
const segs = window.__studio.getState().trace.nodes.team.segments.filter((s) => s.kind === 'member')
|
||
return Math.max(...segs.map((s) => s.start)) + 400
|
||
})
|
||
await page.evaluate((at) => window.__studio.getState().setReplayAt(at), at)
|
||
await page.waitForTimeout(300)
|
||
const r = await cards(page)
|
||
check('回放:团队回到运行中', r.team.state === 'running', r.team.state)
|
||
check('回放:人工审批还没到', r.human.state === 'idle', r.human.state)
|
||
const matrix = await page.locator('.react-flow__node[data-id="team"] .team-matrix').innerText()
|
||
check('回放:矩阵里三个人都还在跑', await page.locator('.react-flow__node[data-id="team"] .team-row[data-member-status="running"]').count() === 3,
|
||
matrix.split('\n').slice(0, 2).join(' / '))
|
||
check('回放:还没节省时间就不报', !matrix.includes('节省'), matrix.split('\n').slice(-1)[0])
|
||
await page.waitForTimeout(700)
|
||
const later = (await cards(page)).team.text
|
||
check('回放:计时是定值、不走', /\d\d:\d\d/.test(r.team.text) && r.team.text === later, `${r.team.text} → ${later}`)
|
||
check('回放:印章还没落', await page.locator('.react-flow__node[data-id="out"] .nc-stamp').count() === 0)
|
||
check('回放:还没跑到的分支出口不亮', await page.locator('.react-flow__node[data-id="gate"] .nc-exit.is-hit').count() === 0)
|
||
const memberClock = await page.locator('.react-flow__node[data-id="team"] .team-row[data-member-status="running"] .team-ms').first().innerText()
|
||
check('回放:在跑的成员写那一刻的用时', /^\d\d:\d\d$/.test(memberClock), memberClock)
|
||
check('回放:高度仍不变', Object.values(r).every((c) => c.h > 60))
|
||
shots.push(await shot(page, 'dark-replay-mid'))
|
||
|
||
// 游标放进循环体第 3 轮:循环容器在两轮之间,实时时它不转圈、不挂光弧,回放也一样。
|
||
// 汇合节点这时上游都交付了、自己还没开始:和画布、小地图一样是「排队」,不是「未运行」
|
||
const at3 = await page.evaluate(() => {
|
||
const runs = window.__studio.getState().trace.nodes.body.segments.filter((s) => s.kind === 'run')
|
||
return runs[2].start + 200
|
||
})
|
||
await page.evaluate((at) => window.__studio.getState().setReplayAt(at), at3)
|
||
await page.waitForTimeout(300)
|
||
const r3 = await cards(page)
|
||
const halo = (id) => page.locator(`.react-flow__node[data-id="${id}"] .fx-halo`).count()
|
||
check('回放:循环在两轮之间仍是运行中', r3.loop.state === 'running', r3.loop.state)
|
||
check('回放:两轮之间的循环容器不挂光弧(循环体挂)', await halo('loop') === 0 && await halo('body') === 1,
|
||
`loop ${await halo('loop')} / body ${await halo('body')}`)
|
||
check('回放:推导状态和画布一致(汇合节点在排队)', r3.join.state === 'queued', r3.join.state)
|
||
shots.push(await shot(page, 'dark-replay-loop'))
|
||
await page.evaluate(() => window.__studio.getState().setReplayAt(null))
|
||
await page.waitForTimeout(300)
|
||
const back = await cards(page)
|
||
check('回到实时:终态回来了', back.team.state === 'done' && back.out.state === 'done', `${back.team.state} / ${back.out.state}`)
|
||
check('回到实时:印章回来了', await page.locator('.react-flow__node[data-id="out"] .nc-stamp').count() === 1)
|
||
check('回到实时:命中的出口亮回来', await page.locator('.react-flow__node[data-id="gate"] .nc-exit.is-hit').count() === 1)
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
})
|
||
|
||
await section('history', async () => {
|
||
console.log('\n=== 打开历史运行:一次性动效不播 ===')
|
||
const { browser, page } = await open({ theme: 'dark' })
|
||
await feed(page, FAILED(Date.now()), { replay: true })
|
||
await page.waitForTimeout(60)
|
||
// 只看卡片上的一次性动效(抖、扫、琥珀一圈、落章);状态切换带的 200ms 透明度
|
||
// 过渡不算时刻
|
||
const anim = await runningAnimations(page, { transitions: false })
|
||
const moments = anim.names.filter((n) => /fx-shake|fx-done|nc-ring-out|nc-stamp|nc-light|nc-block/.test(n))
|
||
check('补发的历史不抖、不扫、不落章', moments.length === 0, anim.names.join(', ') || '无')
|
||
check('补发的历史不挂 .nc-enter', await page.locator('.nc.nc-enter').count() === 0)
|
||
await browser.close()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 校验角标
|
||
|
||
await section('issues', async () => {
|
||
console.log('\n=== 校验角标:只在编辑时上卡,运行态、远景不和状态抢 ===')
|
||
const { browser, page, errors } = await open({ theme: 'dark' })
|
||
// 「没有指定模型」是每个没选模型的节点都有的提示:真实的图里满屏都是它
|
||
issues = [
|
||
{ level: 'error', node_id: 'body', message: '引用了不存在的变量 item.name' },
|
||
{ level: 'warning', node_id: 'join', message: '产出者排在后面,这里取到空值' },
|
||
{ level: 'warning', node_id: 'team', field: 'model', message: '没有指定模型,将使用默认模型' },
|
||
]
|
||
await page.evaluate((iss) => window.__studio.setState({ issues: iss }), issues)
|
||
/** 看得见的角标:哪个节点、挂着哪几种、屏幕上多高 */
|
||
const badges = () => page.evaluate(() => [...document.querySelectorAll('.react-flow__node .nc-issue')].map((el) => {
|
||
const shown = [...el.children].filter((c) => {
|
||
const cs = getComputedStyle(c)
|
||
return cs.visibility === 'visible' && cs.display !== 'none' && c.getBoundingClientRect().width > 0
|
||
})
|
||
return { id: el.closest('.react-flow__node').dataset.id, kinds: shown.map((c) => (c.classList.contains('is-error') ? 'error' : 'warning')),
|
||
h: Math.round(Math.max(0, ...shown.map((c) => c.getBoundingClientRect().height))) }
|
||
}).filter((b) => b.kinds.length))
|
||
await setLod(page, 'full')
|
||
const edit = await badges()
|
||
check('编辑态:error、warning 都上卡', edit.some((b) => b.id === 'body' && b.kinds.includes('error'))
|
||
&& edit.some((b) => b.id === 'join' && b.kinds.includes('warning')), JSON.stringify(edit))
|
||
const lod = await setLod(page, 'signal')
|
||
const far = await badges()
|
||
check('编辑态缩到信号档:warning 不再按缩放反向放大、挂满整张图,只留会拦住运行的 error',
|
||
far.every((b) => !b.kinds.includes('warning')) && far.some((b) => b.id === 'body'), `${lod.lod} ${JSON.stringify(far)}`)
|
||
|
||
for (const [name, events] of [['运行中', LIVE], ['等待审批', WAITING], ['运行结束、结果还挂着', SUCCEEDED]]) {
|
||
await setLod(page, 'full')
|
||
await feed(page, events(Date.now()))
|
||
await page.waitForTimeout(400)
|
||
const near = await badges()
|
||
await setLod(page, 'signal')
|
||
const away = await badges()
|
||
check(`${name}:卡片上不挂校验角标(近景、信号档都没有),状态标记是这时唯一的主角`,
|
||
near.length === 0 && away.length === 0, JSON.stringify({ near, away }))
|
||
}
|
||
await setLod(page, 'signal')
|
||
shots.push(await shot(page, 'dark-run-signal-no-issue-badges'))
|
||
await page.evaluate(() => window.__studio.getState().clearRun())
|
||
await setLod(page, 'full')
|
||
await page.waitForTimeout(200)
|
||
check('清掉运行结果回到编辑态:角标回来', (await badges()).length === 3, JSON.stringify(await badges()))
|
||
issues = []
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 出口密的分支
|
||
|
||
await section('exits', async () => {
|
||
console.log('\n=== 两出口分支:往上拐的线不穿过自己的出口标签(100% 缩放) ===')
|
||
// 协作节点比别的卡高,排版把「快速」那一支顶到分支上方:上面那个出口的线一出来就往上拐。
|
||
// 以前拐弯落在走廊正中,正好压在「快速模式」四个字的尾巴上。标签太长、走廊放不下时截短
|
||
const branchGraph = (fastLabel) => ({
|
||
nodes: [
|
||
{ id: 'in', type: 'input', data: { label: '问题', config: { fields: [{ name: 'q' }] } } },
|
||
{ id: 'prep', type: 'llm', data: { label: '整理问题', config: { prompt: '{{ input.q }}' } } },
|
||
{ id: 'gate', type: 'branch', data: { label: '模式分支', config: { mode: 'expression', cases: [
|
||
{ key: 'fast', condition: "input.q == 'a'", label: fastLabel },
|
||
{ key: 'team', condition: 'true', label: '协作模式' },
|
||
] } } },
|
||
{ id: 'quick', type: 'llm', data: { label: '快速回答', config: { prompt: '{{ input.q }}' } } },
|
||
{ id: 'crew', type: 'supervisor', data: { label: '协作团队', config: { goal: '研究', max_rounds: 3,
|
||
agents: ['检索', '分析', '撰写'].map((n) => ({ name: n, description: n, system: n, tools: [] })) } } },
|
||
{ id: 'out', type: 'output', data: { label: '成果', config: { fields: [{ name: 'r' }] } } },
|
||
],
|
||
edges: [
|
||
{ id: 'x1', source: 'in', target: 'prep' }, { id: 'x2', source: 'prep', target: 'gate' },
|
||
{ id: 'x3', source: 'gate', target: 'quick', sourceHandle: 'fast' },
|
||
{ id: 'x4', source: 'gate', target: 'crew', sourceHandle: 'team' },
|
||
{ id: 'x5', source: 'quick', target: 'out' }, { id: 'x6', source: 'crew', target: 'out' },
|
||
],
|
||
})
|
||
for (const [variant, fastLabel] of [['四个字', '快速模式'], ['放不下的长标签', '按客户分层的快速处理通道']]) {
|
||
const graph = await (await fetch(`${API}/copilot/layout`, {
|
||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ graph: branchGraph(fastLabel) }),
|
||
})).json()
|
||
for (const theme of ['dark', 'light']) {
|
||
const workflow = { ...WORKFLOW, id: 'wcard-two-exits', name: '__two_exits__', graph }
|
||
const { browser, page, errors } = await open({ theme, workflow })
|
||
await page.locator('.react-flow__controls button[aria-label="恢复为 100% 缩放"]').click()
|
||
await page.waitForTimeout(400)
|
||
await page.evaluate(() => window.__studio.getState().focusNode('gate'))
|
||
await page.waitForTimeout(900)
|
||
const r = await page.evaluate(() => {
|
||
const n = document.querySelector('.react-flow__node[data-id="gate"]')
|
||
const labels = [...n.querySelectorAll('.nc-exit')].map((el) => {
|
||
const b = el.getBoundingClientRect()
|
||
return { t: el.textContent, l: b.left, r: b.right, top: b.top, b: b.bottom, cut: el.scrollWidth > el.clientWidth + 1 }
|
||
})
|
||
const up = window.__studio.getState().edges.find((e) => e.source === 'gate' && e.sourceHandle === 'fast')
|
||
const hits = []
|
||
for (const e of window.__studio.getState().edges.filter((x) => x.source === 'gate')) {
|
||
const p = document.querySelector(`.react-flow__edge[data-id="${CSS.escape(e.id)}"] .react-flow__edge-path`)
|
||
if (!p) { hits.push(`${e.id} 没画出来`); continue }
|
||
const len = p.getTotalLength()
|
||
const m = p.getScreenCTM()
|
||
for (let s = 0; s <= len; s += 1) {
|
||
const pt = p.getPointAtLength(s)
|
||
const x = pt.x * m.a + m.e
|
||
const y = pt.y * m.d + m.f
|
||
const hit = labels.find((b) => x > b.l + 1 && x < b.r - 1 && y > b.top + 1 && y < b.b - 1)
|
||
if (hit) { hits.push(`${hit.t}@${Math.round(x)},${Math.round(y)}`); break }
|
||
}
|
||
}
|
||
const zoom = getComputedStyle(document.querySelector('.react-flow')).getPropertyValue('--zoom').trim()
|
||
return { labels, hits, zoom, upward: !!up }
|
||
})
|
||
check(`${variant} · ${theme}: 100% 缩放下分支出口标签不被走线穿过`, r.zoom === '1' && r.upward && r.hits.length === 0,
|
||
`${r.zoom} ${r.hits.join(',')}`)
|
||
const fast = r.labels.find((l) => l.t === fastLabel)
|
||
if (variant === '四个字') check(`${variant} · ${theme}: 走廊放得下就写全,不截`, !!fast && !fast.cut, JSON.stringify(fast))
|
||
else check(`${variant} · ${theme}: 放不下的标签截短(省略号),不伸进拐弯`, !!fast && fast.cut, JSON.stringify(fast))
|
||
const box = await page.locator('.react-flow__node[data-id="gate"]').boundingBox()
|
||
const path = `${SHOTS}/${theme}-two-exits-${variant === '四个字' ? 'short' : 'long'}.png`
|
||
await page.screenshot({ path, clip: { x: box.x - 10, y: box.y - 90, width: box.width + 200, height: box.height + 180 } })
|
||
shots.push(path)
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
}
|
||
}
|
||
|
||
console.log('\n=== 六出口分支:标签不压别的出口的线 ===')
|
||
const cases = ['A', 'B', 'C', 'D', 'E'].map((k, i) => ({ key: `c${i}`, condition: `vars.x == ${i}`, label: `产线${k}` }))
|
||
const graph = await (await fetch(`${API}/copilot/layout`, {
|
||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ graph: {
|
||
nodes: [
|
||
{ id: 'in', type: 'input', data: { label: '入口', config: { fields: [{ name: 'x' }] } } },
|
||
{ id: 'br', type: 'branch', data: { label: '六路分流', config: { mode: 'expression', cases } } },
|
||
...cases.map((c, i) => ({ id: `o${i}`, type: 'output', data: { label: `出口${i}`, config: { fields: [{ name: 'r' }] } } })),
|
||
{ id: 'od', type: 'output', data: { label: '兜底', config: { fields: [{ name: 'r' }] } } },
|
||
],
|
||
edges: [
|
||
{ source: 'in', target: 'br' },
|
||
...cases.map((c, i) => ({ source: 'br', target: `o${i}`, sourceHandle: c.key })),
|
||
{ source: 'br', target: 'od', sourceHandle: 'default' },
|
||
],
|
||
} }),
|
||
})).json()
|
||
const workflow = { ...WORKFLOW, id: 'wcard-six-exits', name: '__six_exits__', graph }
|
||
for (const theme of ['dark', 'light']) {
|
||
const { browser, page, errors } = await open({ theme, workflow })
|
||
await setLod(page, 'full')
|
||
const info = await page.evaluate(() => {
|
||
const n = document.querySelector('.react-flow__node[data-id="br"]')
|
||
const labels = [...n.querySelectorAll('.nc-exit')].map((el) => { const r = el.getBoundingClientRect(); return { t: el.textContent, top: r.top, bottom: r.bottom } })
|
||
const handles = [...n.querySelectorAll('.react-flow__handle.source')].map((el) => { const r = el.getBoundingClientRect(); return r.top + r.height / 2 })
|
||
return { labels, handles, dense: n.querySelector('.nc').classList.contains('nc-exits-dense') }
|
||
})
|
||
const covers = []
|
||
info.labels.forEach((l, i) => info.handles.forEach((y, j) => {
|
||
if (j !== i && y > l.top && y < l.bottom) covers.push(`${l.t} 压住第 ${j + 1} 条`)
|
||
}))
|
||
const own = info.labels.every((l, i) => info.handles[i] > l.top && info.handles[i] < l.bottom)
|
||
check(`${theme}: 六个出口都画了标签`, info.labels.length === 6 && info.handles.length === 6, `${info.labels.length} / ${info.handles.length}`)
|
||
check(`${theme}: 出口密时标签骑在自己那条线上`, info.dense && own)
|
||
check(`${theme}: 没有标签压住别的出口的线`, covers.length === 0, covers.join(','))
|
||
const box = await page.locator('.react-flow__node[data-id="br"]').boundingBox()
|
||
const path = `${SHOTS}/${theme}-six-exits.png`
|
||
await page.screenshot({ path, clip: { x: box.x - 20, y: box.y - 30, width: box.width + 200, height: box.height + 60 } })
|
||
shots.push(path)
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 被驳回的那一步
|
||
|
||
await section('rejected', async () => {
|
||
// 驳回是人的决定,不是故障(05 (a)、总方案 X9):被驳回的那一步节点层仍发 node.failed,但带 reason=rejected。画布上它画成
|
||
// 「被驳回」——中性色、停止的剪影,不是失败的三角、红框、抖动;下游是「未到达」,不是「被阻断」;运行胶囊的计数写被驳回。
|
||
// 没有 reason 的老运行照旧画失败
|
||
console.log('\n=== 被驳回的那一步 ===')
|
||
const REJECT_TAIL = [
|
||
[3, 'human.resolved', 'human', { approved: false, note: '数字对不上', actor: '李工' }],
|
||
[0.05, 'run.resumed', null, { actor: '李工' }],
|
||
[0.05, 'node.started', 'human', { resumed: true }],
|
||
[0.1, 'node.failed', 'human', { duration_ms: 40, error: '人工驳回:数字对不上', reason: 'rejected', kind: 'approve',
|
||
decision: 'reject', actor: '李工', note: '数字对不上' }],
|
||
[0.05, 'run.cancelled', null, { reason: 'rejected', node_id: 'human', label: '班长审批', kind: 'approve', decision: 'reject',
|
||
actor: '李工', note: '数字对不上', timing: { wall_ms: 60000, active_ms: 40000, wait_ms: 20000 } }],
|
||
]
|
||
const REJECTED = script([...HEAD, ...TEAM_ROUND0, ...TEAM_DONE, ...LOOP_DONE, ...TO_GATE, ...REJECT_TAIL])
|
||
const LEGACY = script([...HEAD, ...TEAM_ROUND0, ...TEAM_DONE, ...LOOP_DONE, ...TO_GATE,
|
||
...REJECT_TAIL.slice(0, 3),
|
||
[0.1, 'node.failed', 'human', { duration_ms: 40, error: '人工驳回:数字对不上' }],
|
||
[0.05, 'run.failed', null, { error: '人工驳回:数字对不上', node_id: 'human', timing: { wall_ms: 60000, active_ms: 40000, wait_ms: 20000 } }]])
|
||
const { browser, page, errors } = await open({ theme: 'dark' })
|
||
await setLod(page, 'full')
|
||
await feed(page, REJECTED(Date.now()))
|
||
await page.waitForTimeout(400)
|
||
const all = await cards(page)
|
||
check('被驳回的那一步:卡片状态是 rejected', all.human?.state === 'rejected', all.human?.state)
|
||
check('……剪影是停止(和取消同一个),不是失败的三角', all.human?.shape === 'stop', all.human?.shape)
|
||
check('……槽里写「被驳回」和原话的第一句', /被驳回/.test(all.human?.text ?? '') && /数字对不上/.test(all.human?.text ?? ''),
|
||
JSON.stringify(all.human?.text))
|
||
check('……不写「失败」', !/失败/.test(all.human?.text ?? ''), JSON.stringify(all.human?.text))
|
||
const look = await page.evaluate(() => {
|
||
const card = document.querySelector('.react-flow__node[data-id="human"] .nc')
|
||
const note = card?.querySelector('.nc-tele-note')
|
||
return { failedClass: card?.classList.contains('node-failed'), rejectedClass: card?.classList.contains('node-rejected'),
|
||
noteTitle: note?.getAttribute('title') ?? '' }
|
||
})
|
||
check('……不挂失败的红框(node-failed),挂 node-rejected', !look.failedClass && look.rejectedClass, JSON.stringify(look))
|
||
check('……悬停写原话(谁驳回、为什么)', look.noteTitle.includes('数字对不上'), look.noteTitle)
|
||
check('下游是「未到达」,不是「被阻断」', all.out?.state === 'unreached', all.out?.state)
|
||
const failedId = await page.evaluate(() => window.__studio.getState().trace.failedNodeId ?? null)
|
||
check('航迹里没有「失败的节点」', failedId === null, String(failedId))
|
||
check('运行收成 cancelled(不是 failed)', await page.evaluate(() => window.__studio.getState().runPhase) === 'cancelled')
|
||
shots.push(await shot(page, 'dark-rejected'))
|
||
|
||
await feed(page, LEGACY(Date.now()))
|
||
await page.waitForTimeout(400)
|
||
const old = await cards(page)
|
||
check('老运行(node.failed 没有 reason)照旧画失败、下游被阻断', old.human?.state === 'failed' && old.out?.state === 'blocked',
|
||
`${old.human?.state} / ${old.out?.state}`)
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 驳回出口
|
||
|
||
await section('reject-exit', async () => {
|
||
// 驳回出口(编排完善 §3):审批接了「驳回」出口,被驳回时这一步正常结束(node.finished 带 outcome=rejected),沿驳回
|
||
// 那条边往下走。画布上它画「被驳回」(中性色、停止的剪影),槽里写转到哪一步;驳回出口的边走过了、下游照常跑完,
|
||
// 普通出口那一侧未到达;运行收成成功(途经驳回),不是取消
|
||
console.log('\n=== 驳回出口 ===')
|
||
const EXIT_TAIL = [
|
||
[3, 'human.resolved', 'human', { approved: false, note: '数字对不上', actor: '李工' }],
|
||
[0.05, 'run.resumed', null, { actor: '李工' }],
|
||
[0.05, 'node.started', 'human', { resumed: true }],
|
||
[0.1, 'node.finished', 'human', { duration_ms: 40, outcome: 'rejected', kind: 'approve', decision: 'reject',
|
||
actor: '李工', note: '数字对不上', to: [{ node_id: 'fix', label: '补数工单' }],
|
||
preview: { rejected: true, approved: false, decision: 'reject', kind: 'approve', note: '数字对不上',
|
||
text: '人工驳回:数字对不上', __decision__: 'rejected' } }],
|
||
[0.1, 'node.started', 'fix', {}],
|
||
[0.1, 'node.finished', 'fix', { duration_ms: 5, preview: { 缺口清单: '退回:数字对不上' } }],
|
||
[0.05, 'run.finished', null, { output: { 缺口清单: '退回:数字对不上' },
|
||
rejected_via: [{ node_id: 'human', label: '班长审批', kind: 'approve', decision: 'reject', actor: '李工',
|
||
note: '数字对不上', to: [{ node_id: 'fix', label: '补数工单' }] }],
|
||
timing: { wall_ms: 60000, active_ms: 40000, wait_ms: 20000 } }],
|
||
]
|
||
const EXITED = script([...HEAD, ...TEAM_ROUND0, ...TEAM_DONE, ...LOOP_DONE, ...TO_GATE, ...EXIT_TAIL])
|
||
const { browser, page, errors } = await open({ theme: 'dark' })
|
||
await setLod(page, 'full')
|
||
await feed(page, EXITED(Date.now()))
|
||
await page.waitForTimeout(400)
|
||
const all = await cards(page)
|
||
check('走驳回出口的那一步:卡片状态是 rejected', all.human?.state === 'rejected', all.human?.state)
|
||
check('……剪影是停止,不是失败的三角', all.human?.shape === 'stop', all.human?.shape)
|
||
check('……槽里写转到哪一步', /被驳回,转到「补数工单」/.test(all.human?.text ?? ''), JSON.stringify(all.human?.text))
|
||
check('……不写「失败」', !/失败/.test(all.human?.text ?? ''), JSON.stringify(all.human?.text))
|
||
check('驳回出口那一步照常跑完', all.fix?.state === 'done', all.fix?.state)
|
||
check('普通出口那一步「未到达」', all.out?.state === 'unreached', all.out?.state)
|
||
const edges = await page.evaluate(() => Object.fromEntries(window.__studio.getState().edges
|
||
.filter((e) => e.source === 'human').map((e) => {
|
||
const el = document.querySelector(`.react-flow__edge[data-id="${CSS.escape(e.id)}"]`)
|
||
return [e.sourceHandle, el ? [...el.classList].filter((c) => c.startsWith('sf-e-')).join(' ') : '没画出来']
|
||
})))
|
||
check('驳回出口的边走过了,批准那条没走', /sf-e-walked/.test(edges.rejected ?? '') && /sf-e-unwalked/.test(edges.approved ?? ''),
|
||
JSON.stringify(edges))
|
||
const state = await page.evaluate(() => ({ phase: window.__studio.getState().runPhase,
|
||
failed: window.__studio.getState().trace.failedNodeId ?? null }))
|
||
check('运行收成成功(途经驳回),没有「失败的节点」', state.phase === 'succeeded' && state.failed === null, JSON.stringify(state))
|
||
// 出口:驳回出口是中性色,写「驳回」
|
||
const exit = await page.evaluate(() => {
|
||
const labels = [...document.querySelectorAll('.react-flow__node[data-id="human"] .nc-exit')].map((el) => el.textContent)
|
||
return labels
|
||
})
|
||
check('审批的两个出口:批准、驳回', exit.join() === '批准,驳回', exit.join())
|
||
shots.push(await shot(page, 'dark-reject-exit'))
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 3).join(' | '))
|
||
await browser.close()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 关掉动效
|
||
|
||
await section('reduced', async () => {
|
||
console.log('\n=== 系统关了动效 ===')
|
||
const { browser, page } = await open({ theme: 'dark', reduced: true })
|
||
await setLod(page, 'full')
|
||
await feed(page, LIVE(Date.now()))
|
||
// 跟随执行会把视口挪到在跑的节点上;截图要看全图,关掉跟随再取一次景
|
||
await page.evaluate(() => window.__studio.getState().setFollow(false))
|
||
await setLod(page, 'full')
|
||
await page.waitForTimeout(600)
|
||
const anim = await runningAnimations(page)
|
||
check('画布上一个在播的动画都没有', countOf(anim) === 0, JSON.stringify(anim))
|
||
const drawn = await page.evaluate(() => ({
|
||
halo: document.querySelector('.fx-halo') ? getComputedStyle(document.querySelector('.fx-halo')).display : 'none',
|
||
tele: document.querySelector('.react-flow__node[data-id="body"] .nc-tele')?.innerText,
|
||
slot: getComputedStyle(document.querySelector('.react-flow__node[data-id="body"] .nc-slot')).backgroundColor,
|
||
}))
|
||
check('光弧不再绘制', drawn.halo === 'none', drawn.halo)
|
||
check('静态下仍看得出在跑:状态字 + 计时', /运行中\s*\d\d:\d\d/.test(drawn.tele ?? ''), JSON.stringify(drawn.tele))
|
||
check('静态下仍看得出在跑:左侧实心状态槽', drawn.slot !== 'rgba(0, 0, 0, 0)', drawn.slot)
|
||
shots.push(await shot(page, 'dark-live-reduced'))
|
||
await browser.close()
|
||
})
|
||
|
||
console.log(`\n截图 ${shots.length} 张:${SHOTS}`)
|
||
if (reloads) console.log(`注意:测试中页面被重载或 store 被热更新重建了 ${reloads} 次,失败项请重跑确认`)
|
||
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
|
||
process.exit(failed ? 1 : 0)
|