Files
AgentLab/scripts/check-canvas-fx.mjs
T
ThearchyHelios 587bf7e27c 第三波前两批:六个「看着成功其实没干活」的平台漏洞堵上,记录页能回放,管理页与画布收尾
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」,
校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮,
轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。

平台可信度(先写失败测试)
- Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则,
  工具集合变小时自查报警、改图回执列出变化
- 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修
- 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量
- 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因
- 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员
- 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行

后端
- 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、
  SQLite progress handler),可按数据源配置;超时报错与实际耗时一致
- 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存;
  自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存;
  检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors

前端
- 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时,
  一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子
- 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚;
  回放用量取那一刻的值
- 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口
- 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对
- 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条
- 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问

验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿;
check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
2026-09-27 10:16:32 +08:00

1272 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 画布表层的检查:把脚本化的事件灌进 store,看画布、运行胶囊、航迹坞真的跟上了。
//
// 为什么不是"真跑一张图":多 agent 那张图要连模型、要等十几秒,而且撞不上
// "三个人同时还在跑"那一瞬间——检查会变成偶发失败。dev 构建把 store 挂在
// window.__studio 上,这里直接往里灌事件:走的是页面上那一份 applyEvent /
// NodeCard / FlowEdge / RunHud,只有事件来源是脚本。
//
// 不写库:工作流、会话、已发布版本全用 page.route 在浏览器里伪造,后端只被
// 读(目录、校验、排版这些纯计算)。以前这里会在库里建一张 __fx_check__,
// 别的检查打开 /studio 落到第一张图时会撞上它。
//
// 守的是几件"事件对了但界面没跟上"的事:
// - 取消 / 挂起之后光弧、光点、协作行还在转;
// - 等人时入边还在流光点,工具栏还给一个必然 409 的「停止」;
// - 失败后「接着跑」出不来;胶囊的计时不走;
// - 跑完之后看不出执行路径;拖时间轴卡片不跟着变;
// - 打开大图缩到 0.2;正式运行期间还能拖动改图;
// - 一次性时刻被补发的历史事件触发,或者播完不撤;
// - 定位 / 去审批取景半路被拉远,落地卡在 compact 档(错误正文被藏);
// - 「只看执行路径」带进下一次运行;正式运行横幅盖住「恢复」;
// - 同一刻的等人时长几处写几个数;只是点一下画布就暂停跟随;
// - Esc 在右栏里按也清掉整次运行;泳道、节点格、坞高只能用鼠标;
// - 运行中点节点,属性面板整块盖住右栏的运行视图(应当先请右栏滚到它的步骤);
// - 等审批时只能批不能放弃;回放时回边的 ×N、面板的用量拿终值冒充那一刻;
// - Copilot 搭图时新节点落在视口外看不见;双击空白处只会缩放;1024 宽打开只看得见两张半卡;
// - Copilot 删掉的节点一声不响就没了,看不出删的是哪个、原来在哪。
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'
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: ['web_search'] })
const GRAPH = {
nodes: [
{ id: 'start', type: 'input', data: { label: '问题', config: { fields: [{ name: 'q', required: true }] } } },
{ id: 'team', type: 'supervisor', data: { label: '研究协作团队', config: {
goal: '多角度研究', max_rounds: 4, max_parallel: 3,
agents: [agent('检索员', '找资料'), agent('分析员', '交叉验证'),
agent('撰写员', '写成结论'), agent('审校', '核对数字')],
} } },
{ id: 'gate', type: 'branch', data: { label: '质量门', config: { mode: 'expression', cases: [
{ key: 'ok', condition: "vars.verdict == 'ok'", label: '通过' },
{ key: 'fail', condition: "vars.verdict == 'fail'", label: '打回' },
] } } },
{ id: 'review', type: 'human', data: { label: '主管审批', config: { mode: 'approve', title: '看一眼再发' } } },
{ id: 'retry', type: 'loop', data: { label: '返工重试', config: { mode: 'while', condition: 'x', max_iterations: 5 } } },
{ id: 'fix', type: 'llm', data: { label: '返工', config: { prompt: '按意见改' } } },
{ id: 'done', type: 'output', data: { label: '出具', config: { fields: [{ name: 'r' }] } } },
],
edges: [
{ id: 'e-start-team', source: 'start', target: 'team' },
{ id: 'e-team-gate', source: 'team', target: 'gate' },
{ id: 'e-gate-review', source: 'gate', target: 'review', sourceHandle: 'ok' },
{ id: 'e-gate-retry', source: 'gate', target: 'retry', sourceHandle: 'fail' },
{ id: 'e-retry-fix', source: 'retry', target: 'fix', sourceHandle: 'body' },
{ id: 'e-fix-retry', source: 'fix', target: 'retry' },
{ id: 'e-retry-done', source: 'retry', target: 'done', sourceHandle: 'done' },
{ id: 'e-review-done', source: 'review', target: 'done', sourceHandle: 'approved' },
],
}
/** 一条长链:取景策略要以入口为左锚、不低于可读缩放 */
const WIDE = {
nodes: Array.from({ length: 12 }, (_, i) => ({
id: `n${i}`, type: i === 0 ? 'input' : i === 11 ? 'output' : 'llm',
data: { label: i === 0 ? '入口' : `第 ${i} 步`, config: i === 0 ? { fields: [{ name: 'q' }] } : { prompt: 'x' } },
})),
edges: Array.from({ length: 11 }, (_, i) => ({ id: `w${i}`, source: `n${i}`, target: `n${i + 1}` })),
}
// ---------------------------------------------------------------- 事件
const BASE = Date.now() / 1000 - 45
// t 是相对 BASE 的秒数:段有真实宽度,时间轴才有东西可拖
const ev = (seq, type, node_id, data = {}, t = seq) => ({ seq, type, node_id, data, ts: BASE + t })
const PARALLEL = [
ev(1, 'run.started', null, { nodes: 7 }, 0),
ev(2, 'node.started', 'start', {}, 0.2),
ev(3, 'node.finished', 'start', { duration_ms: 12, preview: { q: 'x' } }, 0.3),
ev(4, 'node.started', 'team', { node_type: 'supervisor' }, 0.5),
ev(5, 'log', 'team', { level: 'info', round: 0, agents: ['检索员', '分析员', '撰写员'], done: false,
parallel: 3, reason: '三个方向互不依赖', message: '调度 → 检索员、分析员、撰写员 · 3 人同时进行' }, 3),
ev(6, 'agent.step.start', 'team', { agent: '检索员', instruction: '把资料找全', round: 0, parallel: 3 }, 3.1),
ev(7, 'agent.step.start', 'team', { agent: '分析员', instruction: '交叉验证', round: 0, parallel: 3 }, 3.2),
ev(8, 'agent.step.start', 'team', { agent: '撰写员', instruction: '写成结论', round: 0, parallel: 3 }, 3.3),
]
// 并行那一段刚开始:最后一条事件离"现在"很近,实时计时从这里往前走
const PARALLEL_NOW = PARALLEL.map((e) => ({ ...e, ts: e.ts + 40 }))
const TEAM_DONE = [
ev(9, 'agent.step.end', 'team', { agent: '检索员', duration_ms: 6600, round: 0, parallel: 3, preview: '8 篇' }, 9.7),
ev(10, 'agent.step.end', 'team', { agent: '分析员', duration_ms: 9800, round: 0, parallel: 3, preview: '2 处矛盾' }, 13),
ev(11, 'agent.step.end', 'team', { agent: '撰写员', duration_ms: 4200, round: 0, parallel: 3, preview: '草案' }, 13.1),
ev(12, 'node.finished', 'team', { duration_ms: 13000, preview: { text: '结论' } }, 13.5),
]
const TO_REVIEW = [
ev(13, 'node.started', 'gate', {}, 13.6),
ev(14, 'edge.taken', 'gate', { branch: 'ok', reason: "命中条件:vars.verdict == 'ok'", mode: 'expression' }, 13.7),
ev(15, 'node.finished', 'gate', { duration_ms: 3, preview: { __decision__: 'ok' } }, 13.8),
ev(16, 'node.started', 'review', {}, 14),
ev(17, 'human.requested', 'review', { node_id: 'review', mode: 'approve', title: '看一眼再发' }, 14.1),
]
const TO_LOOP = [
ev(13, 'node.started', 'gate', {}, 14),
ev(14, 'edge.taken', 'gate', { branch: 'fail', reason: "命中条件:vars.verdict == 'fail'", mode: 'expression' }, 14.1),
ev(15, 'node.finished', 'gate', { duration_ms: 3, preview: { __decision__: 'fail' } }, 14.2),
ev(16, 'node.started', 'retry', {}, 14.5),
ev(17, 'edge.taken', 'retry', { branch: 'body', iteration: 0, mode: 'while' }, 14.6),
ev(18, 'node.finished', 'retry', { duration_ms: 1, preview: { __decision__: 'body', iteration: 0 } }, 14.7),
ev(19, 'node.started', 'fix', {}, 15),
ev(20, 'node.finished', 'fix', { duration_ms: 8000, preview: { text: '改好了' } }, 23),
ev(21, 'node.started', 'retry', {}, 23.2),
ev(22, 'edge.taken', 'retry', { branch: 'done', iteration: 1, mode: 'while' }, 23.3),
ev(23, 'node.finished', 'retry', { duration_ms: 1, preview: { __decision__: 'done', iteration: 1 } }, 23.4),
ev(24, 'node.started', 'done', {}, 24),
ev(25, 'node.finished', 'done', { duration_ms: 400, preview: { r: 'ok' } }, 24.4),
ev(26, 'run.finished', null, { output: { r: 'ok' }, usage: { input_tokens: 1200, output_tokens: 840, cost_usd: 0.0123 },
duration_ms: 24000, timing: { wall_ms: 24500, active_ms: 24500, wait_ms: 0 } }, 24.5),
]
// ---------------------------------------------------------------- 伪造的后端
const FX_ID = 'fx-canvas'
const WIDE_ID = 'fx-wide'
async function layout(graph) {
const res = await fetch(`${API}/copilot/layout`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ graph }),
})
if (!res.ok) throw new Error(`排版接口 ${res.status}`)
return res.json()
}
const [LAID, LAID_WIDE] = await Promise.all([layout(GRAPH), layout(WIDE)])
const workflow = (id, name, graph, extra = {}) => ({
id, name, description: '', graph, tags: [], version: 3, is_template: false, status: 'draft',
published_version: 2, run_count: 0, created_at: '2026-09-26T00:00:00Z', updated_at: '2026-09-26T00:00:00Z', ...extra,
})
const FAKES = {
[FX_ID]: workflow(FX_ID, '画布表层检查', LAID),
[WIDE_ID]: workflow(WIDE_ID, '取景检查', LAID_WIDE, { published_version: null }),
}
// 已发布的 v2 入口字段和画布上的不一样:正式运行的表单必须照 v2 填
const VERSION = { id: 'v2', version: 2, note: '', workflow_id: FX_ID, graph: LAID, graph_hash: 'x',
input_fields: [{ name: 'topic', required: true, description: '已发布版本的字段' }], published: true }
async function fakeBackend(page) {
const json = (route, body, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await page.route(/\/api\/workflows(\?.*)?$/, async (route) => {
if (route.request().method() !== 'GET') return route.continue()
const res = await route.fetch()
const real = await res.json().catch(() => [])
return json(route, [...Object.values(FAKES), ...(Array.isArray(real) ? real : [])])
})
await page.route(/\/api\/workflows\/fx-[a-z]+(\/.*)?(\?.*)?$/, (route) => {
const url = new URL(route.request().url())
const [, , , id, sub, v] = url.pathname.split('/')
if (sub === 'versions' && v) return json(route, VERSION)
if (route.request().method() === 'GET' && !sub) return json(route, FAKES[id])
return json(route, { detail: '检查脚本不写库' }, 409)
})
// 画布会话:只读列表返回空,建会话返回一个假的,都不落库
await page.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (route) => {
const method = route.request().method()
if (method === 'GET') return json(route, [])
return json(route, { id: 'fx-conv', kind: 'canvas', title: '', turns: [] })
})
await page.route(/\/api\/runs(\/.*)?(\?.*)?$/, (route) =>
route.request().method() === 'GET' ? route.continue() : json(route, { detail: '检查脚本不发起运行' }, 409))
}
async function openStudio({ id = FX_ID, theme = 'dark', ...extra } = {}) {
const browser = await chromium.launch({ executablePath: CHROME })
const ctx = await browser.newContext({ viewport: { width: 1440, height: 950 }, colorScheme: theme, ...extra })
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' && !m.text().includes('404') && !m.text().includes('409')) errors.push(m.text()) })
await fakeBackend(page)
// 主题:设置接口里存的偏好会在启动时盖过本地缓存,不改它的话「暗色」那一遍画出来是亮的
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.goto(`${WEB}/studio/${id}`, { waitUntil: 'networkidle' })
await page.waitForFunction((wid) => window.__studio?.getState().workflow?.id === wid, id, { timeout: 15000 })
await page.waitForTimeout(700)
return { browser, page, errors }
}
const feed = (page, events) => page.evaluate((list) => {
const s = window.__studio.getState()
for (const e of list) s.applyEvent(e)
}, events)
/** 灌事件前先放一个运行对象:真实流程里它来自 POST /runs(queued 的快照) */
const seedRun = (page, extra = {}) => page.evaluate((x) => {
window.__studio.setState({ run: {
id: 'fxrun000001', workflow_id: 'fx-canvas', workflow_name: '画布表层检查', status: 'queued', input: {},
output: {}, error: null, usage: {}, run_class: 'exploratory', version: null, ...x,
} })
}, extra)
const count = (page, sel) => page.locator(sel).count()
/** 轮询到条件成立或超时。取景有动画、热更新可能插一脚,固定等几百毫秒会偶发失败 */
async function until(fn, { timeout = 3000, every = 100 } = {}) {
const end = Date.now() + timeout
for (;;) {
const v = await fn().catch(() => false)
if (v || Date.now() > end) return v
await new Promise((r) => setTimeout(r, every))
}
}
/** 开发服务器热更新会把页面整个重载(别的代理恰好在改代码),store 里灌的运行就没了 */
const markBoot = (page) => page.evaluate(() => { window.__fxBoot = true })
const reloaded = (page) => page.evaluate(() => !window.__fxBoot).catch(() => true)
const capsule = (page) => page.locator('.sf-capsule')
/** 工具栏上的「运行」发起按钮(胶囊的名字也以「运行中」开头,按全名找) */
const launcher = (page) => page.locator('[data-run-control]').getByRole('button', { name: /^运行$/ })
const capText = async (page) => (await capsule(page).innerText().catch(() => '')).replace(/\s+/g, ' ')
const edgeClass = (page, id) => page.evaluate((eid) =>
document.querySelector(`.react-flow__edge[data-id="${eid}"]`)?.getAttribute('class') ?? '', id)
/** 画布上一处空白(不压在卡片、控件、浮条上):从那儿拖才是平移画布 */
const emptySpot = (page) => page.evaluate(() => {
const r = document.querySelector('.react-flow__pane').getBoundingClientRect()
for (let y = r.top + 90; y < r.bottom - 120; y += 23) {
for (let x = r.left + 30; x < r.right - 180; x += 37) {
if (document.elementFromPoint(x, y)?.classList.contains('react-flow__pane')) return { x, y }
}
}
return { x: r.left + 30, y: r.top + 90 }
})
const nodeState = (page, id) => page.evaluate((nid) => {
const el = document.querySelector(`.react-flow__node[data-id="${nid}"] [data-state]`)
return el?.getAttribute('data-state') ?? null
}, id)
// ---------------------------------------------------------------- 运行中
console.log('=== 运行中:胶囊、光点、计时 ===')
{
const { browser, page, errors } = await openStudio()
await seedRun(page)
await feed(page, PARALLEL_NOW)
await page.waitForTimeout(400)
check('运行中的卡片有光弧', await count(page, '.node-running .fx-halo, [data-state="running"] .fx-halo') > 0)
check('协作矩阵长出来了', await count(page, '.team-matrix') > 0)
check('三个成员同时在跑', await count(page, '.team-row-running') >= 3, `${await count(page, '.team-row-running')} 行`)
check('流入执行中节点的边:一个光点', await count(page, '.react-flow__edge.sf-e-flow .edge-packet') === 1,
`${await count(page, '.edge-packet')} 个光点`)
check('走过的边是实线(sf-e-walked)', (await edgeClass(page, 'e-start-team')).includes('sf-e-flow')
|| (await edgeClass(page, 'e-start-team')).includes('sf-e-walked'), await edgeClass(page, 'e-start-team'))
const cap = await capText(page)
check('胶囊替换了工具栏的「停止」:写着运行中', cap.includes('运行中'), cap)
check('运行中的主动作是停止', await capsule(page).getByRole('button', { name: /停止/ }).count() === 1)
check('运行中不露发起按钮', await launcher(page).count() === 0)
const t1 = await page.locator('[data-clock]').innerText()
await page.waitForTimeout(1300)
const t2 = await page.locator('[data-clock]').innerText()
check('胶囊计时在走', t1 !== t2 && /^\d\d:\d\d\.\d$/.test(t2), `${t1} → ${t2}`)
// 展开遥测面板:墙钟、节点格、并行、时限
await capsule(page).locator('.sf-cap-main').click()
const hud = page.locator('.sf-hud')
const hudText = (await hud.innerText()).replace(/\s+/g, ' ')
check('面板说出了并行路数', /3 路/.test(hudText), hudText.match(/并行 [^ ]+ [^ ]+/)?.[0])
check('节点格按拓扑序每节点一格', await count(page, '.sf-cell') === 7, `${await count(page, '.sf-cell')} 格`)
check('面板有墙钟 T+', /T\+\d\d:\d\d\.\d/.test(hudText))
check('时限余量来自 /api/system', /余 \d+:\d\d/.test(hudText), hudText.match(/时限[^用]*/)?.[0])
check('还没收到用量时写「—」,不写 0 tok', /用量 — —/.test(hudText) && !/(^|\s)0 tok/.test(hudText),
hudText.match(/用量[^时]*/)?.[0])
const cells = await page.evaluate(() => ({
items: document.querySelectorAll('.sf-cells > [role="listitem"] > button.sf-cell').length,
tabbable: document.querySelectorAll('.sf-cells button.sf-cell[tabindex="0"]').length,
}))
check('节点格是列表项里的按钮,整条只占一个 Tab 位', cells.items === 7 && cells.tabbable === 1, JSON.stringify(cells))
await page.locator('.sf-cells button.sf-cell[tabindex="0"]').focus()
await page.keyboard.press('ArrowRight')
const cellAt = await page.evaluate(() => [...document.querySelectorAll('.sf-cells button.sf-cell')].indexOf(document.activeElement))
check('节点格:左右键在格子间走', cellAt === 1, String(cellAt))
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 收起面板', await count(page, '.sf-hud') === 0)
check('坞头开关有名字(窄屏只剩图标时读屏也念得出)',
await page.getByRole('checkbox', { name: '跟随执行' }).count() === 1
&& await page.getByRole('checkbox', { name: '压缩空闲' }).count() === 1)
check('运行中不给「只看执行路径」:路径要等结束才定下来',
await page.getByRole('checkbox', { name: '只看执行路径' }).count() === 0)
// 只是点一下画布空白处(取消选择):d3 也报开始和结束,但视口没动,不该暂停跟随
await page.locator('.react-flow__pane').click({ position: { x: 20, y: 300 } })
await page.waitForTimeout(200)
check('点一下画布不算手动平移:跟随照常', !(await page.locator('.sf-follow').innerText()).includes('暂停'),
await page.locator('.sf-follow').innerText())
// 泳道、坞高:键盘也能用
const lanes = await page.evaluate(() => ({
n: document.querySelectorAll('.tl-lane[role="button"]').length,
tab: document.querySelectorAll('.tl-lane[tabindex="0"]').length,
}))
check('泳道是按钮,整片只占一个 Tab 位', lanes.n >= 7 && lanes.tab === 1, JSON.stringify(lanes))
await page.locator('.tl-lane[tabindex="0"]').focus()
await page.keyboard.press('ArrowDown')
const nextLane = await page.evaluate(() => document.activeElement?.getAttribute('data-node-id'))
await page.evaluate(() => window.__studio.setState({ focusRequest: null }))
await page.keyboard.press('Enter')
const picked = await page.evaluate(() => window.__studio.getState().focusRequest?.id)
check('泳道:下键换下一条,回车在画布上取景', nextLane === 'team' && picked === 'team', `${nextLane} → ${picked}`)
const grip = page.locator('.tl-grip')
const h0 = Number(await grip.getAttribute('aria-valuenow'))
await grip.focus()
await page.keyboard.press('ArrowUp')
const h1 = Number(await grip.getAttribute('aria-valuenow'))
check('坞高可以用键盘调(上键加高 16px)', h1 === h0 + 16, `${h0} → ${h1}`)
check('航迹坞出现了', await count(page, '.tl') === 1)
check('坞里按拓扑序排了泳道', await count(page, '.tl-lane') >= 7, `${await count(page, '.tl-lane')} 条`)
const lod = await page.evaluate(() => {
const rf = document.querySelector('.react-flow')
return { lod: rf?.dataset.lod, zoom: rf?.style.getPropertyValue('--zoom'), phase: rf?.dataset.runPhase,
rank: document.querySelector('.react-flow__node[data-id="gate"]')?.style.getPropertyValue('--rank') }
})
check('根元素写了 data-lod / --zoom', !!lod.lod && !!lod.zoom, JSON.stringify(lod))
check('根元素写了 data-run-phase', lod.phase === 'running', lod.phase)
check('节点上写了 --rank', lod.rank === '2', `gate 的 --rank=${lod.rank}`)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- 等人
console.log('=== 等人:入边闸门,不流光点;去审批代替停止 ===')
{
const { browser, page } = await openStudio()
await markBoot(page)
await seedRun(page)
await feed(page, [...PARALLEL, ...TEAM_DONE, ...TO_REVIEW])
await page.waitForTimeout(400)
// human.requested 到了、run.interrupted 还没来(并行分支要等同一超步跑完)
const cls = await edgeClass(page, 'e-gate-review')
check('入边改成闸门(sf-e-held)', cls.includes('sf-e-held'), cls)
check('入边上没有光点', await count(page, '.react-flow__edge[data-id="e-gate-review"] .edge-packet') === 0)
check('闸门画出来了', await count(page, '.react-flow__edge[data-id="e-gate-review"] .sf-gate') === 1)
check('run.interrupted 之前相位还是运行中,工具栏不给「去审批」',
(await capText(page)).includes('运行中') && await capsule(page).getByRole('button', { name: /去审批/ }).count() === 0,
await capText(page))
await feed(page, [ev(18, 'run.interrupted', 'review', { payload: { node_id: 'review' } }, 14.3)])
await page.waitForTimeout(300)
const cap = await capText(page)
check('胶囊写着等待审批', cap.includes('等待审批'), cap)
check('主动作是去审批', await capsule(page).getByRole('button', { name: /去审批/ }).count() === 1)
check('等审批时不给必然 409 的停止', await capsule(page).getByRole('button', { name: /停止/ }).count() === 0)
check('等人时全图 0 个光点', await count(page, '.edge-packet') === 0, `${await count(page, '.edge-packet')} 个`)
check('小地图上等人的节点放大了一圈', await count(page, '.sf-mm-ring.sf-mm-waiting') === 1)
// 去审批:属性面板盖在右栏上时先让开,画布取景到等人的节点。右栏(助手面板)接
// agentlab:goto-approval 事件切到运行层、滚到审批卡;它接了(preventDefault)胶囊就不再自己找
await page.evaluate(() => {
window.__goto = []
window.addEventListener('agentlab:goto-approval', (e) => { window.__goto.push(e.detail); e.preventDefault() })
window.__studio.setState({ focusRequest: null, selectedId: 'gate' })
// 胶囊自己兜底时在整页里找审批卡(document.querySelector);右栏接手时只在自己的
// 滚动区里找。数一下前者,就知道胶囊有没有在右栏接手之后还去找
window.__slotLookups = 0
const find = Document.prototype.querySelector
window.__restoreQuery = () => { Document.prototype.querySelector = find }
Document.prototype.querySelector = function (sel) {
if (String(sel).includes('data-approval-slot')) window.__slotLookups += 1
return find.call(this, sel)
}
})
await capsule(page).getByRole('button', { name: /去审批/ }).click()
await page.waitForTimeout(200)
const went = await page.evaluate(() => ({ sel: window.__studio.getState().selectedId, focus: window.__studio.getState().focusRequest?.id }))
check('去审批:属性面板让开、取景到等人的节点', went.sel === null && went.focus === 'review', JSON.stringify(went))
const goto = await page.evaluate(() => window.__goto)
check('去审批:广播给右栏(带运行和节点)', goto.length === 1 && goto[0].nodeId === 'review' && goto[0].runId === 'fxrun000001',
JSON.stringify(goto))
// 描边、滚动都不能拿来判断——右栏接手时自己也描同一圈、滚到同一张卡
await page.waitForTimeout(600)
const lookups = await page.evaluate(() => { window.__restoreQuery(); return window.__slotLookups })
check('右栏接手之后胶囊不再自己找卡片、不报「还没出现」', lookups === 0
&& !(await page.locator('[aria-live]').allInnerTexts()).join('').includes('审批卡还没出现'), `整页找了 ${lookups} 次`)
// F:在需要处理的队列里逐个定位
await page.evaluate(() => window.__studio.setState({ focusRequest: null }))
await page.locator('.react-flow__pane').click({ position: { x: 20, y: 300 } })
await page.keyboard.press('f')
await page.waitForTimeout(200)
const focus = await page.evaluate(() => window.__studio.getState().focusRequest?.id)
check('F 定位到等人的节点', focus === 'review', String(focus))
// 放弃这次运行:等审批时的次级动作。关掉的审批找不回来,先问一句;确认后才调 /cancel
let cancels = 0
await page.route(/\/api\/runs\/fxrun000001\/cancel$/, (route) => {
cancels += 1
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, status: 'cancelled' }) })
})
const abandon = capsule(page).getByRole('button', { name: '放弃这次运行' })
check('等审批时有「放弃这次运行」', await abandon.count() === 1)
await abandon.click()
const ask = page.getByRole('dialog').filter({ hasText: '放弃这次运行?' })
await ask.waitFor({ timeout: 3000 }).catch(() => {})
const askText = (await ask.innerText().catch(() => '')).replace(/\s+/g, ' ')
check('放弃之前先问一句,写清后果', askText.includes('审批卡一并关闭') && cancels === 0, askText.slice(0, 80))
await ask.getByRole('button', { name: '放弃这次运行' }).click().catch(() => {})
await until(async () => cancels === 1, { timeout: 2000 })
check('确认后才调 /cancel', cancels === 1, String(cancels))
await feed(page, [ev(19, 'run.cancelled', null, {
timing: { wall_ms: 20000, active_ms: 14000, wait_ms: 6000 }, actor: '检查员', message: '放弃了这次运行,1 条待审批一并关闭',
}, 20)])
await page.waitForTimeout(300)
// 胶囊不见了多半是别的代理改代码触发了整页热更新(灌进 store 的运行没了):报出来,别卡 30 秒崩掉
const opened = await capsule(page).locator('.sf-cap-main').click({ timeout: 5000 }).then(() => true).catch(() => false)
const line = opened ? await page.locator('.sf-hud-line').innerText().catch(() => '') : ''
check('取消之后说清谁放弃的、关了什么', line.includes('检查员 放弃了这次运行,1 条待审批一并关闭'),
opened ? line : `胶囊不见了${await reloaded(page) ? ':页面被热更新重载过,重跑确认' : ''}`)
await browser.close()
}
console.log('=== 等人:同一刻的等待时长各处同一个数 ===')
{
// 打开一条正在等审批的运行(?run= 接上时补发的历史)。事件挪到"刚才":等了七八秒,
// 段够宽、写得下字;补发的历史不参与时钟偏差,等待时长按真实时间走
const { browser, page } = await openStudio()
await seedRun(page)
const shift = Date.now() / 1000 - 22 - BASE
const history = [...PARALLEL, ...TEAM_DONE, ...TO_REVIEW,
ev(18, 'run.interrupted', 'review', { payload: { node_id: 'review' } }, 14.3)]
await feed(page, history.map((e) => ({ ...e, ts: e.ts + shift, replay: true })))
await page.waitForTimeout(400)
await capsule(page).locator('.sf-cap-main').click()
await page.waitForTimeout(300)
// 一次读完:所有计时共用一拍,同一次提交里读到的是同一刻
const waits = await page.evaluate(() => {
const txt = (el) => el?.textContent ?? ''
const find = (sel, head) => [...document.querySelectorAll(sel)].map(txt).find((t) => t.startsWith(head)) ?? ''
const raw = {
header: txt(document.querySelector('.tl-alert.is-warn')),
headline: txt(document.querySelector('.sf-hud-line')),
queue: txt(document.querySelector('.sf-queue-item.is-waiting .sf-dim')),
metric: find('.sf-metric-s', '等人'),
runBar: find('.tl-run .tl-bar em', '等人'),
lane: find('.tl-lane[data-node-id="review"] .tl-bar em', '等待审批'),
sum: txt(document.querySelector('.tl-run .tl-sum')),
}
// 十分之一秒为单位;合计一分钟以上写 m:ss,只比到秒
const val = (k, s) => {
let m = /(\d+):(\d\d)\.(\d)/.exec(s)
if (m) return Number(m[1]) * 600 + Number(m[2]) * 10 + Number(m[3])
m = /等 (\d+)\.(\d) s/.exec(s)
if (m) return Number(m[1]) * 10 + Number(m[2])
m = /等 (\d+):(\d\d)/.exec(s)
return m ? { secs: Number(m[1]) * 60 + Number(m[2]) } : null
}
return { raw, vals: Object.fromEntries(Object.entries(raw).map(([k, s]) => [k, val(k, s)])) }
})
const base = waits.vals.header
const same = typeof base === 'number' && Object.values(waits.vals).every((v) =>
v === base || (v && typeof v === 'object' && v.secs === Math.floor(base / 10)))
check('坞头、面板标题、需要处理、等人合计、运行行、泳道、右边合计:同一个数', same, JSON.stringify(waits.raw))
check('等了几秒而不是 0', typeof base === 'number' && base >= 30, String(base))
await browser.close()
}
// ---------------------------------------------------------------- 失败 / 取消 / 挂起
const settleCase = async (label, tail, expect) => {
console.log(`=== ${label} ===`)
const { browser, page } = await openStudio()
await seedRun(page)
await feed(page, [...PARALLEL, ...tail])
await page.waitForTimeout(500)
check('光弧全部收掉', await count(page, '.fx-halo') === 0, `${await count(page, '.fx-halo')} 个`)
check('光点全部收掉', await count(page, '.edge-packet') === 0, `${await count(page, '.edge-packet')} 个`)
check('协作行不再"进行中"', await count(page, '.team-row-running') === 0, `${await count(page, '.team-row-running')} 行`)
const cap = await capText(page)
check(`胶囊写着${expect.text}`, cap.includes(expect.text), cap)
for (const name of expect.actions) {
check(`有「${name}」`, await capsule(page).getByRole('button', { name: new RegExp(name) }).count() >= 1)
}
check('不再给停止', await capsule(page).getByRole('button', { name: /停止/ }).count() === 0)
check('发起按钮回来了', await launcher(page).count() === 1)
await expect.more?.(page)
await browser.close()
}
await settleCase('失败:接着跑、定位,下游阻断', [
ev(9, 'node.failed', 'team', { error: '401 模型 id 不存在,改成可用的模型再接着跑', duration_ms: 6000 }, 9.5),
ev(10, 'run.failed', null, { error: '「研究协作团队」失败', node_id: 'team', timing: { wall_ms: 9600, active_ms: 9600, wait_ms: 0 } }, 9.6),
], {
text: '失败', actions: ['接着跑', '定位'],
more: async (page) => {
check('下游的边画成阻断(sf-e-cut)', await count(page, '.react-flow__edge.sf-e-cut') > 0,
`${await count(page, '.react-flow__edge.sf-e-cut')} 条`)
check('小地图上失败的节点放大了一圈', await count(page, '.sf-mm-ring.sf-mm-failed') === 1)
const phase = await page.evaluate(() => document.querySelector('.react-flow')?.dataset.runPhase)
check('根元素的相位是 failed', phase === 'failed', phase)
await capsule(page).getByRole('button', { name: /定位/ }).click()
const focus = await page.evaluate(() => window.__studio.getState().focusRequest?.id)
check('定位请求画布取景到失败节点', focus === 'team', String(focus))
// 取景落地要是完整卡片:d3 默认插值半路会拉远到 0.4–0.5,回滞又让它停在 compact,
// 失败卡片的错误正文就被藏了。先拖开,再定位;再缩到 compact,再定位
const lodNow = () => page.evaluate(() => {
const rf = document.querySelector('.react-flow')
return { lod: rf?.dataset.lod, zoom: Number(rf?.style.getPropertyValue('--zoom')) }
})
await page.waitForTimeout(600)
const pane = await page.locator('.react-flow__pane').boundingBox()
await page.mouse.move(pane.x + 400, pane.y + 300)
await page.mouse.down()
await page.mouse.move(pane.x + 60, pane.y + 150, { steps: 6 })
await page.mouse.up()
await page.waitForTimeout(300)
await page.evaluate(() => {
const rf = document.querySelector('.react-flow')
window.__minZoom = Number(rf.style.getPropertyValue('--zoom'))
new MutationObserver(() => {
window.__minZoom = Math.min(window.__minZoom, Number(rf.style.getPropertyValue('--zoom')))
}).observe(rf, { attributes: true, attributeFilter: ['style'] })
})
const start = await lodNow()
await capsule(page).getByRole('button', { name: /定位/ }).click()
await page.waitForTimeout(700)
const landed = await lodNow()
const dip = await page.evaluate(() => window.__minZoom)
check('定位途中不拉远', dip >= Math.min(start.zoom, 0.6) - 1e-3, `起点 ${start.zoom} · 途中最低 ${dip}`)
check('定位落地:可读缩放、完整卡片', landed.lod === 'full' && landed.zoom >= 0.6 - 1e-3, JSON.stringify(landed))
for (let i = 0; i < 2; i++) await page.locator('.react-flow__controls-zoomout').click()
await page.waitForTimeout(400)
const compact = await lodNow()
await capsule(page).getByRole('button', { name: /定位/ }).click()
await page.waitForTimeout(700)
const back = await lodNow()
check('从 compact 定位回来也回到完整卡片', compact.lod !== 'full' && back.lod === 'full' && back.zoom >= 0.6 - 1e-3,
`${JSON.stringify(compact)} → ${JSON.stringify(back)}`)
},
})
await settleCase('取消:清除、回放', [
ev(9, 'run.cancelled', null, { timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }, 9),
], { text: '已取消', actions: ['清除', '回放'] })
await settleCase('服务重启挂起:接着跑', [
ev(9, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关停时这次运行还在跑,已挂起' }, 9),
], {
text: '已中断', actions: ['接着跑'],
more: async (page) => {
await capsule(page).locator('.sf-cap-main').click()
await page.waitForTimeout(200)
const counts = (await page.locator('.sf-hud-counts').innerText()).replace(/\s+/g, ' ')
check('面板的状态计数里有「已中断」的节点,点得到', await count(page, '.sf-count[title^="已中断"]') === 1, counts)
},
})
// ---------------------------------------------------------------- 跑完
console.log('=== 跑完:执行路径、回边、回放、清除 ===')
{
const { browser, page, errors } = await openStudio()
await seedRun(page)
// 分两批灌:同一秒里只放一个一次性时刻,开场点亮和成功回扫挤在一起时后一个会被让掉
await feed(page, PARALLEL)
await page.waitForTimeout(1100)
await feed(page, [...TEAM_DONE, ...TO_LOOP])
await page.waitForTimeout(150)
const moment = await page.evaluate(() => ({
kind: document.querySelector('.react-flow')?.dataset.moment, sweeps: document.querySelectorAll('.sf-sweep').length,
}))
check('实时跑完:根元素写了成功时刻,走过的边回扫一遍', moment.kind === 'success' && moment.sweeps > 0, JSON.stringify(moment))
await page.waitForTimeout(2800)
check('时刻只播一次:过后撤掉', await page.evaluate(() => !document.querySelector('.react-flow')?.dataset.moment)
&& await count(page, '.sf-sweep') === 0)
const cap = await capText(page)
check('胶囊写着已完成', cap.includes('已完成'), cap)
check('结束后给回放和清除',
await capsule(page).getByRole('button', { name: /回放/ }).count() === 1
&& await capsule(page).getByRole('button', { name: /清除/ }).count() === 1)
check('走过的边留在画布上', await count(page, '.react-flow__edge.sf-e-walked') >= 5,
`${await count(page, '.react-flow__edge.sf-e-walked')} 条`)
check('没走的那一侧虚线淡出', (await edgeClass(page, 'e-gate-review')).includes('sf-e-unwalked'),
await edgeClass(page, 'e-gate-review'))
check('回边可辨(虚线 + ↺)', (await edgeClass(page, 'e-fix-retry')).includes('sf-e-back')
&& await count(page, '.sf-loop-badge') === 1)
check('每条边都有箭头', await count(page, '.sf-arrow') === GRAPH.edges.length,
`${await count(page, '.sf-arrow')} / ${GRAPH.edges.length}`)
check('没有常驻的光点', await count(page, '.edge-packet') === 0)
await capsule(page).locator('.sf-cap-main').click()
const hud = (await page.locator('.sf-hud').innerText()).replace(/\s+/g, ' ')
check('用量按后端累计校正(2,040 tokens)', /2\.0k tok/.test(hud) && hud.includes('$0.012'), hud.match(/用量[^时]*/)?.[0])
check('结束后节点数写「经过」', /6\/7 经过/.test(hud), hud.match(/节点 [^ ]+ [^ ]+/)?.[0])
await page.keyboard.press('Escape')
// 航迹坞和画布、右栏联动:悬停泳道 = 悬停节点,点泳道 = 画布取景
const lane = page.locator('.tl-lane[data-node-id="gate"]').first()
await lane.hover()
await page.waitForTimeout(100)
check('悬停泳道:hoveredNodeId 跟着变', await page.evaluate(() => window.__studio.getState().hoveredNodeId) === 'gate')
await lane.click()
await page.waitForTimeout(100)
check('点泳道:画布取景到那个节点', await page.evaluate(() => window.__studio.getState().focusRequest?.id) === 'gate')
await page.mouse.move(700, 200)
await page.evaluate(() => window.__studio.getState().setHoveredNode('fix'))
await page.waitForTimeout(100)
check('右栏悬停某一步:对应泳道高亮', await count(page, '.tl-lane.is-hover[data-node-id="fix"]') === 1)
await page.evaluate(() => window.__studio.getState().setHoveredNode(null))
// 拖时间轴:游标拖到很早的时候,卡片回到那一刻
const before = await nodeState(page, 'done')
const axis = page.locator('.tl-axis .tl-track')
const box = await axis.boundingBox()
await page.mouse.move(box.x + box.width * 0.2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + box.width * 0.12, box.y + box.height / 2, { steps: 4 })
await page.mouse.up()
await page.waitForTimeout(300)
const replayAt = await page.evaluate(() => window.__studio.getState().replayAt)
const after = await nodeState(page, 'done')
const teamAt = await nodeState(page, 'team')
check('拖游标进入回放', replayAt != null)
check('卡片状态随游标变(出具:完成 → 那一刻还没开始)', before === 'done' && after !== 'done', `${before} → ${after}`)
check('那一刻协作团队还在跑', teamAt === 'running', String(teamAt))
check('回放时胶囊说明在回放', (await capText(page)).includes('回放'), await capText(page))
const replayEdge = await edgeClass(page, 'e-retry-done')
check('回放时边也回到那一刻(出具的入边还没走)', !replayEdge.includes('sf-e-walked'), replayEdge)
// 游标之后压暗的那层不能盖住游标头:泳道滚上去之后也不能
const head = await page.evaluate(() => {
const layers = [...document.querySelectorAll('.tl-future, .tl-cursor, .tl-fold')]
layers.forEach((el) => { el.style.pointerEvents = 'auto' })
const probe = () => {
const h = document.querySelector('.tl-cursor-head').getBoundingClientRect()
return [h.left + 3, h.left + h.width / 2, h.right - 3]
.every((x) => document.elementFromPoint(x, h.top + h.height / 2)?.closest('.tl-cursor-head'))
}
const top = probe()
const sc = document.querySelector('.tl-scroll')
sc.scrollTop = 60
const scrolled = probe()
const scrollable = sc.scrollTop > 0
sc.scrollTop = 0
layers.forEach((el) => { el.style.pointerEvents = '' })
return { top, scrolled, scrollable }
})
check('回放:游标头整块露在最上面(压暗、竖线都不盖它)', head.top && head.scrolled, JSON.stringify(head))
// 坞里的中文不小于 11px(只有纯数字的刻度用 10.5px)
const sizes = await page.evaluate(() => {
const px = (el) => parseFloat(getComputedStyle(el).fontSize)
const fold = document.createElement('span')
fold.className = 'tl-fold-tag'
document.querySelector('.tl-axis .tl-track').appendChild(fold)
const out = {
bar: Math.min(...[...document.querySelectorAll('.tl-bar em')].map(px)),
head: px(document.querySelector('.tl-cursor-head')),
fold: px(fold),
}
fold.remove()
return out
})
check('段上的字、折叠标签、游标头都不小于 11px', sizes.bar >= 11 && sizes.head >= 11 && sizes.fold >= 11,
JSON.stringify(sizes))
await page.locator('.tl-seg').getByRole('button', { name: /实时/ }).click()
await page.waitForTimeout(200)
check('切回实时', await page.evaluate(() => window.__studio.getState().replayAt) === null)
check('回到实时后卡片落回终态', await nodeState(page, 'done') === 'done')
// 只看执行路径:没走的节点和边压暗。开着它去清除,看下一次运行会不会沿用
await page.locator('.tl-toggle', { hasText: '只看执行路径' }).click()
await page.waitForTimeout(200)
check('只看执行路径:没走的节点压暗', await count(page, '.react-flow__node.sf-offpath') === 1,
`${await count(page, '.react-flow__node.sf-offpath')} 个`)
// Esc 只认画布这一片:焦点在右栏的步骤上时按 Esc 不该清掉整次运行
const inPanel = await page.evaluate(() => {
const row = [...document.querySelectorAll('[data-node-id]')].find((el) => !el.closest('.react-flow, .tl'))
const btn = row?.matches('button') ? row : row?.querySelector('button')
btn?.focus()
return !!btn && document.activeElement === btn
})
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('焦点在右栏时 Esc 不清结果', inPanel && await page.evaluate(() => window.__studio.getState().runPhase) === 'succeeded',
inPanel ? '' : '右栏没找到带 data-node-id 的步骤行')
// Esc 清除结果,回到编辑态
await page.locator('.react-flow__pane').click({ position: { x: 20, y: 300 } })
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
check('Esc 清除结果回到编辑态', await page.evaluate(() => window.__studio.getState().runPhase) === 'idle')
check('清除后航迹坞收起', await count(page, '.tl') === 0)
check('清除后没有运行痕迹的边', await count(page, '.react-flow__edge.sf-e-walked') === 0)
// 清除之前「只看执行路径」是开着的:下一次运行不能把还没走到的下游压暗
await seedRun(page, { id: 'fxrun000002' })
await feed(page, PARALLEL_NOW)
await page.waitForTimeout(400)
const carried = await page.evaluate(() => ({
nodes: document.querySelectorAll('.react-flow__node.sf-offpath').length,
edges: document.querySelectorAll('.react-flow__edge.sf-e-offpath').length,
attr: document.querySelector('.react-flow')?.dataset.pathOnly ?? null,
}))
check('「只看执行路径」不带进下一次运行', carried.nodes === 0 && carried.edges === 0 && carried.attr === null,
JSON.stringify(carried))
await feed(page, [ev(9, 'run.cancelled', null, { timing: { wall_ms: 44000, active_ms: 44000, wait_ms: 0 } }, 44)])
await page.waitForTimeout(300)
const toggle = page.getByRole('checkbox', { name: '只看执行路径' })
check('那次运行结束后开关是关着的', await toggle.count() === 1 && !(await toggle.isChecked()))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
console.log('=== 补发的历史(attachRun 回放):直接落终态,不播时刻 ===')
{
const { browser, page } = await openStudio()
await seedRun(page)
const replayed = [...PARALLEL, ...TEAM_DONE, ...TO_LOOP].map((e) => ({ ...e, replay: true }))
await feed(page, replayed.slice(0, PARALLEL.length))
await page.waitForTimeout(1100)
await feed(page, replayed.slice(PARALLEL.length))
await page.waitForTimeout(150)
const seen = await page.evaluate(() => ({
moment: document.querySelector('.react-flow')?.dataset.moment ?? null, sweeps: document.querySelectorAll('.sf-sweep').length,
phase: document.querySelector('.react-flow')?.dataset.runPhase,
}))
check('历史事件不触发一次性时刻', seen.moment === null && seen.sweeps === 0, JSON.stringify(seen))
check('历史事件照样落到终态', seen.phase === 'succeeded', seen.phase)
await browser.close()
}
// ---------------------------------------------------------------- 正式运行
console.log('=== 正式运行:入口看 published_version;运行期间画布只读 ===')
{
const { browser, page } = await openStudio()
// 保存过一次(status 退回草稿)且画布有未保存改动:正式运行入口照样在
await page.evaluate(() => window.__studio.setState({ dirty: true }))
const formal = page.getByRole('button', { name: /正式运行 v2/ })
check('草稿状态下正式运行入口还在(看 published_version)', await formal.count() === 1)
check('画布有未保存改动时也能点', await formal.isEnabled())
// 运行类别的记号:正式运行实心圆点、探索运行空心;盾牌只留给发布等级,不在发起入口上混用
const marks = (sel) => page.evaluate((sel) => {
const root = document.querySelector(sel)
return {
formal: root?.querySelectorAll('svg.sf-dot[data-class="formal"]').length ?? 0,
exploratory: root?.querySelectorAll('svg.sf-dot[data-class="exploratory"]').length ?? 0,
shield: root?.querySelectorAll('.lucide-shield-check, svg[class*="shield"]').length ?? 0,
}
}, sel)
const bar = await marks('[data-run-control]')
check('正式运行入口是实心圆点,没有盾牌', bar.formal === 1 && bar.shield === 0, JSON.stringify(bar))
await formal.click()
await page.waitForSelector('.sf-pop', { timeout: 5000 })
await page.waitForTimeout(400)
const pop = (await page.locator('.sf-pop').innerText()).replace(/\s+/g, ' ')
check('提示不含画布改动', pop.includes('不含画布改动'), pop.slice(0, 80))
check('表单字段取已发布版本的(topic),不取画布的(q)',
await page.locator('.sf-pop textarea#run-field-topic').count() === 1
&& await page.locator('.sf-pop textarea#run-field-q').count() === 0)
const formalPop = await marks('.sf-pop')
check('正式运行浮层的标题和确认按钮都是实心圆点', formalPop.formal === 2 && formalPop.exploratory === 0 && formalPop.shield === 0,
JSON.stringify(formalPop))
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
await launcher(page).click()
await page.waitForSelector('.sf-pop', { timeout: 5000 })
const explorePop = await marks('.sf-pop')
check('探索运行浮层是空心圆点', explorePop.exploratory === 1 && explorePop.formal === 0 && explorePop.shield === 0,
JSON.stringify(explorePop))
await page.keyboard.press('Escape')
await page.evaluate(() => window.__studio.setState({ dirty: false }))
await seedRun(page, { run_class: 'formal', version: 2 })
await feed(page, PARALLEL_NOW)
await page.waitForTimeout(400)
check('只读横幅出现', (await page.locator('.sf-banner').innerText().catch(() => '')).includes('画布只读'))
const ro = await page.evaluate(() => document.querySelector('.react-flow')?.dataset)
check('根元素标了正式运行和只读', ro?.runClass === 'formal' && ro?.readonly === '1', JSON.stringify(ro))
const pos = () => page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'gate').position)
const p0 = await pos()
const card = await page.locator('.react-flow__node[data-id="gate"]').boundingBox()
await page.mouse.move(card.x + 40, card.y + 14)
await page.mouse.down()
await page.mouse.move(card.x + 160, card.y + 120, { steps: 6 })
await page.mouse.up()
await page.waitForTimeout(200)
const p1 = await pos()
check('正式运行期间拖拽被禁', p0.x === p1.x && p0.y === p1.y, `${JSON.stringify(p0)} → ${JSON.stringify(p1)}`)
await browser.close()
}
for (const width of [1280, 1024]) {
console.log(`=== 正式运行 @${width}:只读横幅不盖住跟随那一枚 ===`)
const { browser, page } = await openStudio({ viewport: { width, height: 860 } })
await seedRun(page, { run_class: 'formal', version: 2 })
await feed(page, PARALLEL_NOW)
await page.waitForTimeout(400)
// 手动拖一下画布,让跟随那一枚变成「已暂停跟随 · 恢复」:恢复得点得到
const spot = await emptySpot(page)
await page.mouse.move(spot.x, spot.y)
await page.mouse.down()
await page.mouse.move(spot.x + 60, spot.y + 30, { steps: 4 })
await page.mouse.up()
await page.waitForTimeout(250)
const geo = await page.evaluate(() => {
const r = (sel) => document.querySelector(sel)?.getBoundingClientRect()
const a = r('.sf-follow')
const b = r('.sf-banner')
const btn = document.querySelector('.sf-follow button')
const br = btn?.getBoundingClientRect()
const hit = br ? document.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2) : null
const overlap = !!a && !!b && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom
return { overlap, resume: !!btn && hit === btn, follow: a && [Math.round(a.left), Math.round(a.right)],
banner: b && [Math.round(b.left), Math.round(b.right), Math.round(b.top)] }
})
check('横幅和跟随那一枚不重叠', !geo.overlap && !!geo.banner && !!geo.follow, JSON.stringify(geo))
check('「恢复」点得到', geo.resume)
await browser.close()
}
// ---------------------------------------------------------------- 取景
console.log('=== 打开宽图:以入口为左锚,缩放不低于可读下限 ===')
{
const { browser, page, errors } = await openStudio({ id: WIDE_ID })
await page.waitForTimeout(600)
const view = await page.evaluate(() => {
const vp = document.querySelector('.react-flow__viewport')
const m = /matrix\(([^)]+)\)|scale\(([^)]+)\)/.exec(getComputedStyle(vp).transform)
const zoom = m ? Number((m[1] ?? m[2]).split(',')[0]) : NaN
const pane = document.querySelector('.react-flow').getBoundingClientRect()
const entry = document.querySelector('.react-flow__node[data-id="n0"]').getBoundingClientRect()
return { zoom, entryLeft: entry.left - pane.left, entryRight: entry.right - pane.left, paneW: pane.width }
})
check('缩放不低于 0.6', view.zoom >= 0.6 - 1e-6, `zoom=${view.zoom.toFixed(3)}`)
check('入口在视口左侧、完整可见', view.entryLeft >= 0 && view.entryRight < view.paneW / 2,
`left=${view.entryLeft.toFixed(0)} right=${view.entryRight.toFixed(0)} / ${view.paneW.toFixed(0)}`)
check('没有常驻的航迹坞(没有运行)', await count(page, '.tl') === 0)
const zoomText = await page.locator('.sf-zoom-btn').innerText()
check('控件里有缩放读数', zoomText.trim() === `${Math.round(view.zoom * 100)}%`, zoomText)
check('控件是中文的(适配全图)', await page.getByRole('button', { name: '适配全图' }).count() === 1)
// 跟随执行:视口外的节点开跑时镜头平移过去;人手动拖过画布之后让出 10 秒
const inView = (id) => page.evaluate((nid) => {
const pane = document.querySelector('.react-flow').getBoundingClientRect()
const r = document.querySelector(`.react-flow__node[data-id="${nid}"]`).getBoundingClientRect()
return r.left >= pane.left && r.right <= pane.right && r.top >= pane.top && r.bottom <= pane.bottom
}, id)
const vp = () => page.evaluate(() => document.querySelector('.react-flow__viewport').style.transform)
const t0 = Date.now() / 1000
const wev = (seq, type, node_id, dt) => ({ seq, type, node_id, data: {}, ts: t0 + dt })
await markBoot(page)
await seedRun(page, { workflow_id: WIDE_ID })
await feed(page, [wev(1, 'run.started', null, 0), wev(2, 'node.started', 'n0', 0.1), wev(3, 'node.finished', 'n0', 0.2)])
// 航迹坞升起来、画布变矮之后再量:坞出现时保持中心的那一下也会挪视口
await page.waitForSelector('.tl', { timeout: 3000 }).catch(() => {})
await page.waitForTimeout(300)
check('n9 起初在视口外', !(await inView('n9')))
const vpFrom = await vp()
await feed(page, [wev(4, 'node.started', 'n9', 0.3)])
// 等视口真的动了、并且停在 n9 上:取景有 320ms 的动画,固定等一段时间会偶发落空
const followed = await until(async () => (await vp()) !== vpFrom && await inView('n9'))
const why = followed ? '' : JSON.stringify({
reloaded: await reloaded(page),
phase: await page.evaluate(() => window.__studio?.getState().runPhase).catch(() => null),
follow: await page.evaluate(() => window.__studio?.getState().follow).catch(() => null),
moved: (await vp()) !== vpFrom,
})
check('跟随执行:视口外的节点开跑,镜头跟过去', !!followed, why)
// 等这一趟飞完,再让下一个远处的节点开跑,趁镜头飞到一半时人抓住画布拖一下。
// React Flow 把被打断的那段动画的收尾推迟一拍才报,报到时人的拖动已经开始——
// 以前它把人的起点吃掉,拖完跟随照样不暂停
await until(async () => { const a = await vp(); await new Promise((r) => setTimeout(r, 120)); return a === await vp() })
await feed(page, [wev(5, 'node.finished', 'n9', 0.4), wev(6, 'node.started', 'n1', 0.5)])
await page.waitForTimeout(110)
const pane = await page.locator('.react-flow__pane').boundingBox()
await page.mouse.move(pane.x + 300, pane.y + 60)
await page.mouse.down()
await page.mouse.move(pane.x + 500, pane.y + 90, { steps: 5 })
await page.mouse.up()
await page.waitForTimeout(200)
const paused = await vp()
check('镜头飞到一半被人拖住:显示「已暂停跟随」',
(await page.locator('.sf-follow').innerText({ timeout: 3000 }).catch(() => '')).includes('已暂停跟随'))
await feed(page, [wev(7, 'node.finished', 'n1', 0.6), wev(8, 'node.started', 'n2', 0.7)])
await page.waitForTimeout(700)
check('暂停期间不再抢镜头', await vp() === paused)
// 点名取景:已暂停也照做(是人要看它)
await page.evaluate(() => window.__studio.getState().focusNode('n11'))
check('focusNode:画布取景到点名的节点', !!(await until(() => inView('n11'))))
// 画布尺寸恰好在取景半路变了(航迹坞收起、升起):保持中心那一下会打断动画,
// 打断之后要重新飞到目标,不能停在半路
await page.evaluate(() => window.__studio.getState().focusNode('n0'))
await page.waitForTimeout(90)
await page.locator('.tl').getByRole('button', { name: '收起航迹' }).click().catch(() => {})
check('取景半路画布变高:照样落到目标上', !!(await until(() => inView('n0'))))
await page.locator('.tl').getByRole('button', { name: '展开' }).click().catch(() => {})
await page.evaluate(() => window.__studio.getState().clearRun())
await page.waitForTimeout(200)
// 缩小到远景:语义缩放换档
for (let i = 0; i < 6; i++) await page.locator('.react-flow__controls-zoomout').click()
await page.waitForTimeout(400)
const lod = await page.evaluate(() => document.querySelector('.react-flow')?.dataset.lod)
check('缩小后 LOD 换到 compact / signal', lod === 'compact' || lod === 'signal', String(lod))
await page.locator('.sf-zoom-btn').click()
await page.waitForTimeout(500)
check('点缩放读数回到 100%', (await page.locator('.sf-zoom-btn').innerText()).trim() === '100%'
&& await page.evaluate(() => document.querySelector('.react-flow')?.dataset.lod) === 'full')
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- 运行中点节点
console.log('=== 运行中点节点:先看它的步骤,属性面板是次级入口 ===')
{
const { browser, page, errors } = await openStudio()
await seedRun(page)
await feed(page, PARALLEL_NOW)
await page.waitForTimeout(400)
await page.evaluate(() => {
window.__reveal = []
window.addEventListener('agentlab:reveal-step', (e) => window.__reveal.push(e.detail))
})
const sheet = () => count(page, '[data-inspector-sheet]')
const head = (id) => page.locator(`.react-flow__node[data-id="${id}"] .nc-head`)
await head('start').click()
await page.waitForTimeout(250)
const picked = await page.evaluate(() => ({ sel: window.__studio.getState().selectedId, reveal: window.__reveal }))
check('点跑过的节点:选中它', picked.sel === 'start', String(picked.sel))
check('属性面板不自动盖上右栏', await sheet() === 0)
check('请右栏滚到它的步骤(agentlab:reveal-step)', picked.reveal.length === 1 && picked.reveal[0].nodeId === 'start'
&& picked.reveal[0].runId === 'fxrun000001', JSON.stringify(picked.reveal))
const peek = page.locator('.sf-peek')
check('画布上给出「…的配置」入口', await peek.count() === 1 && (await peek.innerText()).includes('问题'), await peek.innerText().catch(() => ''))
await peek.click()
await page.waitForTimeout(250)
check('点入口才打开属性面板', await sheet() === 1 && await count(page, '.sf-peek') === 0)
check('打开之后底部留着回到运行的那一条', await count(page, '[data-inspector-sheet] [data-run-phase]') === 1)
// 没跑过的节点在右栏里没有步骤可看:照常打开属性面板
await page.locator('.react-flow__pane').click({ position: { x: 20, y: 300 } })
await page.waitForTimeout(150)
await page.evaluate(() => { window.__reveal = [] })
await head('gate').click()
await page.waitForTimeout(250)
check('点没跑过的节点:照常打开属性面板,不请右栏滚动', await sheet() === 1
&& await page.evaluate(() => window.__reveal.length) === 0)
// 双击跑过的节点:直接看配置
await page.locator('.react-flow__pane').click({ position: { x: 20, y: 300 } })
await page.waitForTimeout(150)
await head('team').dblclick()
await page.waitForTimeout(250)
check('双击节点直接打开属性面板', await sheet() === 1 && await page.evaluate(() => window.__studio.getState().selectedId) === 'team')
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 收起属性面板、取消选中,运行还在', await sheet() === 0
&& await page.evaluate(() => window.__studio.getState().runPhase) === 'running')
// 先看了步骤、运行随后结束:节点还选中着、面板没开。再点它(选中没变)也得打开面板
await head('start').click()
await page.waitForTimeout(200)
await page.evaluate(() => window.__studio.getState().clearRun())
await page.waitForTimeout(200)
const kept = await page.evaluate(() => window.__studio.getState().selectedId)
check('运行清掉之后仍选中的节点留着「…的配置」入口', kept !== 'start' || await count(page, '.sf-peek') === 1, String(kept))
await head('start').click()
await page.waitForTimeout(250)
check('没有运行时再点这个节点:打开属性面板', await sheet() === 1)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- 回放:回边圈数、用量跟着游标
console.log('=== 回放:回边的 ×N 和面板的用量都是那一刻的 ===')
{
const { browser, page, errors } = await openStudio()
await seedRun(page)
// 两次模型调用,一次在第 5 秒、一次在第 12 秒;终态的后端累计和它们对得上
const usage = [
ev(30, 'llm.end', 'team', { agent: '检索员', model: 'm', input_tokens: 600, output_tokens: 200, cost_usd: 0.002 }, 5),
ev(31, 'llm.end', 'team', { agent: '分析员', model: 'm', input_tokens: 400, output_tokens: 200, cost_usd: 0.001 }, 12),
]
const finished = TO_LOOP.map((e) => (e.type === 'run.finished'
? { ...e, data: { ...e.data, usage: { input_tokens: 1000, output_tokens: 400, cost_usd: 0.003 } } } : e))
const all = [...PARALLEL, ...usage, ...TEAM_DONE, ...finished].sort((a, b) => a.ts - b.ts)
.map((e, i) => ({ ...e, seq: i + 1, replay: true }))
await feed(page, all)
await page.waitForTimeout(400)
const badge = () => page.locator('.sf-loop-badge').innerText().catch(() => '')
check('跑完:回边写兜回去几次(↺ ×1)', (await badge()).replace(/\s+/g, ' ') === '↺ ×1', await badge())
const at = (t) => page.evaluate((ms) => window.__studio.getState().setReplayAt(ms), (BASE + t) * 1000)
// 返工的第一轮还在跑:还没兜回去过
await at(18)
await page.waitForTimeout(250)
check('回放到第一轮:回边还没兜回去,不写次数', (await badge()).trim() === '↺', await badge())
// 协作团队跑到一半:只有第一次模型调用
await at(8)
await page.waitForTimeout(250)
await capsule(page).locator('.sf-cap-main').click()
const usageOf = async () => (await page.locator('.sf-metric', { hasText: '用量' }).innerText().catch(() => '')).replace(/\s+/g, ' ')
const mid = await usageOf()
check('回放:面板的用量是那一刻的(800 tok),不是终值', /800 tok/.test(mid) && !/1\.4k/.test(mid), mid)
const side = await page.locator('.react-flow__node[data-id="team"] .nc-tele-side').innerText().catch(() => '')
check('回放:卡片上的 token 也是那一刻的', /800 tok/.test(side), side)
await page.keyboard.press('Escape')
await page.evaluate(() => window.__studio.getState().setReplayAt(null))
await page.waitForTimeout(250)
await capsule(page).locator('.sf-cap-main').click()
const live = await usageOf()
check('回到实时:用量是整次运行的(1.4k tok)', /1\.4k tok/.test(live), live)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- Copilot 搭图时的镜头
console.log('=== Copilot 搭图:新节点在视口外时镜头跟过去,人一动手这一轮就不跟 ===')
for (const reducedMotion of ['no-preference', 'reduce']) {
const { browser, page, errors } = await openStudio({ id: WIDE_ID, reducedMotion })
const inView = (id) => page.evaluate((nid) => {
const pane = document.querySelector('.react-flow').getBoundingClientRect()
const r = document.querySelector(`.react-flow__node[data-id="${nid}"]`).getBoundingClientRect()
return r.left >= pane.left && r.right <= pane.right && r.top >= pane.top && r.bottom <= pane.bottom
}, id)
const vp = () => page.evaluate(() => document.querySelector('.react-flow__viewport').style.transform)
const building = (on, cursor) => page.evaluate(({ on, cursor }) => {
const st = window.__studio
st.setState({ copilot: { ...st.getState().copilot, active: on }, copilotCursor: cursor })
}, { on, cursor })
const label = reducedMotion === 'reduce' ? '(减少动效)' : ''
check(`n11 起初在视口外${label}`, !(await inView('n11')))
await building(true, 'n11')
if (reducedMotion === 'reduce') {
// 减少动效时不做动画:下一帧就到位
await page.waitForTimeout(120)
check('减少动效:镜头直接到位,不飞', await inView('n11'))
} else {
check('助手刚放下的节点在视口外:镜头跟过去', !!(await until(() => inView('n11'))))
const z0 = await page.evaluate(() => document.querySelector('.react-flow').style.getPropertyValue('--zoom'))
// 人拖了一下画布:这一轮不再跟
const pane = await page.locator('.react-flow__pane').boundingBox()
await page.mouse.move(pane.x + 200, pane.y + 200)
await page.mouse.down()
await page.mouse.move(pane.x + 380, pane.y + 240, { steps: 5 })
await page.mouse.up()
await page.waitForTimeout(250)
const held = await vp()
await building(true, 'n0')
await page.waitForTimeout(700)
check('人动过镜头之后,这一轮不再抢', await vp() === held)
// 下一轮重新跟
await building(false, null)
await building(true, 'n0')
check('下一轮又跟上', !!(await until(() => inView('n0'))))
const z1 = await page.evaluate(() => document.querySelector('.react-flow').style.getPropertyValue('--zoom'))
check('跟镜头只平移,不改缩放', z0 === z1, `${z0} → ${z1}`)
}
await building(false, null)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- Copilot 删掉的节点:原地虚线框
console.log('=== Copilot 这一轮删掉的节点:原地留一道虚线框,停一下再淡出 ===')
/** 照 store 的真实顺序走一轮:开始 → 节点没了 → 这一轮落定(diff 写着删了谁)→ 结束 */
const removeInTurn = (page, id) => page.evaluate((id) => {
const st = window.__studio
const turn = { id: 'fx-turn', instruction: '去掉返工那一步', ops: [], phase: 'running', explanation: '', error: '' }
st.setState({ copilot: { ...st.getState().copilot, active: true } })
st.setState({ copilotTurns: [...st.getState().copilotTurns, turn] })
st.setState({
nodes: st.getState().nodes.filter((n) => n.id !== id),
edges: st.getState().edges.filter((e) => e.source !== id && e.target !== id),
})
st.setState({ copilotTurns: st.getState().copilotTurns.map((t) => (t.id === turn.id ? {
...t, phase: 'done', outcome: 'applied',
diff: { added: [], removed: [id], changed: [], edgesAdded: 0, edgesRemoved: 2, total: 3 },
} : t)) })
st.setState({ copilot: { ...st.getState().copilot, active: false } })
}, id)
for (const reducedMotion of ['no-preference', 'reduce']) {
const { browser, page, errors } = await openStudio({ reducedMotion })
const was = await page.locator('.react-flow__node[data-id="fix"]').boundingBox()
await removeInTurn(page, 'fix')
await page.waitForTimeout(400)
const ghost = page.locator('.sf-ghost[data-ghost="fix"]')
if (reducedMotion === 'reduce') {
check('减少动效:不画虚线框', await ghost.count() === 0)
} else {
const box = await ghost.boundingBox()
const tag = await ghost.innerText().catch(() => '')
check('删掉的节点原地留一道虚线框,写着删的是谁', !!box && tag.includes('已删除 · 返工'), tag)
check('框就在它原来的位置、一样大', !!box && !!was && Math.abs(box.x - was.x) < 4 && Math.abs(box.y - was.y) < 4
&& Math.abs(box.width - was.width) < 4, JSON.stringify({ was, box }))
const tagPx = await ghost.locator('.sf-ghost-tag').evaluate((el) => el.getBoundingClientRect().height)
check('标签在屏幕上读得清', tagPx >= 14, `${tagPx.toFixed(1)}px`)
check('虚线框不挡操作', await ghost.evaluate((el) => getComputedStyle(el).pointerEvents) === 'none')
await page.screenshot({ path: '/tmp/agentlab-canvas-fx-ghost.png' })
check('停一下就淡出、收掉', !!(await until(async () => await ghost.count() === 0, { timeout: 4000 })))
}
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// 窄画布打开就是精简档(缩放 0.4 上下):标签的字照样补回 11px,不跟着缩成 7px
{
const { browser, page, errors } = await openStudio({ viewport: { width: 1024, height: 768 } })
await page.waitForTimeout(400)
const zoom = Number(await page.evaluate(() => document.querySelector('.react-flow').style.getPropertyValue('--zoom')))
// 质量门在前三列里,这个缩放下在视口内:截图看得到
await removeInTurn(page, 'gate')
await page.waitForTimeout(400)
const tag = await page.locator('.sf-ghost[data-ghost="gate"] .sf-ghost-tag').evaluate((el) => ({
h: el.getBoundingClientRect().height, font: parseFloat(getComputedStyle(el).fontSize),
clipped: el.scrollWidth > el.clientWidth + 1,
})).catch(() => null)
const px = tag ? tag.font * zoom : 0
check(`精简档(缩放 ${zoom.toFixed(2)}):虚线框的标签照样读得清(屏幕上 ≥ 11px、高 ≥ 14px)`,
zoom < 0.5 && !!tag && px >= 10.9 && tag.h >= 14 && !tag.clipped,
tag ? `字 ${px.toFixed(1)}px · 高 ${tag.h.toFixed(1)}px${tag.clipped ? ' · 被截断' : ''}` : '没画虚线框')
await page.screenshot({ path: '/tmp/agentlab-canvas-fx-ghost-compact.png' })
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- 双击快速添加
console.log('=== 双击空白处:在光标处快速添加节点 ===')
{
const { browser, page, errors } = await openStudio()
const spot = await emptySpot(page)
const zoomOf = () => page.evaluate(() => document.querySelector('.react-flow').style.getPropertyValue('--zoom'))
const z0 = await zoomOf()
const n0 = await page.evaluate(() => window.__studio.getState().nodes.length)
await page.mouse.dblclick(spot.x, spot.y)
await page.waitForTimeout(250)
const quick = page.locator('.sf-quick')
check('双击空白处打开快速添加', await quick.count() === 1)
check('双击不再缩放画布', await zoomOf() === z0, `${z0} → ${await zoomOf()}`)
check('焦点直接在搜索框里', await page.evaluate(() => !!document.activeElement?.closest('.sf-quick input')))
const panel = await quick.boundingBox()
check('面板贴着双击处', !!panel && Math.abs(panel.x - spot.x) < 260 && Math.abs(panel.y - spot.y) < 320,
panel ? `${Math.round(panel.x)},${Math.round(panel.y)} / ${Math.round(spot.x)},${Math.round(spot.y)}` : '没有面板')
// 只打一个字:名字里有它的排在只是说明里提到它的前面(协作节点的说明里有「分派」)
await page.keyboard.type('分')
await page.waitForTimeout(100)
const first = await quick.locator('[role="option"][aria-selected="true"]').innerText().catch(() => '')
check('按名字搜,第一个就是要的', first.includes('条件分支'), first)
await page.keyboard.press('Enter')
await page.waitForTimeout(300)
const added = await page.evaluate(() => {
const st = window.__studio.getState()
const n = st.nodes[st.nodes.length - 1]
const el = document.querySelector(`.react-flow__node[data-id="${n.id}"]`)?.getBoundingClientRect()
return { total: st.nodes.length, type: n.data.nodeType, cx: el ? el.left + el.width / 2 : null, cy: el ? el.top + el.height / 2 : null }
})
check('回车放下一个条件分支', added.total === n0 + 1 && added.type === 'branch', JSON.stringify(added))
check('落在双击处(卡片中心对着光标)', added.cx != null && Math.abs(added.cx - spot.x) < 40 && Math.abs(added.cy - spot.y) < 40,
JSON.stringify({ spot, at: [added.cx, added.cy] }))
check('放下之后面板收起', await quick.count() === 0)
await page.evaluate(() => window.__studio.getState().select(null))
const spot2 = await emptySpot(page)
await page.mouse.dblclick(spot2.x, spot2.y)
await page.waitForTimeout(200)
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
check('Esc 收起、不加节点', await quick.count() === 0
&& await page.evaluate(() => window.__studio.getState().nodes.length) === n0 + 1)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await browser.close()
}
// ---------------------------------------------------------------- 窄画布的打开取景
console.log('=== 1024 宽打开:可读缩放下放不下三列时退到精简档,多看几列 ===')
for (const theme of ['dark', 'light']) {
const { browser, page } = await openStudio({ theme, viewport: { width: 1024, height: 768 } })
await page.waitForTimeout(500)
// 两遍真的是两套主题:不然「亮暗都看过」只是同一张图看了两次
const shown = await page.evaluate(() => ({
attr: document.documentElement.dataset.theme ?? null,
scheme: getComputedStyle(document.documentElement).colorScheme,
}))
check(`${theme}: 画出来的确实是这一套主题`, shown.attr === theme && shown.scheme.includes(theme), JSON.stringify(shown))
const view = await page.evaluate(() => {
const rf = document.querySelector('.react-flow')
const pane = rf.getBoundingClientRect()
const nodes = [...document.querySelectorAll('.react-flow__node')]
const whole = nodes.filter((n) => {
const r = n.getBoundingClientRect()
return r.left >= pane.left && r.right <= pane.right && r.top >= pane.top && r.bottom <= pane.bottom
}).length
return { zoom: Number(rf.style.getPropertyValue('--zoom')), lod: rf.dataset.lod, whole, total: nodes.length, w: Math.round(pane.width) }
})
check(`${theme}: 1024 宽打开至少看得全四张卡`, view.whole >= 4, JSON.stringify(view))
check(`${theme}: 缩放不低于精简档下限 0.4,档位是精简卡`, view.zoom >= 0.4 - 1e-3 && view.lod === 'compact', JSON.stringify(view))
await page.screenshot({ path: `/tmp/agentlab-canvas-fx-open-1024-${theme}.png` })
await browser.close()
}
// ---------------------------------------------------------------- 关掉动效
console.log('=== 系统关了动效 ===')
{
const { browser, page } = await openStudio({ reducedMotion: 'reduce' })
await seedRun(page)
await feed(page, PARALLEL_NOW)
await page.waitForTimeout(400)
const styles = await page.evaluate(() => {
const el = document.querySelector('.fx-halo')
const packet = document.querySelector('.edge-packet')
return {
matches: matchMedia('(prefers-reduced-motion: reduce)').matches,
halo: el ? getComputedStyle(el).display : '不在 DOM 里',
packet: packet ? getComputedStyle(packet).display : '不在 DOM 里',
}
})
check('浏览器确实处在减少动效模式', styles.matches)
check('光弧不再绘制', styles.halo === 'none' || styles.halo === '不在 DOM 里', styles.halo)
check('光点不再绘制', styles.packet === 'none', styles.packet)
// 状态本身还得看得出来:走过 / 正在流的边是实线,胶囊照样计时
check('静态下边的运行态还在', (await edgeClass(page, 'e-start-team')).includes('sf-e-flow'))
check('静态下胶囊照样写着运行中', (await capText(page)).includes('运行中'))
// 「适配全图」也不做动画:点完下一帧就到位
const vp = () => page.evaluate(() => document.querySelector('.react-flow__viewport').style.transform)
const pane = await page.locator('.react-flow__pane').boundingBox()
await page.mouse.move(pane.x + 300, pane.y + 300)
await page.mouse.down()
await page.mouse.move(pane.x + 80, pane.y + 200, { steps: 4 })
await page.mouse.up()
await page.waitForTimeout(200)
const moved = await vp()
await page.getByRole('button', { name: '适配全图' }).click()
await page.waitForTimeout(40)
const soon = await vp()
await page.waitForTimeout(400)
const later = await vp()
check('减少动效时「适配全图」直接到位', soon !== moved && soon === later, `${moved} → ${soon} → ${later}`)
await browser.close()
}
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
process.exit(failed ? 1 : 0)