mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:11:57 +00:00
03c18c4d78
一、文案(前端约 1600 处,后端传到界面的约 880 处) - 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」; 总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别 - 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法 - 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」); 报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供; 发布弹窗里结论句的选项不再是 off / require_citation - 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份 - 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例 二、说错的提示(都有测试证明) - 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀 - 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页 - 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填 - 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因 - 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头 - 导出的证据 CSV 里两列同名,第三列改为「片段编号」 三、前后端对齐 - 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对 - 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上 四、检查 - 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证 - 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、 界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
1586 lines
96 KiB
JavaScript
1586 lines
96 KiB
JavaScript
// 画布表层的检查:把脚本化的事件灌进 store,看画布、运行胶囊、航迹坞真的跟上了。
|
||
//
|
||
// 为什么不是"真跑一张图":多 agent 那张图要连模型、要等十几秒,而且撞不上
|
||
// "三个人同时还在跑"那一瞬间——检查会变成偶发失败。dev 构建把 store 挂在
|
||
// window.__studio 上,这里直接往里灌事件:走的是页面上那一份 applyEvent /
|
||
// NodeCard / FlowEdge / RunHud,只有事件来源是脚本。
|
||
//
|
||
// 不写库:工作流、会话、已发布版本全用 page.route 在浏览器里伪造,后端只被
|
||
// 读(目录、校验、排版这些纯计算)。以前这里会在库里建一张 __fx_check__,
|
||
// 别的检查打开 /studio 落到第一张图时会撞上它。
|
||
//
|
||
// 守的是几件"事件对了但界面没跟上"的事:
|
||
// - 取消 / 挂起之后光弧、光点、协作行还在转;
|
||
// - 等人时入边还在流光点,工具栏还给一个必然 409 的「停止」;
|
||
// - 失败后「接着跑」出不来;胶囊的计时不走;
|
||
// - 跑完之后看不出执行路径;拖时间轴卡片不跟着变;
|
||
// - 打开大图缩到 0.2;正式运行期间还能拖动改图;
|
||
// - 一次性时刻被补发的历史事件触发,或者播完不撤;
|
||
// - 定位 / 去审批取景半路被拉远,落地卡在 compact 档(错误正文被藏);
|
||
// - 「只看执行路径」带进下一次运行;正式运行横幅盖住「恢复」;
|
||
// - 同一刻的等人时长几处写几个数;只是点一下画布就暂停跟随;
|
||
// - Esc 在右栏里按也清掉整次运行;泳道、节点格、坞高只能用鼠标;
|
||
// - 运行中点节点,属性面板整块盖住右栏的运行视图(应当先请右栏滚到它的步骤);
|
||
// - 等审批时只能批不能放弃;回放时回边的 ×N、面板的用量拿终值冒充那一刻;
|
||
// - Copilot 搭图时新节点落在视口外看不见;双击空白处只会缩放;窄画布打开只看得见两张半卡;
|
||
// - Copilot 删掉的节点一声不响就没了,看不出删的是哪个、原来在哪;
|
||
// - 循环体没有范围:哪些节点每轮都跑、最多几轮、现在第几轮,只能顺着回边倒推。
|
||
//
|
||
// 跑之前前后端都得起着(./scripts/dev.sh),默认连 5273 / 8000。对别的实例(比如一份
|
||
// 沙箱拷贝)跑时带上地址:
|
||
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-canvas-fx.mjs
|
||
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'
|
||
|
||
// FX_ONLY=九天,运行中点节点 只跑段名里含这些字的段(逗号分隔)。FX_SKIP 反过来,跳过段名里含这些字的段:
|
||
// check-all 把这个检查拆成几份分到不同的道上,最后一份用它兜住「其余全部」,以后新加的段不用改 check-all
|
||
const ONLY = (process.env.FX_ONLY ?? '').split(',').filter(Boolean)
|
||
const SKIP = (process.env.FX_SKIP ?? '').split(',').filter(Boolean)
|
||
/**
|
||
* 一节一节地跑:某一节里等待超时、元素找不到,只记成这一节失败,关掉它开的浏览器,
|
||
* 接着跑下一节——不让一处卡住把后面几十项一起吞掉
|
||
*/
|
||
const opened = new Set()
|
||
async function section(name, fn) {
|
||
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
|
||
if (SKIP.some((k) => name.includes(k))) return
|
||
console.log(`=== ${name} ===`)
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
} finally {
|
||
for (const 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: ['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 LOOPS = {
|
||
nodes: [
|
||
{ id: 'in', type: 'input', data: { label: '批次', config: { fields: [{ name: 'batches' }] } } },
|
||
{ id: 'outer', type: 'loop', data: { label: '逐批处理', config: { mode: 'foreach', items: '{{ input.batches }}', item_var: 'batch', max_iterations: 3 } } },
|
||
{ id: 'inner', type: 'loop', data: { label: '逐条复核', config: { mode: 'while', condition: 'vars.left > 0', max_iterations: 4 } } },
|
||
{ id: 'check', type: 'llm', data: { label: '复核一条', config: { prompt: '{{ vars.batch }}' } } },
|
||
{ id: 'merge', type: 'transform', data: { label: '合并本批', config: { expression: 'vars.batch' } } },
|
||
{ id: 'side', type: 'llm', data: { label: '旁路抽检', config: { prompt: 'x' } } },
|
||
{ id: 'report', type: 'output', data: { label: '汇总报告', config: { fields: [{ name: 'r' }] } } },
|
||
],
|
||
edges: [
|
||
{ id: 'l1', source: 'in', target: 'outer' },
|
||
{ id: 'l2', source: 'outer', target: 'inner', sourceHandle: 'body' },
|
||
{ id: 'l3', source: 'inner', target: 'check', sourceHandle: 'body' },
|
||
{ id: 'l4', source: 'check', target: 'inner' },
|
||
{ id: 'l5', source: 'inner', target: 'merge', sourceHandle: 'done' },
|
||
{ id: 'l6', source: 'merge', target: 'outer' },
|
||
{ id: 'l7', source: 'outer', target: 'report', sourceHandle: 'done' },
|
||
{ id: 'l8', source: 'in', target: 'side' },
|
||
{ id: 'l9', source: 'side', target: 'report' },
|
||
],
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 事件
|
||
|
||
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'
|
||
const LOOPS_ID = 'fx-loops'
|
||
|
||
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, LAID_LOOPS] = await Promise.all([layout(GRAPH), layout(WIDE), layout(LOOPS)])
|
||
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 }),
|
||
[LOOPS_ID]: workflow(LOOPS_ID, '循环体检查', LAID_LOOPS, { 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))
|
||
}
|
||
|
||
/**
|
||
* 画布真窄的一种摆法:1280 宽(助手栏占位、不浮)、节点库展开、助手栏拖到 456px,画布只剩 552px(和以前 1024 宽三栏全开时一样)。
|
||
* 窄于 1280 时助手栏默认收着、展开也是浮层,画布不会被挤成这样
|
||
*/
|
||
const NARROW_CANVAS = {
|
||
viewport: { width: 1280, height: 768 },
|
||
prefs: { 'agentlab.studio.palette': 'open', 'agentlab.studio.assistant': 'open', 'agentlab.studio.assistantWidth': '456' },
|
||
}
|
||
|
||
/** prefs:页面脚本之前写进 localStorage 的偏好(三栏开合、助手栏宽度) */
|
||
async function openStudio({ id = FX_ID, theme = 'dark', prefs = {}, ...extra } = {}) {
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
opened.add(browser)
|
||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 950 }, colorScheme: theme, ...extra })
|
||
await ctx.addInitScript(([t, p]) => {
|
||
try {
|
||
localStorage.setItem('agentlab.theme', t)
|
||
for (const [k, v] of Object.entries(p)) localStorage.setItem(k, v)
|
||
} catch { /* 隐私窗口 */ }
|
||
}, [theme, prefs])
|
||
// 按页面自己加载时的地址 import 模块:热更新过的模块地址带 ?t=,直接写 /src/… 会拿到
|
||
// 另一份实例(另一个坞的 store),改了它页面上什么都不变
|
||
await ctx.addInitScript(() => {
|
||
performance.setResourceTimingBufferSize(10_000)
|
||
window.__appImport = (path) => import(performance.getEntriesByType('resource').map((e) => e.name)
|
||
.find((n) => { try { return new URL(n).pathname === path } catch { return false } }) ?? path)
|
||
})
|
||
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)
|
||
|
||
// ---------------------------------------------------------------- 运行中
|
||
|
||
await section('运行中:胶囊、光点、计时', async () => {
|
||
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()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 等人
|
||
|
||
await section('等人:入边闸门,不流光点;去审批代替停止', async () => {
|
||
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()
|
||
})
|
||
|
||
await section('等人:同一刻的等待时长各处同一个数', async () => {
|
||
// 打开一条正在等审批的运行(?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()
|
||
})
|
||
|
||
await section('等了九天:跨天的读数按天、小时写,各处同一个说法(和记录页一致)', async () => {
|
||
// 审批挂了九天一小时:以前坞头写「已等 217:00:14.3」、泳道「等 217:00…」,记录页写「9 天 01 小时」
|
||
const { browser, page } = await openStudio()
|
||
await seedRun(page)
|
||
const shift = Date.now() / 1000 - (9 * 86_400 + 3600) - 14.3 - 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 read = () => page.evaluate(() => {
|
||
const txt = (el) => el?.textContent?.replace(/\s+/g, ' ').trim() ?? ''
|
||
const find = (sel, head) => [...document.querySelectorAll(sel)].map(txt).find((t) => t.startsWith(head)) ?? ''
|
||
return {
|
||
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', '等待审批 '),
|
||
sum: txt(document.querySelector('.tl-run .tl-sum')),
|
||
sumTitle: document.querySelector('.tl-run .tl-sum')?.getAttribute('title') ?? '',
|
||
lane: txt(document.querySelector('.tl-lane[data-node-id="review"] .tl-sum')),
|
||
laneTitle: document.querySelector('.tl-lane[data-node-id="review"] .tl-sum')?.getAttribute('title') ?? '',
|
||
clipped: [...document.querySelectorAll('.tl-sum')].filter((el) => el.scrollWidth > el.clientWidth + 1).map(txt),
|
||
wall: find('.sf-metric-v', 'T+'),
|
||
wallOver: [...document.querySelectorAll('.sf-metric-v')].filter((el) => el.scrollWidth > el.clientWidth + 1).map((el) => `${txt(el)} ${el.scrollWidth}/${el.clientWidth}`),
|
||
card: txt(document.querySelector('.react-flow__node[data-id="review"] .nc')),
|
||
clock: txt(document.querySelector('.sf-cap-clock')),
|
||
cursor: txt(document.querySelector('.tl-cursor-head')),
|
||
folds: [...document.querySelectorAll('.tl-fold-tag')].map(txt),
|
||
}
|
||
})
|
||
const r = await read()
|
||
const spans = ['header', 'headline', 'queue', 'metric', 'card', 'sumTitle', 'laneTitle']
|
||
check('等了多久:坞头、面板、需要处理、等待审批合计、卡片、摘要的悬停都写「9 天 01 小时」',
|
||
spans.every((k) => r[k].includes('9 天 01 小时')), JSON.stringify(Object.fromEntries(spans.map((k) => [k, r[k]]))))
|
||
// 摘要那一列只有 116px:只写最大的单位,不被截成「等 9 天 0…」
|
||
check('泳道和运行行的摘要写「等待 9 天」,一列都没被截断', r.sum.endsWith('等待 9 天') && r.lane.endsWith('等待 9 天')
|
||
&& !r.clipped.length, `${r.sum} | ${r.lane}${r.clipped.length ? ` · 截断:${r.clipped.join(' / ')}` : ''}`)
|
||
check('面板的墙钟过了一天写「T+9 天」,读数都放得下(不压到旁边那一格)', r.wall === 'T+9 天' && !r.wallOver.length, `${r.wall} ${r.wallOver.join(' / ')}`)
|
||
check('相对开始的时刻(胶囊计时、游标)写「9 天 01:00:xx」', /^9 天 01:00:\d\d$/.test(r.clock)
|
||
&& /9 天 01:00:\d\d/.test(r.cursor), `${r.clock} · ${r.cursor}`)
|
||
check('压起来的空档写「9 天」', r.folds.includes('9 天'), r.folds.join(' | '))
|
||
// 右栏运行层底部的用量行:以前写「等人 219:34:39.6」,和上面一众「9 天 01 小时」不是一个说法(3C REQ-9)
|
||
await page.evaluate(() => window.dispatchEvent(new CustomEvent('agentlab:goto-approval',
|
||
{ detail: { runId: window.__studio.getState().run?.id, nodeId: 'review' }, cancelable: true })))
|
||
await page.waitForTimeout(400)
|
||
const panelUsage = await page.evaluate(() =>
|
||
document.querySelector('[data-assistant-panel] [aria-label="这次运行的用量"]')?.textContent?.replace(/\s+/g, ' ').trim() ?? '')
|
||
check('右栏用量行的等待审批也写「9 天 01 小时」', /等待审批 9 天 01 小时/.test(panelUsage) && !/\d{3}:\d\d/.test(panelUsage), panelUsage)
|
||
check('没有一处写成「217:…」这种得自己除 24 的钟面', !Object.values(r).flat().some((v) => /\d{3}:\d\d/.test(v)),
|
||
Object.values(r).flat().filter((v) => /\d{3}:\d\d/.test(v)).join(' | '))
|
||
// 不压缩空档时九天摊开:刻度按天排,不能挤成一团
|
||
await page.locator('.tl-toggle input[aria-label="压缩空闲"]').uncheck({ timeout: 2000 }).catch(() => {})
|
||
await page.waitForTimeout(300)
|
||
const ticks = await page.evaluate(() => [...document.querySelectorAll('.tl-axis .tl-tick')].map((el) => ({
|
||
x: el.getBoundingClientRect().left, text: el.textContent ?? '' })))
|
||
const gap = Math.min(...ticks.slice(1).map((t, i) => t.x - ticks[i].x))
|
||
check('不压缩时刻度按天排、互不挤压', ticks.length >= 3 && ticks.length <= 30 && gap >= 60
|
||
&& ticks.some((t) => /^\d+ 天/.test(t.text)), `${ticks.length} 个 · 最近 ${Math.round(gap)}px · ${ticks.slice(0, 4).map((t) => t.text).join(' ')}`)
|
||
await page.locator('.tl-toggle input[aria-label="压缩空闲"]').check({ timeout: 2000 }).catch(() => {})
|
||
await browser.close()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 失败 / 取消 / 挂起
|
||
|
||
const settleCase = async (label, tail, expect) => {
|
||
await section(label, async () => {
|
||
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)
|
||
},
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 跑完
|
||
|
||
await section('跑完:执行路径、回边、回放、清除', async () => {
|
||
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 token)', /2\.0k token/.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))
|
||
|
||
// 给坞分高度的人(记录页的航迹页签)照 dockHeightFor 给高度:泳道一行不裁,也不多出一截。
|
||
// 少 1px 就得滚——上边框那 1px 算在坞的高度里,漏算它最后一条泳道被裁掉 1px
|
||
const fit = await page.evaluate(async () => {
|
||
const m = await window.__appImport('/src/canvas/RunTimeline.tsx')
|
||
const s = window.__studio.getState()
|
||
const want = m.dockHeightFor(s.trace, { nodes: s.nodes, edges: s.edges })
|
||
const was = m.useDock.getState().height
|
||
const measure = async (h) => {
|
||
m.useDock.getState().set({ height: h })
|
||
await new Promise((res) => requestAnimationFrame(() => requestAnimationFrame(res)))
|
||
const sc = document.querySelector('.tl-scroll')
|
||
const lanes = [...document.querySelectorAll('.tl-lane')]
|
||
const last = lanes[lanes.length - 1]?.getBoundingClientRect()
|
||
return { box: Math.round(document.querySelector('.tl').getBoundingClientRect().height),
|
||
over: sc.scrollHeight - sc.clientHeight, cut: last ? Math.round(last.bottom - sc.getBoundingClientRect().bottom) : null,
|
||
lanes: lanes.length }
|
||
}
|
||
const exact = await measure(want)
|
||
const short = await measure(want - 1)
|
||
m.useDock.getState().set({ height: was })
|
||
return { want, exact, short, border: m.DOCK_BORDER_H }
|
||
}).catch((e) => ({ error: e.message.split('\n')[0] }))
|
||
check('坞高给 dockHeightFor:泳道一行不裁,也不用滚', fit.exact?.box === fit.want && fit.exact.over === 0
|
||
&& fit.exact.cut <= 0 && fit.exact.lanes > 7, JSON.stringify(fit))
|
||
check('少 1px 就得滚:dockHeightFor 不多给(上边框算进去了)', fit.short?.over === 1 && fit.short.cut === 1,
|
||
JSON.stringify(fit.short))
|
||
|
||
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()
|
||
})
|
||
|
||
await section('补发的历史(attachRun 回放):直接落终态,不播时刻', async () => {
|
||
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()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 正式运行
|
||
|
||
await section('正式运行:入口看 published_version;运行期间画布只读', async () => {
|
||
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]) await section(`正式运行 @${width}:只读横幅不盖住跟随那一枚`, async () => {
|
||
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()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 取景
|
||
|
||
await section('打开宽图:以入口为左锚,缩放不低于可读下限', async () => {
|
||
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()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 运行中点节点
|
||
|
||
await section('运行中点节点:先看它的步骤,属性面板是次级入口', async () => {
|
||
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')
|
||
// 右栏停在对话层时步骤行根本不在页面上:由右栏接手这个事件,切到运行层、滚到这个节点
|
||
// 最后一次执行的步骤、描一圈。以前画布只能在右栏已经是运行层时自己去找,对话层时点了没反应
|
||
const toChat = page.locator('[data-assistant-panel] button[title="回到和助手的对话"]')
|
||
await toChat.click({ timeout: 2000 }).catch(() => {})
|
||
await page.waitForTimeout(250)
|
||
const inChat = await count(page, '[data-assistant-panel] [data-step-status]') === 0
|
||
await page.evaluate(() => { window.__reveal = [] })
|
||
await head('start').click()
|
||
const stepRow = () => page.evaluate(() => {
|
||
const row = [...document.querySelectorAll('[data-assistant-panel] [data-step-status][data-node-id="start"]')].at(-1)
|
||
return row ? { flash: row.dataset.flash ?? null, handled: window.__reveal.length } : null
|
||
})
|
||
const shown = await until(async () => ((await stepRow())?.flash === 'focus' ? stepRow() : false), { timeout: 2500 })
|
||
|| await stepRow()
|
||
check('右栏在对话层:点跑过的节点,右栏切到运行层并描出它的步骤', inChat && !!shown && shown.flash === 'focus',
|
||
`${inChat ? '' : '(准备:右栏没回到对话层)'}${JSON.stringify(shown)}`)
|
||
// 先选中、再请右栏滚过去(派发的先后换一下):右栏接手时不能把选中清掉,
|
||
// 不然画布上「…的配置」入口跟着没了(3C REQ-7)
|
||
const selectFirst = await page.evaluate(() => {
|
||
const s = window.__studio.getState()
|
||
s.select('team')
|
||
const e = new CustomEvent('agentlab:reveal-step', { detail: { runId: s.run?.id, nodeId: 'team' }, cancelable: true })
|
||
const unhandled = window.dispatchEvent(e)
|
||
return { handled: !unhandled, sel: window.__studio.getState().selectedId }
|
||
})
|
||
await page.waitForTimeout(250)
|
||
check('先选中再请右栏滚过去:右栏接手,选中留着,「…的配置」入口还在',
|
||
selectFirst.handled && selectFirst.sel === 'team' && await page.evaluate(() => window.__studio.getState().selectedId) === 'team'
|
||
&& await count(page, '.sf-peek') === 1 && await sheet() === 0, JSON.stringify(selectFirst))
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(150)
|
||
// 先看了步骤、运行随后结束:节点还选中着、面板没开。再点它(选中没变)也得打开面板
|
||
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()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 回放:回边圈数、用量跟着游标
|
||
|
||
await section('回放:回边的 ×N 和面板的用量都是那一刻的', async () => {
|
||
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 token),不是终值', /800 token/.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 token/.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 token)', /1\.4k token/.test(live), live)
|
||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||
await browser.close()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- Copilot 搭图时的镜头
|
||
|
||
await section('Copilot 搭图:新节点在视口外时镜头跟过去,人一动手这一轮就不跟', async () => {
|
||
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 删掉的节点:原地虚线框
|
||
|
||
/** 照 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)
|
||
await section('Copilot 这一轮删掉的节点:原地留一道虚线框,停一下再淡出', async () => {
|
||
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(NARROW_CANVAS)
|
||
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()
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 双击快速添加
|
||
|
||
await section('双击空白处:在光标处快速添加节点', async () => {
|
||
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()
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 窄画布的打开取景
|
||
|
||
await section('打开取景:1024 宽助手栏收着、画布够宽就用可读缩放;画布真窄时退到精简档,多看几列', async () => {
|
||
const viewOf = (page) => 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) }
|
||
})
|
||
for (const theme of ['dark', 'light']) {
|
||
// 1024 宽:窄屏的助手栏默认收着(展开是浮层),画布拿到整块宽度,完整卡片就放得下
|
||
const wide = await openStudio({ theme, viewport: { width: 1024, height: 768 } })
|
||
await wide.page.waitForTimeout(500)
|
||
const open1024 = await viewOf(wide.page)
|
||
check(`${theme}: 1024 宽打开画布有 880px 以上、至少看得全四张完整卡片`,
|
||
open1024.w >= 880 && open1024.whole >= 4 && open1024.lod === 'full', JSON.stringify(open1024))
|
||
await wide.page.screenshot({ path: `/tmp/agentlab-canvas-fx-open-1024-${theme}.png` })
|
||
await wide.browser.close()
|
||
|
||
// 画布真窄:可读缩放下连前三列都放不下
|
||
const { browser, page } = await openStudio({ theme, ...NARROW_CANVAS })
|
||
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 viewOf(page)
|
||
check(`${theme}: 窄画布(${view.w}px)打开至少看得全四张卡`, view.w < 600 && 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-narrow-${theme}.png` })
|
||
await browser.close()
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 关掉动效
|
||
|
||
await section('循环体底框:框套框、按列圈、写上限,跑起来写第几轮', async () => {
|
||
for (const theme of ['dark', 'light']) {
|
||
const { browser, page, errors } = await openStudio({ id: LOOPS_ID, theme })
|
||
await page.getByRole('button', { name: '适应画布' }).click()
|
||
await page.waitForTimeout(500)
|
||
const read = () => page.evaluate(() => {
|
||
const frames = [...document.querySelectorAll('.sf-loop-frame')]
|
||
const tags = Object.fromEntries([...document.querySelectorAll('.sf-loop-tag')].map((t) => [t.dataset.loop, {
|
||
text: t.innerText.replace(/\s+/g, ' ').trim(), shown: getComputedStyle(t).visibility === 'visible', running: t.classList.contains('is-running'),
|
||
}]))
|
||
const nodes = window.__studio.getState().nodes.map((n) => ({ id: n.id, x: n.position.x, y: n.position.y,
|
||
w: n.measured?.width ?? 238, h: n.measured?.height ?? 80 }))
|
||
// 节点的中心和四角落不落在框里:框在画布坐标里,和节点位置同一套
|
||
const inside = (path, n) => {
|
||
const pts = [[n.x + n.w / 2, n.y + n.h / 2], [n.x + 2, n.y + 2], [n.x + n.w - 2, n.y + 2], [n.x + 2, n.y + n.h - 2], [n.x + n.w - 2, n.y + n.h - 2]]
|
||
return pts.map(([x, y]) => path.isPointInFill(new DOMPoint(x, y)))
|
||
}
|
||
const cover = Object.fromEntries(frames.map((f) => [f.dataset.loop, Object.fromEntries(nodes.map((n) => [n.id, inside(f, n)]))]))
|
||
const box = Object.fromEntries(frames.map((f) => { const b = f.getBBox(); return [f.dataset.loop, { x: b.x, y: b.y, r: b.x + b.width, b: b.y + b.height }] }))
|
||
const layer = document.querySelector('.sf-loops')
|
||
return { n: frames.length, tags, cover, box, pe: layer ? getComputedStyle(layer).pointerEvents : null,
|
||
stroke: frames[0] ? getComputedStyle(frames[0]).vectorEffect : null }
|
||
})
|
||
const r = await read()
|
||
const all = (arr) => arr.every(Boolean)
|
||
const none = (arr) => arr.every((v) => !v)
|
||
check(`${theme}: 两个循环各一块底框`, r.n === 2, `${r.n}`)
|
||
check(`${theme}: 标签写出每进来一次最多几轮`, r.tags.outer?.text === '↻ 循环体 · 最多 3 轮' && r.tags.inner?.text === '↻ 循环体 · 最多 4 轮',
|
||
JSON.stringify(r.tags))
|
||
check(`${theme}: 外层框圈住循环节点和整个循环体(含内层)`, ['outer', 'inner', 'check', 'merge'].every((id) => all(r.cover.outer?.[id] ?? [])),
|
||
JSON.stringify(r.cover.outer))
|
||
check(`${theme}: 内层框只圈内层循环和它的循环体`, all(r.cover.inner?.inner ?? []) && all(r.cover.inner?.check ?? [])
|
||
&& none(r.cover.inner?.merge ?? [true]) && none(r.cover.inner?.outer ?? [true]), JSON.stringify(r.cover.inner))
|
||
check(`${theme}: 同列却不在循环里的节点不被圈进去`, ['in', 'side', 'report'].every((id) => none(r.cover.outer?.[id] ?? [true])),
|
||
JSON.stringify({ side: r.cover.outer?.side, report: r.cover.outer?.report }))
|
||
const b = r.box
|
||
check(`${theme}: 框套框:内层四周都在外层以内、留出一圈`, !!b.outer && !!b.inner && b.inner.x - b.outer.x >= 8 && b.inner.y - b.outer.y >= 8
|
||
&& b.outer.r - b.inner.r >= 8 && b.outer.b - b.inner.b >= 8, JSON.stringify(b))
|
||
check(`${theme}: 底框不接指针(平移、点空白处照常)`, r.pe === 'none', r.pe)
|
||
check(`${theme}: 框线不随缩放变粗变细`, r.stroke === 'non-scaling-stroke', r.stroke)
|
||
// 点框里的空白处:落到画布上,选中的节点被取消
|
||
await page.evaluate(() => window.__studio.getState().select('check'))
|
||
const spot = await page.evaluate(() => {
|
||
const f = document.querySelector('.sf-loop-frame[data-loop="outer"]')
|
||
const bb = f.getBoundingClientRect()
|
||
for (let y = bb.top + 4; y < bb.bottom - 4; y += 6) {
|
||
for (let x = bb.left + 4; x < bb.right - 4; x += 6) {
|
||
const el = document.elementFromPoint(x, y)
|
||
const m = f.getScreenCTM().inverse()
|
||
const p = new DOMPoint(x, y).matrixTransform(m)
|
||
if (el?.classList.contains('react-flow__pane') && f.isPointInFill(p)) return { x, y }
|
||
}
|
||
}
|
||
return null
|
||
})
|
||
if (spot) await page.mouse.click(spot.x, spot.y)
|
||
await page.waitForTimeout(200)
|
||
check(`${theme}: 点框里的空白处等于点画布(取消选中)`, !!spot && (await page.evaluate(() => window.__studio.getState().selectedId)) === null, JSON.stringify(spot))
|
||
await page.screenshot({ path: `/tmp/agentlab-canvas-fx-loops-${theme}.png` })
|
||
|
||
// 跑起来:外层第 1 轮、内层第 2 轮
|
||
const t0 = Date.now() / 1000 - 5
|
||
const le = (seq, type, node_id, data = {}, t = seq * 0.2) => ({ seq, type, node_id, data, ts: t0 + t })
|
||
const ROUNDS = [
|
||
le(1, 'run.started', null, { nodes: 7 }), le(2, 'node.started', 'in'), le(3, 'node.finished', 'in', { duration_ms: 5 }),
|
||
le(4, 'node.started', 'outer'), le(5, 'edge.taken', 'outer', { branch: 'body', iteration: 0, total: 2, mode: 'foreach' }),
|
||
le(6, 'node.finished', 'outer', { duration_ms: 1, preview: { __decision__: 'body' } }),
|
||
le(7, 'node.started', 'inner'), le(8, 'edge.taken', 'inner', { branch: 'body', iteration: 0, mode: 'while' }),
|
||
le(9, 'node.finished', 'inner', { duration_ms: 1, preview: { __decision__: 'body' } }),
|
||
le(10, 'node.started', 'check', { iteration: 1 }), le(11, 'node.finished', 'check', { duration_ms: 180 }),
|
||
le(12, 'node.started', 'inner'), le(13, 'edge.taken', 'inner', { branch: 'body', iteration: 1, mode: 'while' }),
|
||
le(14, 'node.finished', 'inner', { duration_ms: 1, preview: { __decision__: 'body' } }),
|
||
le(15, 'node.started', 'check', { iteration: 2 }),
|
||
]
|
||
await seedRun(page)
|
||
await feed(page, ROUNDS)
|
||
await page.waitForTimeout(300)
|
||
const live = await read()
|
||
check(`${theme}: 在跑的循环写出第几轮(外层第 1 轮、内层第 2 轮)`,
|
||
live.tags.outer?.text.endsWith('第 1 轮') && live.tags.inner?.text.endsWith('第 2 轮') && live.tags.inner.running, JSON.stringify(live.tags))
|
||
// 回放到内层第 1 轮
|
||
await page.evaluate((ts) => window.__studio.getState().setReplayAt(ts * 1000), ROUNDS[9].ts + 0.05)
|
||
await page.waitForTimeout(250)
|
||
const past = await read()
|
||
check(`${theme}: 回放时写的是游标那一刻的轮次`, past.tags.inner?.text.endsWith('第 1 轮'), JSON.stringify(past.tags.inner))
|
||
await page.evaluate(() => window.__studio.getState().setReplayAt(null))
|
||
await feed(page, [le(16, 'run.cancelled', null, { timing: { wall_ms: 3000, active_ms: 3000, wait_ms: 0 } })])
|
||
await page.waitForTimeout(300)
|
||
const ended = await read()
|
||
check(`${theme}: 运行结束就不再写轮次(正常态安静)`, !ended.tags.outer?.running && !ended.tags.inner?.running
|
||
&& !/第 \d+ 轮/.test(`${ended.tags.outer?.text}${ended.tags.inner?.text}`), JSON.stringify(ended.tags))
|
||
await page.evaluate(() => window.__studio.getState().clearRun())
|
||
|
||
// 远景:框还在(一道细线),标签不画
|
||
for (let i = 0; i < 10; i++) {
|
||
if (await page.evaluate(() => document.querySelector('.react-flow').dataset.lod === 'signal')) break
|
||
await page.locator('.react-flow__controls-zoomout').click()
|
||
await page.waitForTimeout(250)
|
||
}
|
||
await page.waitForTimeout(300)
|
||
const far = await page.evaluate(() => ({ lod: document.querySelector('.react-flow').dataset.lod,
|
||
frames: document.querySelectorAll('.sf-loop-frame').length,
|
||
tag: getComputedStyle(document.querySelector('.sf-loop-tag')).visibility }))
|
||
check(`${theme}: 信号档里框线还在、标签不画`, far.lod === 'signal' && far.frames === 2 && far.tag === 'hidden', JSON.stringify(far))
|
||
check(`${theme}: 没有运行时报错`, errors.length === 0, errors.slice(0, 2).join(' | '))
|
||
await browser.close()
|
||
}
|
||
})
|
||
|
||
await section('系统关了动效', async () => {
|
||
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)
|