Files
AgentLab/scripts/check-studio.mjs
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

895 lines
54 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.
// 编排页编辑体验的检查:撤销重做、问题面板定位、快捷键、三栏收起、调色板落点、
// 分支 default 拦截、?run=&focus= 参数、助手改图(应用 / 只回话 / 失败 / 停止)。
//
// 不写库:要编辑的工作流、它的版本、画布会话、助手的流式输出都用 page.route 在
// 浏览器里伪造;后端只被读(目录、校验、变量分析这些纯计算)。保存走伪造的 PATCH,
// 其余写请求一律拦成 409 并记下来——检查结束时这张单子必须是空的。
//
// 跑法(只对沙箱):
// AGENTLAB_WEB=http://localhost:5373 AGENTLAB_API=http://localhost:8100/api node scripts/check-studio.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'
const MOD = process.platform === 'darwin' ? 'Meta' : 'Control'
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 120)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
// ---------------------------------------------------------------- 夹具
const node = (id, type, x, y, label, config = {}) => ({ id, type, position: { x, y }, data: { label, config } })
const edge = (source, target, sourceHandle) => ({
id: `e_${source}_${sourceHandle ?? ''}_${target}`, source, target, sourceHandle: sourceHandle ?? null,
})
const GRAPH = {
nodes: [
node('start', 'input', 0, 200, '目标', { fields: [{ name: 'goal', required: true }] }),
node('lookup', 'retrieve', 300, 200, '背景检索', { query: '{{ input.goal }}', assign_to: 'knowledge', limit: 3 }),
node('gate', 'branch', 600, 200, '模式分支', {
mode: 'expression',
cases: [{ key: 'fast', condition: "input.goal == 'x'", label: '快速' },
{ key: 'default', condition: 'true', label: '协作' }],
}),
// vars.summary 由下游的「汇总」产出:这里取到的是空值(warning),补全里也该置灰
node('answer', 'llm', 900, 80, '快速回答', { prompt: '{{ input.goal }} {{ vars.knowledge }} {{ vars.summary }}', assign_to: 'result' }),
node('call', 'tool', 900, 340, '查询销量', { args: {} }),
node('sum', 'transform', 1200, 200, '汇总', { mode: 'template', template: '{{ vars.result }}', assign_to: 'summary' }),
node('done', 'output', 1500, 200, '成果', { fields: [{ name: '结果', value: '{{ vars.summary }}' }] }),
],
edges: [
edge('start', 'lookup'), edge('lookup', 'gate'), edge('gate', 'answer', 'fast'),
edge('gate', 'call', 'default'), edge('answer', 'sum'), edge('call', 'sum'), edge('sum', 'done'),
],
}
const V1 = { nodes: GRAPH.nodes.slice(0, 3).concat([GRAPH.nodes[6]]), edges: [edge('start', 'lookup'), edge('lookup', 'gate'), edge('gate', 'done', 'fast')] }
const wf = (id, extra) => ({
id, name: extra.name, description: extra.description ?? '检查脚本伪造的工作流', graph: extra.graph ?? GRAPH,
tags: extra.tags ?? [], version: extra.version ?? 3, is_template: !!extra.is_template,
status: extra.status ?? 'draft', published_version: extra.published_version ?? null,
published_by: extra.published_by ?? null, run_count: 0,
created_at: '2026-09-20T02:00:00Z', updated_at: '2026-09-26T02:00:00Z',
})
const FAKES = {
'st-main': wf('st-main', { name: '__studio_check__', version: 3, published_version: 2 }),
'st-gov': wf('st-gov', { name: '__studio_check_gov__', status: 'governed', version: 3, published_version: 3, published_by: '张工' }),
'st-tpl': wf('st-tpl', { name: '__studio_check_tpl__', is_template: true, tags: ['extracted'] }),
}
const VERSIONS = [
{ id: 'v3', version: 3, note: '', created_at: '2026-09-26T02:00:00Z' },
{ id: 'v2', version: 2, note: '收紧检索条数', created_at: '2026-09-25T02:00:00Z' },
{ id: 'v1', version: 1, note: '起步', created_at: '2026-09-24T02:00:00Z' },
]
const FAKE_RUN = 'st-run-0001'
const sse = (ops) => ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('')
// ---------------------------------------------------------------- 浏览器
const browser = await chromium.launch({ executablePath: CHROME })
const writes = []
let dialogs = 0
/**
* only:目录里只放这几张伪造的(不混真实的),用来造「删完一张不剩」
* platform:伪装成别的平台('Windows'),看快捷键提示是不是跟着平台走
*/
async function open({ width = 1440, height = 900, path = '/studio/st-main', prefs = {}, stream, only, platform } = {}) {
const ctx = await browser.newContext({ viewport: { width, height } })
// 每个上下文只在第一次加载时清偏好:刷新之后要看得到上一次存下的收起状态
await ctx.addInitScript((p) => {
try {
if (sessionStorage.getItem('st-init')) return
sessionStorage.setItem('st-init', '1')
localStorage.removeItem('agentlab.studio.palette')
localStorage.removeItem('agentlab.studio.assistant')
localStorage.setItem('agentlab_actor', '检查脚本')
for (const [k, v] of Object.entries(p)) localStorage.setItem(k, v)
} catch { /* noop */ }
}, prefs)
if (platform) {
await ctx.addInitScript((p) => {
Object.defineProperty(navigator, 'platform', { get: () => (p === 'Windows' ? 'Win32' : p) })
Object.defineProperty(navigator, 'userAgentData', { get: () => ({ platform: p }) })
}, platform)
}
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { dialogs++; void d.dismiss() })
const json = (route, body, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
const state = { stream: stream ?? null, bodies: [], patches: [], deleted: new Set() }
// 兜底:别的写请求一律拦下记账(先注册的最后匹配)
await page.route(/\/api\//, (route) => {
const req = route.request()
if (req.method() === 'GET') return route.continue()
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
return json(route, { detail: '检查脚本不写库' }, 409)
})
await page.route(/\/api\/workflows\/(validate|variables)$/, (route) => route.continue())
await page.route(/\/api\/copilot\/layout$/, (route) => route.continue())
await page.route(/\/api\/workflows(\?.*)?$/, async (route) => {
if (route.request().method() !== 'GET') return route.fallback()
const res = await route.fetch()
const real = only ? [] : await res.json().catch(() => [])
const fakes = Object.values(FAKES).filter((w) => (!only || only.includes(w.id)) && !state.deleted.has(w.id))
return json(route, [...fakes, ...(Array.isArray(real) ? real : [])])
})
await page.route(/\/api\/workflows\/st-[a-z]+(\/.*)?(\?.*)?$/, (route) => {
const url = new URL(route.request().url())
const [, , , id, sub, v] = url.pathname.split('/')
const method = route.request().method()
if (sub === 'versions' && !v) return json(route, VERSIONS)
if (sub === 'versions' && v) {
const n = Number(v)
return json(route, { ...VERSIONS.find((x) => x.version === n), workflow_id: id,
graph: n === 1 ? V1 : GRAPH, graph_hash: 'x', input_fields: [{ name: 'goal' }], published: n === 2 })
}
if (method === 'GET' && !sub) return state.deleted.has(id) ? json(route, { detail: '不在了' }, 404) : json(route, FAKES[id])
if (method === 'DELETE' && !sub) {
state.deleted.add(id)
return route.fulfill({ status: 204, body: '' })
}
if (method === 'PATCH' && !sub) {
const body = route.request().postDataJSON()
state.patches.push(body)
return json(route, { ...FAKES[id], ...body, version: FAKES[id].version + 1, status: 'draft' })
}
return route.fallback()
})
await page.route(/\/api\/conversations(\/.*)?(\?.*)?$/, (route) => {
const req = route.request()
const url = new URL(req.url())
if (req.method() === 'GET' && url.pathname.endsWith('/conversations')) {
return json(route, [{ id: 'conv-old', title: '', kind: 'canvas', workflow_id: 'st-main', archived: false,
turn_count: 2, last_question: '改造一下' }])
}
if (req.method() === 'GET') {
return json(route, { id: 'conv-old', title: '', kind: 'canvas', archived: false, turn_count: 2, last_question: '',
turns: [1, 2].map((i) => ({ id: `t${i}`, seq: i, question: `之前的第 ${i} 轮`, answer: '', explanation: '好',
status: 'done', error: '' })) })
}
if (req.method() === 'POST' && url.pathname.endsWith('/conversations')) {
return json(route, { id: 'conv-new', title: '', kind: 'canvas', archived: false, turn_count: 0, last_question: '', turns: [] })
}
// 记一轮:startTurn / patchTurn
return json(route, { id: 't-new', seq: 3, question: 'x', answer: '', explanation: '', status: 'done', error: '' })
})
await page.route(/\/api\/copilot\/generate-stream$/, async (route) => {
state.bodies.push(route.request().postDataJSON())
const s = state.stream
if (s === 'hang') return new Promise(() => {})
return route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse(s ?? []) })
})
await page.route(new RegExp(`/api/runs/${FAKE_RUN}(\\?.*)?$`), (route) => json(route, {
id: FAKE_RUN, workflow_id: 'st-main', status: 'succeeded', run_class: 'exploratory', version: 3,
input: {}, output: {}, error: '', usage: {}, created_at: '2026-09-26T02:00:00Z',
}))
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.waitForFunction(() => window.__studio?.getState().workflow != null, null, { timeout: 8000 })
await page.waitForTimeout(500)
return { ctx, page, errors, state }
}
const S = (page, fn) => page.evaluate(fn)
const st = (page) => page.evaluate(() => {
const s = window.__studio.getState()
return { nodes: s.nodes.map((n) => n.id), edges: s.edges.length, selectedId: s.selectedId,
past: s.past.length, future: s.future.length, dirty: s.dirty, analysis: s.analysis }
})
const waitAnalysis = (page) => page.waitForFunction(() => {
const s = window.__studio.getState()
return s.analysis === 'ok' && s.issues.length > 0
}, null, { timeout: 8000 })
/** 点画布空白处:让焦点离开输入框(快捷键不抢输入框里的键) */
const blur = (page) => page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } })
const count = (page, sel) => page.locator(sel).count()
// ================================================================ 1
console.log('=== 工具栏:版本标签、校验结论、问题面板定位 ===')
{
const { ctx, page, errors } = await open()
await waitAnalysis(page)
const label = await page.locator('span[title*="画布是草稿"]').innerText().catch(() => '')
check('状态标签写出草稿和已发布两版', /草稿 v3/.test(label) && /已发布 v2/.test(label) && /领先 1 版/.test(label), label.replace(/\s+/g, ' '))
const chip = page.locator('button[aria-expanded]').filter({ hasText: /错|提示|可运行/ }).first()
const chipText = (await chip.innerText()).replace(/\s+/g, ' ')
check('校验 chip 同时写出错误和提示', /1 错/.test(chipText) && /提示/.test(chipText), chipText)
await chip.click()
await page.waitForTimeout(250)
check('点 chip 打开问题面板', await count(page, '[role="tablist"][aria-label="画布停靠栏"]') === 1)
const rows = await count(page, '[data-problem]')
const issues = await S(page, () => window.__studio.getState().issues.length)
check('每条问题一行', rows === issues, `${rows} 行 / ${issues} 条`)
await page.locator('[data-problem]').filter({ hasText: '还没选工具' }).locator('button').first().click()
await page.waitForTimeout(400)
const s = await S(page, () => ({ sel: window.__studio.getState().selectedId, focus: window.__studio.getState().focusRequest?.id }))
check('点一条问题:选中那个节点', s.sel === 'call', s.sel)
check('点一条问题:请画布取景到它', s.focus === 'call', s.focus)
const fieldMsg = await page.locator('[data-field="tool"]').innerText().catch(() => '')
check('字段级错误落在「工具」输入框下面', fieldMsg.includes('还没选工具'), fieldMsg.slice(0, 40))
const ring = await S(page, () => window.__studio.getState().nodes.filter((n) => n.selected).map((n) => n.id).join(','))
check('React Flow 的选中和检查器一致', ring === 'call', ring)
// 图级和悬空边:注入和后端同形的 issues
await S(page, () => window.__studio.setState((s) => ({ issues: [...s.issues,
{ level: 'error', node_id: null, edge_id: null, message: '找不到入口:每个节点都有入边,图里存在环且没有起点' },
{ level: 'error', node_id: null, edge_id: 'ghost', message: "边指向了不存在的目标节点 'nope'" }] })))
await page.waitForTimeout(200)
check('图级问题有自己的分组', (await page.locator('[role="list"][aria-label="问题"]').innerText()).includes('整张工作流'))
check('悬空边给出「删除这条悬空边」', await count(page, 'button:has-text("删除这条悬空边")') === 1)
// F8 在节点上的问题之间跳(图级、边上的没有节点可定位,列在面板顶上)
await blur(page)
await page.keyboard.press('F8')
await page.waitForTimeout(200)
const first = await S(page, () => window.__studio.getState().selectedId)
await page.keyboard.press('F8')
await page.waitForTimeout(200)
const second = await S(page, () => window.__studio.getState().selectedId)
await page.keyboard.press('Shift+F8')
await page.waitForTimeout(200)
const back = await S(page, () => window.__studio.getState().selectedId)
check('F8 逐个跳到有问题的节点,⇧F8 往回', !!first && !!second && back === first, `${first} → ${second} → ${back}`)
// 字段级 warning + 模板高亮
await S(page, () => window.__studio.getState().analyzeNow())
await S(page, () => window.__studio.getState().select('answer'))
await page.waitForTimeout(400)
const promptMsg = await page.locator('[data-field="prompt"]').innerText().catch(() => '')
check('下游变量的 warning 落在「用户提示」下面', /之后才跑|取到的会是空值/.test(promptMsg), promptMsg.slice(0, 50))
check('高亮层把下游变量标成琥珀', await count(page, '[data-field="prompt"] [data-path="vars.summary"][data-tone="late"]') === 1)
check('能取到的变量是普通胶囊', await count(page, '[data-field="prompt"] [data-path="vars.knowledge"][data-tone="ok"]') === 1)
check('字段旁标着「模板」', (await page.locator('[data-field="prompt"]').innerText()).includes('模板'))
// 补全只给上游
const ta = page.locator('[data-field="prompt"] textarea')
await ta.click()
await page.keyboard.press('End')
await page.keyboard.type(' {{ va')
await page.waitForTimeout(200)
const list = await page.locator('[data-field="prompt"] [role="listbox"]').innerText().catch(() => '')
check('补全给出上游的 vars.knowledge', list.includes('vars.knowledge'), list.replace(/\s+/g, ' ').slice(0, 80))
check('下游的 vars.summary 置灰、写明取不到', /vars\.summary[\s\S]*这里还取不到/.test(list))
await page.keyboard.press('Escape')
// 表达式字段敲 {{ 就地提示
await S(page, () => window.__studio.getState().select('gate'))
await page.waitForTimeout(300)
const cond = page.locator('[data-field="cases"] input').nth(2)
await cond.click()
await page.keyboard.press('End')
await page.keyboard.type(' {{')
await page.waitForTimeout(150)
check('表达式里敲 {{ 就地提示不需要', (await page.locator('[data-field="cases"]').innerText()).includes('这里是表达式'))
check('条件字段标着「表达式」', (await page.locator('[data-field="cases"]').innerText()).includes('表达式'))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await ctx.close()
}
// ================================================================ 2
console.log('=== 分支 case:default 拦截、重复标识、出口去重 ===')
{
const { ctx, page } = await open()
await S(page, () => window.__studio.getState().select('gate'))
await page.waitForTimeout(400)
const handles = () => count(page, '.react-flow__node[data-id="gate"] .react-flow__handle.source')
check('key=default 和兜底出口合并成一个(2 个出口)', await handles() === 2, `${await handles()} 个`)
const text = await page.locator('[data-field="cases"]').innerText()
check('行内说破 default 是保留名', text.includes('兜底出口的保留名'))
check('case 表单每个框都有标签', /标识(连线出口)/.test(text) && /说明/.test(text) && /条件/.test(text))
await page.locator('[data-field="cases"] button:has-text("改成 team")').click()
await page.waitForTimeout(250)
const key = await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'gate').data.config.cases[1].key)
check('一键改名为 team', key === 'team', key)
check('改名后出口变成 3 个(fast / team / 其他)', await handles() === 3, `${await handles()} 个`)
const keyInput = page.locator('[data-field="cases"] input').nth(3)
const caseKey = () => S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'gate').data.config.cases[1].key)
const gateEdge = () => S(page, () => window.__studio.getState().edges.find((e) => e.source === 'gate' && e.target === 'call')?.sourceHandle ?? null)
// 改名:先清空再重打,中间态不落地,连在这个出口上的线跟过去
await keyInput.fill('')
await keyInput.type('crew')
check('写到一半(空标识)不落进画布,线不断', await caseKey() === 'team' && await gateEdge() === 'team', `${await caseKey()} / ${await gateEdge()}`)
await keyInput.press('Enter')
await page.waitForTimeout(200)
check('回车后改名生效,线跟到新出口', await caseKey() === 'crew' && await gateEdge() === 'crew', `${await caseKey()} / ${await gateEdge()}`)
// 禁用重复
await keyInput.fill('fast')
await page.waitForTimeout(200)
check('重复标识当场报错', (await page.locator('[data-field="cases"]').innerText()).includes('重复'))
await keyInput.press('Enter')
await page.waitForTimeout(200)
check('重复标识落不进画布,退回原来的', await caseKey() === 'crew' && await gateEdge() === 'crew', `${await caseKey()}`)
check('说明为什么没改', (await page.locator('[data-field="cases"]').innerText()).includes('没有改成「fast」'))
check('重复标识不产生同 id 的两个出口', await count(page, '.react-flow__node[data-id="gate"] .react-flow__handle[data-handleid="fast"]') === 1)
// 禁用 default
await keyInput.fill('default')
await page.waitForTimeout(150)
check('新写 default 当场拦下', (await page.locator('[data-field="cases"]').innerText()).includes('保留名'))
// 焦点挪到别的输入框上(点画布空白会连检查器一起收起)
await page.locator('input[id^="node-gate-label"]').click()
await page.waitForTimeout(200)
check('default 落不进画布(离开输入框也不行)', await caseKey() === 'crew', await caseKey())
// 新加的分支自带不重名的标识,不会一出来就是红的
await page.locator('[data-field="cases"] button:has-text("添加分支")').click()
await page.waitForTimeout(150)
const keys = await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'gate').data.config.cases.map((c) => c.key))
check('添加分支自带不重名的标识', keys.length === 3 && !!keys[2] && new Set(keys).size === 3, keys.join(','))
await ctx.close()
}
// ================================================================ 3
console.log('=== 撤销 / 重做、删除回执、复制粘贴、调色板落点 ===')
{
const { ctx, page, errors } = await open()
const s0 = await st(page)
// 连续打字只算一步
await S(page, () => window.__studio.getState().select('answer'))
await page.waitForTimeout(250)
const name = page.locator('input[id^="node-answer-label"]')
await name.click()
await page.keyboard.press('End')
await page.keyboard.type('(改)')
await page.waitForTimeout(100)
const s1 = await st(page)
check('连续打字合成一步撤销', s1.past === s0.past + 1, `${s0.past} → ${s1.past}`)
await blur(page)
await page.keyboard.press(`${MOD}+z`)
await page.waitForTimeout(150)
const label = await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'answer').data.label)
check('⌘Z 撤回改名', label === '快速回答', label)
check('撤回到保存时的样子,「未保存」消失', !(await st(page)).dirty)
await page.keyboard.press(`${MOD}+Shift+z`)
await page.waitForTimeout(150)
check('⇧⌘Z 重做', (await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'answer').data.label)) === '快速回答(改)')
await page.keyboard.press(`${MOD}+z`)
// 删除:Backspace → 检查器不留空白层,toast 能撤销
await page.locator('.react-flow__node[data-id="sum"]').click()
await page.waitForTimeout(200)
await page.keyboard.press('Backspace')
await page.waitForTimeout(300)
const s2 = await st(page)
check('Backspace 删掉选中的节点', !s2.nodes.includes('sum'))
check('删掉之后检查器收起', s2.selectedId === null && await count(page, 'button[title="返回助手(Esc)"]') === 0)
const toastUndo = page.locator('button:has-text("撤销")').last()
check('删除回执带「撤销」', await page.getByText('已删除「汇总」').count() === 1)
await toastUndo.click()
await page.waitForTimeout(250)
const s3 = await st(page)
check('回执里撤销:节点和它的边都回来了', s3.nodes.includes('sum') && s3.edges === s0.edges, `${s3.edges}/${s0.edges} 条边`)
check('一次删除(边 + 节点)只占一步', s3.future === 1, `future=${s3.future}`)
// 什么都没变的编辑:JSON 字段里删个空格会把同一个对象原样再发一遍。不记步、不亮「未保存」,
// 也不该顺手清掉重做栈
await S(page, () => {
const s = window.__studio.getState()
const n = s.nodes.find((x) => x.id === 'call')
s.updateNode('call', { label: n.data.label, config: structuredClone(n.data.config) })
})
const same = await st(page)
check('原样再发一遍的编辑不占撤销、不标未保存', same.past === s3.past && same.future === s3.future && same.dirty === s3.dirty,
`past ${s3.past}→${same.past} future ${s3.future}→${same.future} dirty ${same.dirty}`)
// 复制粘贴、⌘D
await page.locator('.react-flow__node[data-id="answer"]').click()
await page.waitForTimeout(150)
await blur(page)
await S(page, () => window.__studio.getState().select('answer'))
await page.locator('.react-flow__pane').focus().catch(() => {})
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur())
await page.keyboard.press(`${MOD}+c`)
await page.keyboard.press(`${MOD}+v`)
await page.waitForTimeout(200)
const s4 = await st(page)
const pasted = s4.nodes.filter((id) => !s3.nodes.includes(id))
check('⌘C ⌘V 粘出一个新节点(新 id)', pasted.length === 1 && pasted[0].startsWith('llm_'), pasted.join(','))
await page.keyboard.press(`${MOD}+d`)
await page.waitForTimeout(200)
check('⌘D 原地复制一份', (await st(page)).nodes.length === s4.nodes.length + 1)
// 调色板点击添加:视口中心附近、不压住别人、自动选中
const before = await st(page)
await page.locator('button[aria-label="添加模型调用"], button:has-text("模型调用")').first().click()
await page.waitForTimeout(300)
const added = await S(page, () => {
const s = window.__studio.getState()
const n = s.nodes[s.nodes.length - 1]
const c = s.getViewportCenter?.()
const box = (x) => ({ x: x.position.x, y: x.position.y, w: x.measured?.width ?? 238, h: x.measured?.height ?? 96 })
const me = box(n)
const hit = s.nodes.filter((o) => o.id !== n.id).some((o) => {
const b = box(o)
return me.x < b.x + b.w && b.x < me.x + me.w && me.y < b.y + b.h && b.y < me.y + me.h
})
return { id: n.id, sel: s.selectedId, ring: !!n.selected, dx: c ? Math.abs(n.position.x + 119 - c.x) : -1,
dy: c ? Math.abs(n.position.y + 48 - c.y) : -1, hit, hasCenter: !!c,
prompt: n.data.config.prompt }
})
check('调色板点击:多了一个节点', (await st(page)).nodes.length === before.nodes.length + 1)
check('新节点自动选中(检查器 + 选中环)', added.sel === added.id && added.ring, JSON.stringify(added).slice(0, 80))
check('落点在视口中心附近', added.hasCenter && added.dx < 700 && added.dy < 500, `dx=${Math.round(added.dx)} dy=${Math.round(added.dy)}`)
check('不压住已有节点', !added.hit)
check('默认提示词用上了这张图真实的入口字段', added.prompt === '{{ input.goal }}', added.prompt)
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await ctx.close()
}
// ================================================================ 4
console.log('=== 快捷键与三栏 ===')
{
const { ctx, page, state } = await open()
const width = (sel) => page.locator(sel).evaluate((el) => Math.round(el.getBoundingClientRect().width))
await blur(page)
await page.keyboard.press('Alt+KeyV')
await page.waitForTimeout(250)
check('⌥V 打开变量抽屉(Mac 上 e.key 是 √ 也认)', await count(page, '#dock-variables') === 1)
await page.hover('tr[data-var="vars.knowledge"]')
await page.waitForTimeout(150)
const lineage = await S(page, () => window.__studio.getState().lineage)
check('悬停变量:画出血缘(谁产出、谁引用)', lineage?.var === 'vars.knowledge' && lineage.producers[0] === 'lookup'
&& lineage.consumers.includes('answer'), JSON.stringify(lineage))
const drawn = await page.evaluate(() => {
const nc = (id) => document.querySelector(`.react-flow__node[data-id="${id}"] > .nc`)
const tag = (id) => getComputedStyle(nc(id), '::after').content
const dim = (id) => getComputedStyle(nc(id)).getPropertyValue('--nc-dim').trim()
const edgeOp = (s, t) => {
const e = window.__studio.getState().edges.find((x) => x.source === s && x.target === t)
return getComputedStyle(document.querySelector(`.react-flow__edge[data-id="${CSS.escape(e.id)}"]`)).opacity
}
return { producer: tag('lookup'), consumer: tag('answer'), other: dim('call'), mid: dim('gate'),
hop: edgeOp('gate', 'answer'), off: edgeOp('gate', 'call') }
})
check('画布上标出产出和引用的节点', drawn.producer.includes('产出 vars.knowledge') && drawn.consumer.includes('引用'),
`${drawn.producer} / ${drawn.consumer}`)
check('中间隔着的节点和走线一起亮,其余退下去', drawn.mid === '1' && drawn.other === '0.45'
&& drawn.hop === '1' && Number(drawn.off) < 1, JSON.stringify(drawn))
await blur(page)
await page.keyboard.press('Alt+KeyV')
await page.waitForTimeout(200)
check('再按 ⌥V 收起', await count(page, '#dock-variables') === 0)
check('收起后血缘高亮一起收掉', (await S(page, () => window.__studio.getState().lineage)) === null)
await page.keyboard.press('Alt+KeyP')
await page.waitForTimeout(200)
check('⌥P 打开问题面板', await count(page, '#dock-problems') === 1)
await page.keyboard.press('Alt+KeyN')
await page.waitForTimeout(200)
check('⌥N 把节点库收成 48px 图标轨', await width('aside:has([aria-label^="节点库"])') === 48)
check('收起状态存进 localStorage', await S(page, () => localStorage.getItem('agentlab.studio.palette')) === 'closed')
// 图标轨的悬停说明:入场只有 fade-up 那 4px,不能先落低半个身位、动画一完再跳上去
const railBtn = page.locator('button[aria-label="添加模型调用"]')
await railBtn.hover()
await page.waitForSelector('[role="tooltip"]')
const tops = []
for (let i = 0; i < 10; i++) {
tops.push(await page.evaluate(() => {
const t = document.querySelector('[role="tooltip"]')
return { outer: t.getBoundingClientRect().top, inner: t.firstElementChild.getBoundingClientRect().top }
}))
await page.waitForTimeout(20)
}
const iconMid = await railBtn.evaluate((el) => { const r = el.getBoundingClientRect(); return r.top + r.height / 2 })
const tipMid = await page.evaluate(() => { const r = document.querySelector('[role="tooltip"]').firstElementChild.getBoundingClientRect(); return r.top + r.height / 2 })
const inner = tops.map((t) => t.inner)
const spread = Math.max(...inner) - Math.min(...inner)
check('图标轨说明浮层不跳:入场只挪 4px,落定时和图标对齐',
new Set(tops.map((t) => Math.round(t.outer))).size === 1 && spread <= 4.5 && Math.abs(tipMid - iconMid) <= 1,
`spread=${spread.toFixed(1)} 偏差=${(tipMid - iconMid).toFixed(1)}`)
await page.mouse.move(700, 450)
await page.keyboard.press('Alt+KeyA')
await page.waitForTimeout(200)
const asideW = await page.locator('aside[aria-hidden="true"]').count()
check('⌥A 收起助手栏(不卸载,输入草稿保住)', asideW === 1 && await count(page, 'textarea') > 0)
const overflow = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check('助手栏收起时页面不横向溢出', await overflow() <= 0, `${await overflow()}px`)
await page.keyboard.press('Alt+KeyA')
await page.waitForTimeout(150)
check('助手栏展开时页面不横向溢出', await overflow() <= 0, `${await overflow()}px`)
// 选着节点也要收得起来:以前「选中就展开」的 effect 盯着栏的开合,一收它就又展开
const asideWidth = () => page.locator('main + aside').evaluate((el) => Math.round(el.getBoundingClientRect().width))
await page.locator('.react-flow__node[data-id="lookup"]').click()
await page.waitForTimeout(200)
const picked = await S(page, () => window.__studio.getState().selectedId)
await page.keyboard.press('Alt+KeyA')
await page.waitForTimeout(250)
const shut = { w: await asideWidth(), sel: await S(page, () => window.__studio.getState().selectedId) }
check('选着节点按 ⌥A 也收得起助手栏(属性面板跟着收)', picked === 'lookup' && shut.w === 0 && shut.sel === null, JSON.stringify(shut))
await page.locator('.react-flow__node[data-id="answer"]').click()
await page.waitForTimeout(250)
check('收着时选一个节点:栏展开,属性面板有地方待', await asideWidth() > 0 && await count(page, '[data-field="prompt"]') === 1)
await page.getByRole('button', { name: '收起助手栏' }).click()
await page.waitForTimeout(250)
check('选着节点点「收起助手栏」也收得起来', await asideWidth() === 0)
await page.getByRole('button', { name: '展开助手栏' }).click()
await page.waitForTimeout(200)
await page.keyboard.press('/')
await page.waitForTimeout(200)
check('「/」展开节点库并聚焦搜索', await S(page, () => document.activeElement?.id) === 'studio-palette-search')
await blur(page)
await page.keyboard.press('Alt+KeyH')
await page.waitForTimeout(500)
check('⌥H 打开版本历史', await count(page, '[role="dialog"][aria-label="版本历史"]') === 1)
await page.keyboard.press('Escape')
await page.waitForTimeout(150)
await S(page, () => window.__studio.getState().select('lookup'))
await blur(page)
await S(page, () => window.__studio.getState().select('lookup'))
const seq0 = await S(page, () => window.__studio.getState().focusRequest?.seq ?? 0)
await page.keyboard.press('f')
await page.waitForTimeout(100)
const fr = await S(page, () => window.__studio.getState().focusRequest)
check('F 把镜头对准选中的节点', fr?.id === 'lookup' && fr.seq > seq0, JSON.stringify(fr))
// ⌘S 保存(走伪造的 PATCH)
await S(page, () => window.__studio.getState().updateNode('lookup', { label: '背景检索2' }))
await blur(page)
await page.keyboard.press(`${MOD}+s`)
await page.waitForTimeout(500)
check('⌘S 保存', state.patches.length === 1 && !(await st(page)).dirty, `${state.patches.length} 次 PATCH`)
// 刷新后收起状态还在(上面的「/」把它展开了,先收回去)
await blur(page)
await page.keyboard.press('Alt+KeyN')
await page.waitForTimeout(150)
await page.reload({ waitUntil: 'networkidle' })
await page.waitForTimeout(500)
check('刷新后节点库仍是收起的', await width('aside:has([aria-label^="节点库"])') === 48)
await ctx.close()
const narrow = await open({ width: 1024, height: 760 })
check('1024 宽默认收起节点库', await narrow.page.locator('aside:has([aria-label^="节点库"])')
.evaluate((el) => Math.round(el.getBoundingClientRect().width)) === 48)
const tb = await narrow.page.evaluate(() => {
const t = document.querySelector('.\\@container')
return { sw: t.scrollWidth, cw: t.clientWidth, h: Math.round(t.getBoundingClientRect().height) }
})
check('1024 宽工具栏不溢出、不超过 48px', tb.sw <= tb.cw && tb.h <= 48, JSON.stringify(tb))
await narrow.ctx.close()
const wide = await open({ width: 1440 })
check('1440 宽默认展开节点库', await wide.page.locator('aside:has([aria-label^="节点库"])')
.evaluate((el) => Math.round(el.getBoundingClientRect().width)) === 208)
await wide.ctx.close()
}
// ================================================================ 4b
console.log('=== 分析失败不静默 ===')
{
const { ctx, page } = await open()
await waitAnalysis(page)
let down = true
await page.route(/\/api\/workflows\/(validate|variables)$/, (route) => (down
? route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ detail: '后端忙不过来' }) })
: route.continue()))
await S(page, () => window.__studio.getState().analyzeNow())
await page.waitForTimeout(300)
const chip = page.locator('button:has-text("分析失败 · 重试")')
check('分析失败时工具栏写「分析失败 · 重试」,不再说可运行', await chip.count() === 1
&& await page.getByText('可运行').count() === 0)
await blur(page)
await page.keyboard.press('Alt+KeyP')
await page.waitForTimeout(200)
check('问题面板说清分析失败、给重试', (await page.locator('#dock-problems').innerText()).includes('分析失败'))
const dockText = await page.locator('#dock-problems').innerText()
const staleRows = await count(page, '#dock-problems [data-problem]')
check('分析失败时照样列出上一次的问题,并说明可能过时', staleRows > 0 && dockText.includes('上一次校验'), `${staleRows} 行`)
check('校验接口报错(后端没断)不承诺会自动重试', dockText.includes('点「重试」') && !dockText.includes('自动重新校验'))
const kept = await S(page, () => window.__studio.getState().issues)
await S(page, () => window.__studio.setState({ issues: [] }))
await page.waitForTimeout(100)
check('没有旧清单可列时不说「下面列的」', (await page.locator('#dock-problems').innerText()).includes('问题清单暂时拿不到')
&& !(await page.locator('#dock-problems').innerText()).includes('下面'))
await page.evaluate((v) => window.__studio.setState({ issues: v }), kept)
down = false
await chip.click()
await page.waitForFunction(() => window.__studio.getState().analysis === 'ok', null, { timeout: 5000 }).catch(() => {})
check('点重试恢复', await chip.count() === 0 && await S(page, () => window.__studio.getState().analysis) === 'ok')
await ctx.close()
}
// ================================================================ 5
console.log('=== 版本历史:预览、恢复成一次可撤销的改动 ===')
{
const { ctx, page, state } = await open()
await blur(page)
await page.keyboard.press('Alt+KeyH')
await page.waitForTimeout(500)
const rows = await count(page, 'ol[aria-label="版本"] > li')
check('列出全部版本', rows === 3, `${rows} 版`)
check('标出已发布的那一版', (await page.locator('ol[aria-label="版本"] > li').nth(1).innerText()).includes('已发布'))
await page.locator('ol[aria-label="版本"] > li').nth(2).locator('button').first().click()
await page.waitForTimeout(500)
check('选一版给出缩略图预览', await count(page, 'svg[aria-label^="缩略图"]') === 1)
check('预览列出恢复后会拿掉的节点', (await page.locator('[role="dialog"][aria-label="版本历史"]').innerText()).includes('拿掉'))
const before = await st(page)
await page.locator('button:has-text("恢复 v1 到画布")').click()
await page.waitForTimeout(300)
const after = await st(page)
const note = await S(page, () => window.__studio.getState().pendingNote)
check('恢复:画布换成 v1', after.nodes.length === V1.nodes.length, `${after.nodes.length} 个节点`)
check('恢复不调后端 restore(受管不会被悄悄保留)', !writes.some((w) => w.includes('/restore')))
check('保存时写「回滚到 v1」', note === '回滚到 v1', note)
await blur(page)
await page.keyboard.press(`${MOD}+z`)
await page.waitForTimeout(200)
check('恢复是一次可撤销的改动', (await st(page)).nodes.length === before.nodes.length)
await page.keyboard.press(`${MOD}+Shift+z`)
await page.keyboard.press(`${MOD}+s`)
await page.waitForTimeout(400)
check('保存带上版本说明', state.patches[0]?.note === '回滚到 v1', JSON.stringify(state.patches[0] ?? {}).slice(0, 60))
await ctx.close()
}
// ================================================================ 6
console.log('=== 助手改图:一步撤销、回执、只回话、失败退回、停止、再修一次、记忆 ===')
{
const { ctx, page, state, errors } = await open()
const mem = await S(page, () => window.__studio.getState().copilotMemory)
check('打开图就取回这条会话之前的轮次', mem.turns === 2 && mem.past.length === 2, JSON.stringify({ turns: mem.turns }))
// 1) 应用:改一个节点、加一个节点;final 里旧节点坐标不动
const pos0 = await S(page, () => Object.fromEntries(window.__studio.getState().nodes.map((n) => [n.id, n.position])))
const finalGraph = {
nodes: [
...GRAPH.nodes.map((n) => (n.id === 'answer'
? { ...n, data: { ...n.data, config: { ...n.data.config, prompt: '{{ input.goal }} 简短回答' } } }
: n)),
node('polish', 'llm', 1180, 40, '润色', { prompt: '{{ nodes.answer.text }}' }),
],
edges: [...GRAPH.edges, edge('answer', 'polish')],
}
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'plan', summary: '加一步润色' },
{ op: 'update_node', id: 'answer', config: finalGraph.nodes[3].data.config },
{ op: 'add_node', node: { id: 'polish', type: 'llm', label: '润色', config: { prompt: '{{ nodes.answer.text }}' } } },
{ op: 'add_edge', edge: { source: 'answer', target: 'polish' } },
{ op: 'done', explanation: '加了润色' },
{ op: 'check', status: 'passed', repaired: 0 },
{ op: 'final', graph: finalGraph, explanation: '加了润色', layout: { mode: 'keep', placed: ['polish'] },
issues: [{ level: 'warning', node_id: null, code: 'unknown_node_type', type: 'magic',
message: '模型写了一个不存在的节点类型「magic」,这一步已跳过' }] },
]
const past0 = (await st(page)).past
await S(page, () => window.__studio.getState().runCopilot('给快速回答后面加一步润色', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const t1 = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
check('收到 final:这一轮是「已应用」', t1.outcome === 'applied', t1.outcome)
check('diff 记下新增和改动', t1.diff?.added.includes('polish') && t1.diff?.changed.includes('answer'), JSON.stringify(t1.diff))
check('final.issues 进了这一轮(少了一步)', t1.issues?.some((i) => i.code === 'unknown_node_type'))
const pos1 = await S(page, () => Object.fromEntries(window.__studio.getState().nodes.map((n) => [n.id, n.position])))
check('旧节点留在原位,不整图重排', Object.keys(pos0).every((id) => pos1[id].x === pos0[id].x && pos1[id].y === pos0[id].y))
check('整轮只占一步撤销', (await st(page)).past === past0 + 1)
check('回执:已应用 N 处改动 · 撤销', await page.getByText(/已应用 \d+ 处改动/).count() === 1)
check('新节点高亮', (await S(page, () => window.__studio.getState().copilotNew)).includes('polish'))
await page.waitForTimeout(450)
const landed = await S(page, () => window.__studio.getState().nodes.find((n) => n.id === 'polish')?.position)
check('新节点从临时位置滑到排版位置,落定在后端给的坐标', landed?.x === 1180 && landed?.y === 40, JSON.stringify(landed))
await blur(page)
await page.keyboard.press(`${MOD}+z`)
await page.waitForTimeout(200)
const undone = await S(page, () => {
const s = window.__studio.getState()
return { polish: s.nodes.some((n) => n.id === 'polish'), prompt: s.nodes.find((n) => n.id === 'answer').data.config.prompt }
})
check('一次 ⌘Z 撤掉整轮改图', !undone.polish && undone.prompt.includes('vars.knowledge'), JSON.stringify(undone))
await page.keyboard.press(`${MOD}+Shift+z`)
await page.waitForTimeout(150)
check('⇧⌘Z 再应用回来', await S(page, () => window.__studio.getState().nodes.some((n) => n.id === 'polish')))
// 1b) 前端认不出的类型:后端 final.issues 没说到的,这一轮也要记下「少了一步」
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'add_node', node: { id: 'm1', type: 'wizard', label: '魔法', config: {} } },
{ op: 'update_node', id: 'answer', label: '快速回答' },
{ op: 'final', graph: finalGraph, explanation: '', layout: { mode: 'keep', placed: [] }, issues: [] },
]
await S(page, () => window.__studio.getState().runCopilot('加一步魔法', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const tw = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
check('认不出的节点类型不上画布,但记成「少了一步」', tw.issues?.some((i) => i.code === 'unknown_node_type' && i.type === 'wizard')
&& !(await st(page)).nodes.includes('m1'), JSON.stringify(tw.issues))
// 2) 只回了一句话
state.stream = [{ op: 'model', model: 'fake-model' }, { op: 'reply', text: '分支按 **input.goal** 分' }]
const b2 = await st(page)
await S(page, () => window.__studio.getState().runCopilot('分支是按什么分的?', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const t2 = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
check('reply:这一轮记下回答', t2.reply?.includes('input.goal') && t2.outcome === 'answered', t2.outcome)
check('reply:画布没动、不占撤销栈', (await st(page)).nodes.length === b2.nodes.length && (await st(page)).past === b2.past)
// 3) 从头生成,搭到一半失败:画布退回原样,半成品能 ⇧⌘Z 找回
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'add_node', node: { id: 'x1', type: 'llm', label: '半截', config: {} } },
{ op: 'error', message: '助手这一轮没跑完:模型超时' },
]
const b3 = await st(page)
await S(page, () => window.__studio.getState().runCopilot('重新设计', false))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
const t3 = await S(page, () => window.__studio.getState().copilotTurns.at(-1))
const a3 = await st(page)
check('失败:画布退回这一轮之前(从头生成也不丢原图)', a3.nodes.join() === b3.nodes.join(), `${a3.nodes.length}/${b3.nodes.length}`)
check('失败:这一轮标成已退回', t3.outcome === 'reverted' && t3.phase === 'error', `${t3.outcome}/${t3.phase}`)
check('失败:半成品进了重做栈', a3.future === 1)
check('失败:新节点高亮清掉', (await S(page, () => window.__studio.getState().copilotNew.length)) === 0)
// 4) 生成中锁画布与工具栏,停止
state.stream = 'hang'
await S(page, () => window.__studio.getState().runCopilot('慢慢想', true))
await page.waitForTimeout(300)
const bar = page.locator('div:has(> [role="status"]:has-text("助手正在改这张工作流"))')
check('生成中画布上方有进度条和停止', await bar.locator('button:has-text("停止")').count() === 1)
const spoken = await page.locator('[role="status"]:has-text("助手正在改这张工作流")').innerText()
check('播报区只圈阶段文字,不带 100ms 一跳的计时', !/\d\d:\d\d/.test(spoken)
&& /\d\d:\d\d/.test(await bar.innerText()), spoken)
const n4 = (await st(page)).nodes.length
await S(page, () => window.__studio.getState().addNode('llm'))
check('生成中编辑被锁住', (await st(page)).nodes.length === n4)
check('生成中工具栏锁住(保存、撤销、运行)', await page.locator('fieldset[disabled]').count() >= 1)
await bar.locator('button:has-text("停止")').click()
await page.waitForTimeout(200)
const t4 = await S(page, () => ({ active: window.__studio.getState().copilot.active, turn: window.__studio.getState().copilotTurns.at(-1) }))
check('停止:解锁,这一轮收尾', !t4.active && t4.turn.phase === 'error' && t4.turn.error === '已停止', JSON.stringify(t4.turn?.error))
// 5) 让助手再修一次:把剩下的问题拼成指令,在现有的图上改
state.stream = [
{ op: 'model', model: 'fake-model' },
{ op: 'check', status: 'failed', issues: ['「查询销量」:「调用工具」节点还没选工具'] },
{ op: 'final', graph: GRAPH, explanation: '', layout: { mode: 'keep', placed: [] }, issues: [] },
]
await S(page, () => window.__studio.getState().runCopilot('检查一下', true))
await page.waitForFunction(() => window.__studio.getState().copilotTurns.at(-1)?.phase !== 'running')
state.stream = [{ op: 'model', model: 'fake-model' }, { op: 'reply', text: '好' }]
const turnId = await S(page, () => window.__studio.getState().copilotTurns.at(-1).id)
await S(page, (id) => window.__studio.getState().repairWithCopilot(id), turnId).catch(() => null)
await page.evaluate((id) => window.__studio.getState().repairWithCopilot(id), turnId)
await page.waitForTimeout(400)
const lastBody = state.bodies.at(-1)
check('再修一次:指令里带着剩下的问题', lastBody?.instruction?.includes('还没选工具') && !!lastBody?.base_graph,
(lastBody?.instruction ?? '').slice(0, 40))
// 6) 开始新对话:换会话,记忆清零
await S(page, () => window.__studio.getState().newCopilotConversation())
await page.waitForTimeout(200)
const m2 = await S(page, () => ({ id: window.__studio.getState().copilotConversationId, turns: window.__studio.getState().copilotMemory.turns }))
check('开始新对话:换一条会话,模型上下文清零', m2.id === 'conv-new' && m2.turns === 0, JSON.stringify(m2))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await ctx.close()
}
// ================================================================ 7
console.log('=== 入口:?run=&focus=、选择器、发布弹窗 ===')
{
const { ctx, page } = await open({ path: `/studio/st-main?run=${FAKE_RUN}&focus=answer` })
await page.waitForFunction(() => window.__studio.getState().run != null, null, { timeout: 5000 }).catch(() => {})
await page.waitForTimeout(400)
const s = await S(page, () => ({ run: window.__studio.getState().run?.id, focus: window.__studio.getState().focusRequest?.id }))
check('?run= 接上那次运行', s.run === FAKE_RUN, s.run)
check('?focus= 请画布取景到那个节点', s.focus === 'answer', s.focus)
await page.locator('button[title^="切换、新建"]').click()
await page.waitForTimeout(300)
await page.keyboard.type('__studio_check')
await page.waitForTimeout(200)
const rows = await count(page, '[role="listbox"][aria-label="工作流"] > li')
check('选择器能搜索', rows === 3, `${rows} 条`)
check('当前工作流有底色和勾', await count(page, 'li[aria-selected="true"] .bg-accent-solid') === 1)
check('模板行常驻「用它新建」', await count(page, 'li:has-text("__studio_check_tpl__") button:has-text("用它新建")') === 1)
check('标签本地化(extracted → 从运行提取)', await count(page, 'li:has-text("__studio_check_tpl__") :text("从运行提取")') === 1)
const mainRow = page.locator('li[role="option"]', { has: page.locator('span.truncate', { hasText: /^__studio_check__$/ }) })
const chips = await mainRow.locator('.chip').allInnerTexts()
check('发布后又改过的草稿:选择器同时写「草稿」和「已发布 v2」', chips.includes('草稿') && chips.some((t) => t.includes('已发布 v2')), chips.join(' | '))
await page.keyboard.press('Escape')
await ctx.close()
// 发布只换状态和发布人,图没动:撤销栈、镜头都留着
const pub = await open()
let published = false
await pub.page.route(/\/api\/workflows\/st-main\/publish$/, (route) => {
published = true
return route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ ok: true, level: 'published', version: 3, issues: [] }) })
})
await pub.page.route(/\/api\/workflows\/st-main$/, (route) => (route.request().method() === 'GET' && published
? route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ ...FAKES['st-main'], status: 'published', published_version: 3, published_by: '检查脚本' }) })
: route.fallback()))
await S(pub.page, () => { const s = window.__studio.getState(); s.updateNode('lookup', { label: '背景检索2' }); s.undo() })
const pb = await S(pub.page, () => { const s = window.__studio.getState(); return { future: s.future.length, fit: s.fitRequest, dirty: s.dirty } })
await pub.page.locator('button:has-text("发布")').first().click()
await pub.page.waitForTimeout(300)
await pub.page.locator('[role="dialog"] button.btn-primary').click()
await pub.page.waitForFunction(() => window.__studio.getState().workflow?.status === 'published', null, { timeout: 5000 }).catch(() => {})
await pub.page.waitForTimeout(200)
const pa = await S(pub.page, () => { const s = window.__studio.getState(); return { future: s.future.length, fit: s.fitRequest, status: s.workflow?.status, pv: s.workflow?.published_version } })
check('发布之后状态跟着变(已发布 v3)', pa.status === 'published' && pa.pv === 3, JSON.stringify(pa))
check('发布不清撤销栈、不重新取景', !pb.dirty && pb.future === 1 && pa.future === 1 && pa.fit === pb.fit, JSON.stringify({ pb, pa }))
await pub.ctx.close()
// 快捷键提示跟着平台走:Windows 上不该写 ⌘ ⌫
const win = await open({ platform: 'Windows' })
await S(win.page, () => window.__studio.getState().select('answer'))
await win.page.waitForTimeout(300)
const delTitle = await win.page.getByRole('button', { name: '删除节点', exact: true }).getAttribute('title')
const undoTitle = await win.page.getByRole('button', { name: '撤销', exact: true }).first().getAttribute('title')
check('非 Mac 上快捷键提示写 Backspace / Ctrl+Z,不写 ⌫ ⌘', /Backspace/.test(delTitle) && /Ctrl\+Z/.test(undoTitle)
&& !/[⌘⌫]/.test(delTitle + undoTitle), `${delTitle} · ${undoTitle}`)
await win.ctx.close()
const gov = await open({ path: '/studio/st-gov' })
await gov.page.locator('button:has-text("发布")').first().click()
await gov.page.waitForTimeout(300)
const radio = await gov.page.locator('[role="radio"][aria-checked="true"]').innerText()
check('受管工作流的发布默认选「受管」', radio.includes('受管'), radio.slice(0, 20))
check('选项用统一术语(已发布 — 可发起正式运行)', await gov.page.getByText('已发布 — 可发起正式运行').count() === 1)
check('写明以谁的名义发布', await gov.page.getByText('检查脚本').count() >= 1)
await gov.page.locator('[role="radio"]:has-text("已发布")').click()
check('从受管降级要说出来', await gov.page.getByText(/会降级/).count() === 1)
await gov.ctx.close()
}
// ================================================================ 8
console.log('=== 删掉眼前这张 ===')
{
const removeCurrent = async (page) => {
await page.locator('button[title^="切换、新建"]').click()
await page.waitForTimeout(300)
await page.getByRole('button', { name: '删除「__studio_check__」', exact: true }).click()
await page.waitForTimeout(200)
await page.getByRole('button', { name: '删除工作流' }).click()
}
const { ctx, page, state } = await open()
await removeCurrent(page)
await page.waitForFunction(() => window.__studio.getState().workflow?.id !== 'st-main', null, { timeout: 5000 }).catch(() => {})
await page.waitForTimeout(300)
const s = await S(page, () => { const x = window.__studio.getState(); return { id: x.workflow?.id, nodes: x.nodes.length, dirty: x.dirty, past: x.past.length } })
const at = new URL(page.url()).pathname
check('删掉正开着的那张:换到剩下的一张,画布和地址都不再是它', state.deleted.has('st-main') && !!s.id && s.id !== 'st-main'
&& at === `/studio/${s.id}` && s.nodes > 0, `${s.id} · ${at}`)
check('换过去的那张是干净的(没有未保存、撤销栈)', !s.dirty && s.past === 0)
await ctx.close()
const solo = await open({ only: ['st-main'] })
await removeCurrent(solo.page)
await solo.page.waitForFunction(() => window.__studio.getState().workflow == null, null, { timeout: 5000 }).catch(() => {})
await solo.page.waitForTimeout(300)
const e = await S(solo.page, () => ({ wf: window.__studio.getState().workflow?.id ?? null, nodes: window.__studio.getState().nodes.length }))
check('一张不剩:画布卸空,回到「还没有工作流」', e.wf === null && e.nodes === 0
&& await solo.page.getByText('还没有工作流').count() === 1 && new URL(solo.page.url()).pathname === '/studio', JSON.stringify(e))
check('卸空时不误报「不在了」', await solo.page.getByText('那张工作流不在了').count() === 0)
await solo.ctx.close()
}
check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`)
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)