// 问数据页的检查:会话、轮次、运行全部在浏览器这一侧伪造,页面走的是真的 // chat store / ChatPage / AssistantStream,只有后端换成了脚本。 // // 为什么不真跑:沙箱没有模型密钥,而且要撞上的恰恰是真跑很难稳定复现的那几种 // 时刻——两个会话同时在跑、答案超过 2000 字被事件截断、跑到一半服务重启、 // 历史取得慢或者取失败。这里每一种都由脚本精确地摆出来。 // // 守的事: // 1. 忙不忙按会话算:A 在跑时 B 的按钮是「发送」,停止只停 A 自己,删除保护 // 对准正在跑的那个; // 2. run.finished 的 output 被截断时,显示和落库都用 GET 运行拿到的完整版; // llm.token 不进 events; // 3. 服务重启:stream.end status=interrupted 且没有待审批,这一轮收成 // 「服务重启,这一轮中断了」,给接着跑,不再转圈; // 4. 加载中 / 加载失败 / 真的是空的 三种状态分开,加载失败时不能在空态上提问; // 5. 已取消的、人驳回这类原样续上还会失败的轮次没有「接着跑」,续跑被拒时说人话; // 6. 1440×900 下长会话的输入框和发送键完整可见,整页不多滚; // 7. 首屏例句不拿系统表造句;可信度信息刷新后还在,且写进单独的 meta,不夹在 review 里; // 8. 从库里恢复的半路轮次:核对撞上 500 有出路、补交付不走实时计时、查到的终态落库; // 9. 长答案没取全要记进 meta,刷新后补全;2000 字按码点数,不按 UTF-16; // 10. 发起运行失败:报错不糊成一整行粗体,不许诺不存在的自动重试;重跑挂起的轮次 // 顺手取消旧运行;断在建图阶段按最后一次落库算有没有动静,发起运行时 meta 跟上新运行; // 11. 左栏:没打开过的会话也标得出状态;筛选框在有筛选词时不消失;删掉的会话进回收站, // 能预览、恢复、彻底删除(要确认);回收站里只能看,不发起任何运行,正在跑的不给 // 彻底删除(清空也跳过它); // 12. 限定数据源:点一下只查这个库,请求里带上 datasource_ids,库不在了说人话、补救是放开范围; // 库列表没取回来时,从上一问接过来的范围照样看得见; // 13. 画布助手的「在现有图上改 | 从头生成」分段控件;助手出错时同一句报错只说一遍。 // // 所有写请求都被拦在浏览器里,不碰任何库。 import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' import { mkdirSync } from 'node:fs' const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' const SHOTS = process.env.SHOTS ?? '/tmp/agentlab-check-chat' mkdirSync(SHOTS, { recursive: true }) // 只跑其中几段:ONLY=busy,restart THEMES=dark node scripts/check-chat.mjs const ONLY = (process.env.ONLY ?? '').split(',').filter(Boolean) const THEMES = (process.env.THEMES ?? 'light,dark').split(',').filter(Boolean) const section = (name, title) => { if (ONLY.length && !ONLY.includes(name)) return false console.log(`\n=== ${title} ===`) return true } let failed = 0 const check = (name, cond, detail = '') => { console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) if (!cond) failed++ } // ---------------------------------------------------------------- 伪造的数据 const iso = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString() const YESTERDAY_NOON = Math.round((Date.now() - new Date(new Date().setHours(12, 0, 0, 0) - 86_400_000).getTime()) / 60_000) const conv = (id, title, turns, minsAgo, extra = {}) => ({ id, title, kind: 'chat', workflow_id: null, archived: false, created_at: iso(minsAgo + 5), last_active_at: iso(minsAgo), turn_count: turns, last_question: title, last_status: turns ? 'done' : null, last_run_id: null, ...extra, }) const LONG = (n) => Array.from({ length: n }, (_, i) => `${i + 1}. 这一段是很长的结论正文,用来把会话撑到超过一屏,检查输入框会不会被挤出视口。`).join('\n') const FULL = '这是完整答案的开头。' + '正文'.repeat(1920) + '【完整结尾】' // 3,850 字左右 // 后端按码点截在 2000:带 BMP 之外的字符时,JS 的 length 会比 2000 多 const ASTRAL_2000 = '旧'.repeat(1995) + '📊'.repeat(5) const turn = (id, question, extra = {}) => ({ id, seq: 0, question, answer: '', explanation: '', graph: null, run_id: null, status: 'done', error: '', review: null, created_at: iso(30), ...extra, }) const GRAPH = { nodes: [ { id: 'in', type: 'input', position: { x: 0, y: 0 }, data: { label: '问题', config: { fields: [{ name: 'question' }] } } }, { id: 'ag', type: 'agent', position: { x: 0, y: 100 }, data: { label: '查数', config: { max_steps: 12 } } }, { id: 'out', type: 'output', position: { x: 0, y: 200 }, data: { label: '成果', config: {} } }, ], edges: [{ source: 'in', target: 'ag' }, { source: 'ag', target: 'out' }], } const CONVS = { busyA: conv('c0busya', '会话 A:跑得很久的那个', 0, 1), idleB: conv('c0idleb', '会话 B:已经答完的那个', 1, 3), longC: conv('c0longc', '会话 C:长会话,检查布局', 6, 60), slowD: conv('c0slowd', '会话 D:历史取得慢', 2, 90), failE: conv('c0faile', '会话 E:历史取失败', 3, 120), // 「昨天」按日历算:写死 30 小时前的话,凌晨 6 点前跑会落进前天,分组检查就挂了 histF: conv('c0histf', '会话 F:取消过、失败过、不可用的历史', 4, YESTERDAY_NOON), emptyG: conv('c0emptg', '新对话', 0, 60 * 24 * 3), truncH: conv('c0trunc', '会话 H:长答案', 0, 60 * 24 * 10), // 下面四个在「列表」那段里没打开过:状态只能从列表接口的 last_status 来 restI: conv('c0resti', '会话 I:跑到一半服务重启', 0, 60 * 24 * 12, { last_status: 'suspended' }), buildJ: conv('c0buildj', '会话 J:建流程就失败', 0, 60 * 24 * 13, { last_status: 'error' }), waitK: conv('c0waitk', '会话 K:停在审批上', 0, 60 * 24 * 14, { last_status: 'waiting', last_run_id: 'run-wait' }), writeL: conv('c0writel', '会话 L:模型正在出字', 0, 60 * 24 * 15, { last_status: 'running' }), metaM: conv('c0metam', '会话 M:刷新之后的可信度', 1, 60 * 24 * 16), killN: conv('c0killn', '会话 N:服务被强杀', 0, 60 * 24 * 17), suspO: conv('c0suspo', '会话 O:三天前被重启打断的', 1, 60 * 24 * 3, { last_status: 'suspended' }), unkP: conv('c0unkp', '会话 P:核对时后端出错', 1, 60 * 24 * 18), lateQ: conv('c0lateq', '会话 Q:跑完了、没来得及交付', 1, 60 * 24 * 3), termR: conv('c0termr', '会话 R:停在半路的几轮', 5, 60 * 24 * 19), clipS: conv('c0clips', '会话 S:长答案没取全', 0, 60 * 24 * 20), clipT: conv('c0clipt', '会话 T:刷新之后补全长答案', 3, 60 * 24 * 21), launchU: conv('c0launu', '会话 U:发起运行失败', 0, 60 * 24 * 22), scopeV: conv('c0scopv', '会话 V:限定数据源', 1, 60 * 24 * 23), scopeW: conv('c0scopw', '会话 W:上一问限定过数据源', 1, 60 * 24 * 24), buildX: conv('c0buildx', '会话 X:两天前的问题,别处刚又跑了一遍', 2, 60 * 24 * 25), } /** 回收站里的:列表接口只有带 include_archived 才给 */ const TRASHED = { trash1: conv('c0trash1', '删掉的对话甲', 3, 60 * 24 * 2, { archived: true }), trash2: conv('c0trash2', '删掉的对话乙', 1, 60 * 24 * 40, { archived: true }), } /** 只在「回收站里只能看」那段出现,免得别的段数回收站里有几个时数错 */ const TRASH_MORE = { // 各种能补救的轮次都有一个:没跑的、停在审批上的、跑挂了的、结论不可用的 trash3: conv('c0trash3', '删掉的对话丙:各种没跑完的', 4, 60 * 24 * 4, { archived: true, last_status: 'done' }), // 删掉之后又跑起来了(比如在「记录」里通过了审批):它底下有一个真在跑的运行 trash4: conv('c0trash4', '删掉的对话丁:还在跑', 1, 60 * 24 * 5, { archived: true, last_status: 'running', last_run_id: 'run-trash-live' }), } const DETAIL = { c0busya: [], c0idleb: [turn('tb1', 'B 的问题', { answer: 'B 的答案', graph: GRAPH, run_id: 'run-b1' })], c0longc: Array.from({ length: 6 }, (_, i) => turn(`tc${i}`, `第 ${i + 1} 个问题`, { answer: LONG(14), graph: GRAPH, run_id: `run-c${i}` })), c0slowd: [turn('td1', 'D 的第一问', { answer: 'D 的答案一' }), turn('td2', 'D 的第二问', { answer: 'D 的答案二' })], c0faile: [turn('te1', 'E 的第一问', { answer: 'E 的答案' })], c0histf: [ // 用户点过停止的老数据:status=error + 「已取消」,运行在后端是 cancelled turn('tf1', '被我停下的那一轮', { status: 'error', error: '已取消', graph: GRAPH, run_id: 'run-cancelled' }), // 运行失败了:可以接着跑,但这次后端会拒绝(比如别处已经把它续跑完了) turn('tf2', '跑挂了的那一轮', { status: 'error', error: 'KeyError: 查询超时,数据库没有在 30 秒内返回', graph: GRAPH, run_id: 'run-failed' }), // 人工驳回:原样接着跑只会再被驳回一次(lib/explain 的 continuable=false),不给「接着跑」 turn('tf5', '被人驳回的那一轮', { status: 'error', error: '人工驳回:数字对不上', graph: GRAPH, run_id: 'run-rejected' }), // 没有运行、没有图,却有答案:reply,没查库。老数据没有 meta,靠推断 turn('tf3', '上一轮的数字是多少', { answer: '上一轮查到的是 42。' }), // 复核判为不可用,原因是步数用满 turn('tf4', '步数用满的那一轮', { answer: '只查了一半的结论', graph: GRAPH, run_id: 'run-broken', review: { verdict: 'annotated', note: 'agent 用满了 12 步还没给出结论。', answer: null, retry: true, severity: 'broken', signals: [{ kind: 'step_limit', detail: 'agent 用满了 12 步', severity: 'broken' }], }, meta: { v: 1, runId: 'run-broken', runClass: 'exploratory', runStatus: 'succeeded', queries: 3, ms: 41000 }, }), ], c0emptg: [], c0trunc: [], c0resti: [], c0buildj: [], c0waitk: [], c0writel: [], c0killn: [], // 三天前服务重启打断、跑了 42 秒的那一轮 c0suspo: [turn('to1', '三天前没跑完的问题', { status: 'error', error: '服务重启,这一轮中断了', graph: GRAPH, run_id: 'run-susp', created_at: iso(60 * 24 * 3), meta: { v: 1, runId: 'run-susp', runStatus: 'interrupted', outcome: 'suspended', ms: 42000 }, })], // 出具档位、运行类别、耗时、查库次数只存在 meta 里:刷新后得从这里读回来, // 而不是再去问一遍运行 c0metam: [turn('tm1', '上个月各产线的出勤率', { answer: '一线 96.2%,二线 91.0%。', graph: GRAPH, run_id: 'run-meta', meta: { v: 1, runId: 'run-meta', runClass: 'exploratory', runStatus: 'succeeded', ms: 12300, queries: 2, issuance: { tier: 'degraded', gaps: ['叙述模板渲染为空'], matched_numbers: 2, metrics_checked: 3, unmatched_numbers: [] }, }, })], // 上次停在半路(轮次还是 running),核对时后端回 500 c0unkp: [turn('tp1', '核对时撞上后端出错的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-unk', created_at: iso(60 * 24 * 2), })], // 运行三天前就跑完了,交付没赶上:补交付期间不能按实时运行从三天前起算计时 c0lateq: [turn('tq1', '三天前跑完、没交付的问题', { status: 'running', graph: GRAPH, run_id: 'run-late', created_at: iso(60 * 24 * 3), })], // 轮次都还是 running,运行早已各有结局:查到之后要落库,不能每次进来都重新核对 c0termr: [ turn('tr1', '在别处被取消的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-t-cancel' }), turn('tr2', '在别处失败的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-t-failed' }), turn('tr3', '服务重启挂起的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-t-susp' }), turn('tr4', '运行记录被删掉的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-404-t' }), turn('tr5', '还停在审批上的那一轮', { status: 'running', graph: GRAPH, run_id: 'run-t-wait' }), ], c0clips: [], c0clipt: [ // 上次交付时 GET 运行失败,只拿到事件里截断的那份:meta 记着 partial,刷新后要补全。 // 多个键拼起来比 2000 长,光看长度是认不出来的 turn('tt1', '多个成果键的长答案', { answer: `${FULL.slice(0, 2000)}\n附注:另一个成果键`, graph: GRAPH, run_id: 'run-multi', meta: { v: 1, runId: 'run-multi', runStatus: 'succeeded', clipped: 'partial' }, }), // 恰好 2000 个码点(UTF-16 是 2005)、运行也没了:补不回来,但要标出来 turn('tt2', '旧版本被截断、运行没了的长答案', { answer: ASTRAL_2000, graph: GRAPH }), turn('tt3', '旧版本被截断、运行记录删掉了的长答案', { answer: ASTRAL_2000, graph: GRAPH, run_id: 'run-404-clip' }), ], c0launu: [], c0scopv: [turn('tv1', '上一问', { answer: '上一问的答案', graph: GRAPH, run_id: 'run-b1' })], c0scopw: [turn('tw0', '上一问只查了一个库', { answer: '上一问的答案 W', graph: GRAPH, run_id: 'run-b1', meta: { v: 1, runId: 'run-b1', runStatus: 'succeeded', scope: [{ id: 'ds-plant', name: 'factory' }] }, })], // 两天前问的、刚被重试过:run_id 列还指着上一次的运行(清不掉),meta.runId 是 null。 // 有没有动静按 meta.at 算——取的那一刻才定,脚本跑得再久也是「2 分钟前」「20 分钟前」 c0buildx: () => [ turn('tx7', '两分钟前在别的页面里重试的问题', { status: 'running', graph: GRAPH, run_id: 'run-failed', created_at: iso(60 * 24 * 2), meta: { v: 1, runId: null, at: Date.now() - 2 * 60_000 }, }), turn('tx8', '重试之后二十分钟没动静的问题', { status: 'running', graph: GRAPH, run_id: 'run-failed', created_at: iso(60 * 24 * 2), meta: { v: 1, runId: null, at: Date.now() - 20 * 60_000 }, }), ], c0trash1: [turn('tx1', '回收站里的问题', { answer: '回收站里的答案', graph: GRAPH, run_id: 'run-b1' })], c0trash2: [], c0trash3: [ turn('ty0', '搭好了没跑的问题', { graph: GRAPH }), turn('ty1', '停在审批上的问题', { status: 'running', graph: GRAPH, run_id: 'run-trash-wait' }), turn('ty2', '跑挂了的问题', { status: 'error', error: '查询超时', graph: GRAPH, run_id: 'run-failed', meta: { v: 1, runId: 'run-failed', runStatus: 'failed' }, }), turn('ty3', '步数用满、结论不可用的问题', { answer: '只查了一半的结论', graph: GRAPH, run_id: 'run-broken', review: { verdict: 'annotated', note: 'agent 用满了 12 步还没给出结论。', answer: null, retry: true, severity: 'broken', signals: [{ kind: 'step_limit', detail: 'agent 用满了 12 步', severity: 'broken' }], }, meta: { v: 1, runId: 'run-broken', runStatus: 'succeeded' }, }), ], c0trash4: [turn('tz1', '删掉之后还在跑的问题', { status: 'running', graph: GRAPH, run_id: 'run-trash-live', created_at: iso(10) })], } const RUNS = { 'run-cancelled': { status: 'cancelled', output: {}, error: null }, 'run-failed': { status: 'failed', output: {}, error: '查询超时' }, 'run-rejected': { status: 'failed', output: {}, error: '人工驳回:数字对不上' }, 'run-broken': { status: 'succeeded', output: { answer: '只查了一半的结论' }, error: null }, 'run-late': { status: 'succeeded', output: { answer: '补交付的答案:三条记录。' }, error: null, started_at: iso(60 * 24 * 3 - 1), finished_at: iso(60 * 24 * 3 - 1.5) }, 'run-t-cancel': { status: 'cancelled', output: {}, error: null }, 'run-t-failed': { status: 'failed', output: {}, error: '鉴权没通过(401):对方拒绝了这把密钥' }, 'run-multi': { status: 'succeeded', output: { answer: FULL, note: '附注:另一个成果键' }, error: null }, 'run-trash-live': { status: 'running', output: {}, error: null }, } const SOURCES = [ { id: 'ds-mig', name: 'shop', kind: 'mysql', description: '' }, { id: 'ds-plant', name: 'factory', kind: 'oracle', description: '示例工厂库:采购、物料、生产、设备' }, ] const TABLES = { 'ds-mig': ['__prisma_migrations', 'schema_migrations', 'admin_audit_log', 'app_log', 'test_sales', 'user', 'orders'], 'ds-plant': ['ANALYTICS.test_sales', 'ANALYTICS.job_etl_audit', 'ANALYTICS.v_demo_table'], } // ---------------------------------------------------------------- 伪造的后端 const log = { patches: [], cancels: [], continues: [], runStarts: [], archived: [], posts: [], approvalGets: [], runGets: [], deleted: [], gens: [], } const ctl = { slowMs: 0, failE: true, wsClosed: {}, approvals: [], thinkGo: false, writingGo: false, writingAt: 0, /** 列表只给前 8 个:筛选框刚好出现,删一个就不够 8 个了 */ few: false, run500: true, reviewDelay: {}, launchFail: true, scope400: false, /** 回收站里多摆两个(TRASH_MORE) */ trashMore: false, /** 数据源列表取不回来 */ sourcesFail: false, } /** 会话的归档 / 删除状态。各段各自 resetDb,免得上一段删掉的会话影响下一段 */ const db = { archived: new Set(), purged: new Set() } const resetDb = () => { db.archived = new Set([...Object.values(TRASHED), ...Object.values(TRASH_MORE)].map((c) => c.id)) db.purged = new Set() } resetDb() const allConvs = () => [...Object.values(CONVS), ...Object.values(TRASHED), ...(ctl.trashMore ? Object.values(TRASH_MORE) : [])] .filter((c) => !db.purged.has(c.id)) .map((c) => ({ ...c, archived: db.archived.has(c.id) })) let runSeq = 0 async function fakeApi(route) { const req = route.request() const url = new URL(req.url()) const path = url.pathname.replace(/^\/api/, '') const method = req.method() const json = (body, status = 200) => route.fulfill({ status, json: body }) const body = () => { try { return req.postDataJSON() } catch { return {} } } if (path === '/conversations' && method === 'GET') { if (url.searchParams.get('kind') === 'canvas') return json([]) const rows = allConvs() const live = rows.filter((c) => !c.archived) const shown = ctl.few ? live.slice(0, 8) : live return json(url.searchParams.get('include_archived') === 'true' ? [...shown, ...rows.filter((c) => c.archived)] : shown) } if (path === '/conversations' && method === 'POST') return json({ ...conv('c0new', '新对话', 0, 0), turns: [] }, 201) let m = path.match(/^\/conversations\/([^/]+)$/) if (m && method === 'GET') { const id = m[1] if (id === 'c0slowd') await new Promise((r) => setTimeout(r, ctl.slowMs)) if (id === 'c0faile' && ctl.failE) return json({ detail: '数据库暂时不可用' }, 500) const known = allConvs().find((c) => c.id === id) if (!known && id !== 'c0new') return json({ detail: '这个对话不存在,可能已经被删了' }, 404) const turns = typeof DETAIL[id] === 'function' ? DETAIL[id]() : DETAIL[id] return json({ ...(known ?? conv('c0new', '新对话', 0, 0)), turns: turns ?? [] }) } if (m && method === 'PATCH') { const b = body() log.archived.push([m[1], b]) if (b.archived === true) db.archived.add(m[1]) if (b.archived === false) db.archived.delete(m[1]) return json({ ...allConvs().find((c) => c.id === m[1]), ...b }) } if (m && method === 'DELETE') { log.deleted.push(m[1]) db.purged.add(m[1]) return route.fulfill({ status: 204, body: '' }) } m = path.match(/^\/conversations\/([^/]+)\/turns$/) if (m && method === 'POST') { log.posts.push(m[1]) return json(turn(`srv-${m[1]}-${log.posts.length}`, body().question, { status: 'running' }), 201) } m = path.match(/^\/conversations\/([^/]+)\/turns\/([^/]+)$/) if (m && method === 'PATCH') { log.patches.push({ conv: m[1], turn: m[2], body: body() }); return json(turn(m[2], '', body())) } if (path === '/copilot/generate-stream' && method === 'POST') { const b = body() log.gens.push(b) if (ctl.scope400 && b.datasource_ids?.length) { return json({ detail: '限定的数据源都不在了:可能已经被删掉或停用。去掉限定再问,或者到「数据」页确认它还在、而且是启用的' }, 400) } if (String(b.instruction ?? '').includes('建流程就失败')) { // 后端的 error 操作是三段:人话、怎么办、原始异常 const ops = [ { op: 'heartbeat', phase: 'planning', elapsed_ms: 400 }, { op: 'error', message: '模型交回的工作流结构不对:第 2 个节点的类型「sql」不存在', hint: '再试一次,或者把需求说得更具体些', detail: "2 validation errors for GraphSpec\nnodes.1.type\n Input should be 'input', 'output', 'llm' [type=literal_error]" }, ] return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) } const ops = [ { op: 'heartbeat', phase: 'planning', elapsed_ms: 800 }, { op: 'plan', summary: '查一下再回答' }, ...GRAPH.nodes.map((n) => ({ op: 'add_node', node: n })), { op: 'done', explanation: '一张三步的图' }, { op: 'final', graph: GRAPH, issues: [], explanation: '一张三步的图' }, ] return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ops.map((o) => `data: ${JSON.stringify(o)}\n\n`).join('') }) } if (path === '/copilot/review' && method === 'POST') { const wait = ctl.reviewDelay[body().run_id] if (wait) await new Promise((r) => setTimeout(r, wait)) return json({ verdict: 'ok', note: '', answer: null, retry: false, severity: '', signals: [] }) } if (path === '/runs' && method === 'POST') { const b = body() const conversation = String(b.input?.question ?? '') // 发起请求根本没到后端(网断了、后端正在重启) if (conversation.includes('启动失败') && ctl.launchFail) return route.abort('connectionrefused') const id = conversation.includes('长答案') ? (conversation.includes('取不到运行') ? 'run-noget' : 'run-long') : conversation.includes('审批') ? 'run-wait' : conversation.includes('重启') ? 'run-restart' : conversation.includes('正在写') ? 'run-writing' : conversation.includes('强杀') ? 'run-killed' : conversation.includes('步数用满') ? `run-steps-${++runSeq}` : `run-busy-${++runSeq}` log.runStarts.push({ id, graph: b.graph }) return json({ id, status: 'queued', run_class: 'exploratory', output: {}, usage: {} }) } m = path.match(/^\/runs\/([^/]+)\/(cancel|continue)$/) if (m && method === 'POST') { if (m[2] === 'cancel') { log.cancels.push(m[1]); return json({ ok: true }) } log.continues.push(m[1]) if (m[1] === 'run-restart' || m[1] === 'run-susp') return json({ id: m[1], status: 'running' }) return json({ detail: '这次运行已经完成,不能再接着跑。要重来,请重新发起一次运行。' }, 409) } m = path.match(/^\/runs\/([^/]+)$/) if (m && method === 'GET') { const id = m[1] log.runGets.push(id) if (id === 'run-long') return json({ id, status: 'succeeded', run_class: 'exploratory', output: { answer: FULL }, usage: {} }) if (id === 'run-noget' || (id === 'run-unk' && ctl.run500)) return json({ detail: '后端出错了,详情看服务日志' }, 500) if (id === 'run-unk') return json({ id, status: 'cancelled', run_class: 'exploratory', output: {}, usage: {} }) if (id.startsWith('run-404')) return json({ detail: '运行不存在' }, 404) if (RUNS[id]) return json({ id, run_class: 'exploratory', usage: {}, ...RUNS[id] }) if (id.startsWith('run-c') || id === 'run-b1') return json({ id, status: 'succeeded', run_class: 'exploratory', output: {}, usage: {} }) return json({ id, status: 'interrupted', run_class: 'exploratory', output: {}, usage: {} }) } if (path.startsWith('/approvals')) { log.approvalGets.push(Date.now()) const runId = url.searchParams.get('run_id') return json(ctl.approvals.filter((a) => !runId || a.run_id === runId)) } m = path.match(/^\/runs\/([^/]+)\/events$/) if (m && method === 'GET') { return json([ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.started', 'ag', {}), ev(3, 'node.finished', 'ag', { duration_ms: 700 }), ev(4, 'run.finished', null, { output: {} })]) } if (path === '/datasources' && method === 'GET') { return ctl.sourcesFail ? json({ detail: '数据源列表暂时取不到' }, 500) : json(SOURCES) } m = path.match(/^\/datasources\/([^/]+)\/schema$/) if (m) return json({ tables: TABLES[m[1]] ?? [] }) if (method === 'GET') return route.continue() return route.abort() // 其余写操作一律不放 } /** 每条运行的事件剧本。done=true 时剧本放完就发 stream.end */ const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, data, ts: Date.now() / 1000 }) function script(runId, after = 0) { if (runId === 'run-noget') { // 事件里的成果被切过,交付时 GET 运行又失败了:只能先交这一份,并且要记下来 return { events: [ ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.finished', 'ag', { duration_ms: 900 }), ev(3, 'run.finished', null, { output: { answer: FULL.slice(0, 2000), note: '附注' }, output_truncated: true }), ], end: 'succeeded', } } if (runId === 'run-long') { return { events: [ ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.started', 'ag', {}), ...Array.from({ length: 60 }, (_, i) => ev(3 + i, 'llm.token', 'ag', { delta: '字' })), ev(70, 'node.finished', 'ag', { duration_ms: 1200 }), ev(71, 'run.finished', null, { output: { answer: FULL.slice(0, 2000) }, output_truncated: true }), ], end: 'succeeded', } } if (runId === 'run-restart' && after >= 4) { // 接着跑:从断点往下,这次跑完 return { events: [ ev(5, 'run.resumed', null, { from: 'ag', message: '从「查数」接着跑…' }), ev(6, 'node.started', 'ag', { resumed: true }), ev(7, 'tool.start', 'ag', { tool: 'db_query', call_id: 'q2', args: { sql: 'select 1' } }), ev(8, 'tool.end', 'ag', { tool: 'db_query', call_id: 'q2', rows: 3 }), ev(9, 'node.finished', 'ag', { duration_ms: 800 }), ev(10, 'run.finished', null, { output: { answer: '接着跑完了:三条记录。' } }), ], end: 'succeeded', } } if (runId === 'run-killed') { // 进程被强杀:连结束标记都来不及发,连接直接断了。前端自己重连, // 重启后的后端回放完补一条 stream.end status=interrupted if (after >= 2) return { events: [], end: 'interrupted' } return { events: [ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.started', 'ag', {})], end: 'close' } } if (runId === 'run-restart') { return { events: [ ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.started', 'ag', {}), ev(3, 'tool.start', 'ag', { tool: 'db_query', call_id: 'q1', args: { sql: 'select 1' } }), ev(4, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务正在关停' }), ], end: 'interrupted', } } if (runId === 'run-wait') { // 停在审批上:事件流不关,后端也不发 stream.end return { events: [ ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.started', 'ag', {}), ev(3, 'human.requested', 'ag', { interrupt_id: 'ap-1', payload: { title: '确认一下再查' } }), ev(4, 'run.interrupted', null, { node_id: 'ag' }), ], end: null, } } if (runId === 'run-writing') { // 先想、再写,写完不收尾:一次还在出字的模型调用。gate 处等脚本放行 // 第一段停在半句上:半句不该出现在头部,要等它写完 const tokens = [...Array.from({ length: 12 }, () => '字'.repeat(100)), '字'.repeat(34)] // 1,234 字 return { events: [ ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.started', 'ag', {}), ev(3, 'llm.start', 'ag', { model: 'm' }), ev(4, 'llm.thinking.delta', 'ag', { delta: '先看看 factory 里有哪些表。' }), ev(5, 'llm.thinking.delta', 'ag', { delta: '然后按月汇总出勤' }), { gate: 'thinkGo' }, ev(6, 'llm.thinking.delta', 'ag', { delta: '率,再和上个月比。' }), { gate: 'writingGo' }, ...tokens.map((delta, i) => ev(10 + i, 'llm.token', 'ag', { delta })), ], end: null, } } if (runId.startsWith('run-steps')) { return { events: [ ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.started', 'ag', {}), ev(3, 'node.finished', 'ag', { duration_ms: 900 }), ev(4, 'run.finished', null, { output: { answer: '放宽步数之后查全了' } }), ], end: 'succeeded', } } // 忙的那条:开了头就停住,流不关,像一次还在跑的运行 return { events: [ev(1, 'run.started', null, { nodes: 3 }), ev(2, 'node.started', 'ag', {})], end: null } } async function fakeStream(ws) { const runId = new URL(ws.url()).pathname.split('/')[3] ctl.wsClosed[runId] = false ws.onClose(() => { ctl.wsClosed[runId] = true }) const after = Number(new URL(ws.url()).searchParams.get('after') ?? 0) const { events, end } = script(runId, after) for (const e of events) { if (e.gate) { await until(() => ctl[e.gate], 15000) continue } if (e.seq <= after) continue if (runId === 'run-writing' && e.type === 'llm.token' && !ctl.writingAt) ctl.writingAt = Date.now() // 审批和中断同时产生:后端在停下的那一刻才建出这条待审批 if (runId === 'run-wait' && e.type === 'run.interrupted') { ctl.approvals = [ctl.pendingApproval] ctl.interruptedAt = Date.now() } ws.send(JSON.stringify(e)) await new Promise((r) => setTimeout(r, 15)) } if (end === 'close') ws.close() else if (end) ws.send(JSON.stringify({ type: 'stream.end', status: end, data: { status: end } })) } // ---------------------------------------------------------------- 浏览器 const browser = await chromium.launch({ executablePath: CHROME }) async function open(theme = 'light', { width = 1440, height = 900, reducedMotion = 'no-preference' } = {}) { const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, reducedMotion }) await ctx.addInitScript((t) => localStorage.setItem('agentlab.theme', t), theme) const page = await ctx.newPage() const errors = [] page.on('pageerror', (e) => errors.push(e.message)) await page.route('**/api/settings', async (route) => { if (route.request().method() !== 'GET') return route.abort() // 主题以设置为准(沙箱里存的是 light),这里按要看的那套改掉 return route.fulfill({ json: { ui: { theme }, run: {}, limits: { max_agent_steps: 25 }, copilot: {}, embedding: {} } }) }) await page.route(/\/api\/(conversations|copilot\/(generate-stream|review)|runs|approvals|datasources)(\/|\?|$)/, fakeApi) await page.routeWebSocket(/\/api\/runs\/[^/]+\/stream/, fakeStream) return { page, ctx, errors } } const goto = async (page, id) => { await page.goto(`${WEB}/chat/${id}`) await page.waitForFunction(() => !!window.__chat, null, { timeout: 15000 }) } const shows = (page, text, timeout = 6000) => page.getByText(text).first().waitFor({ timeout }).then(() => true, () => false) const chatState = (page) => page.evaluate(() => window.__chat.getState()) /** 等脚本这一侧的某个条件成立(伪造后端记下的请求、事件流的开关) */ const until = async (cond, ms = 5000) => { const end = Date.now() + ms while (!cond() && Date.now() < end) await new Promise((r) => setTimeout(r, 50)) return cond() } const send = async (page, q) => { const box = page.getByRole('textbox', { name: '向数据提问' }) await box.fill(q) await box.press('Enter') } for (const theme of THEMES) { console.log(`\n######## ${theme} ########`) if (section('hero', '首屏:输入框是主角,例句不拿系统表造句')) { const { page, ctx, errors } = await open(theme) await goto(page, 'c0emptg') check('空会话显示首屏', await shows(page, '问你的数据')) const hints = await page.locator('button[title="填进输入框,改一改再发"]').allInnerTexts() check('有例句', hints.length > 0, hints.join(' | ')) check('例句里没有系统表', hints.every((h) => !/__prisma|_migrations|test_|etl_audit|_log\b/.test(h)), hints.join(' | ')) check('描述里的业务词造了句', hints.some((h) => /factory:/.test(h)), hints.join(' | ')) const ph = await page.getByRole('textbox', { name: '向数据提问' }).getAttribute('placeholder') check('占位符简短', !!ph && ph.length <= 12, ph ?? '') const box = await page.getByRole('textbox', { name: '向数据提问' }).boundingBox() check('输入框在首屏中部,不贴底', !!box && box.y > 200 && box.y + box.height < 700, JSON.stringify(box)) check('数据源入口指向 /data', await page.locator('a[href="/data"]').count() > 0) const postsBefore = log.posts.length await page.locator('button[title="填进输入框,改一改再发"]').first().click() await page.waitForTimeout(200) check('点例句只是填进输入框', (await page.getByRole('textbox', { name: '向数据提问' }).inputValue()).length > 0 && log.posts.length === postsBefore) await page.screenshot({ path: `${SHOTS}/hero-${theme}.png` }) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('states', '三种状态:加载中 / 加载失败 / 空')) { const { page, ctx, errors } = await open(theme) ctl.slowMs = 2500 await page.goto(`${WEB}/chat/c0slowd`) await page.waitForFunction(() => !!window.__chat, null, { timeout: 15000 }) await page.waitForTimeout(600) check('取历史时画骨架', await page.locator('[aria-busy="true"]').count() > 0) check('取历史时不显示首屏', !(await page.getByText('问你的数据').count())) const blocked = await page.getByRole('button', { name: '发送' }).isDisabled() check('取历史时发不出去', blocked) await page.screenshot({ path: `${SHOTS}/loading-${theme}.png` }) check('历史回来了', await shows(page, 'D 的答案二', 5000)) ctl.slowMs = 0 ctl.failE = true const before = log.posts.length await goto(page, 'c0faile') check('取失败时给出错误和重试', await shows(page, '数据库暂时不可用')) check('取失败时不显示首屏', !(await page.getByText('问你的数据').count())) await send(page, '失败时问一句') await page.waitForTimeout(400) check('取失败时不能在空态上提问', log.posts.length === before, `${log.posts.length - before} 次开轮`) await page.screenshot({ path: `${SHOTS}/load-failed-${theme}.png` }) ctl.failE = false await page.getByRole('button', { name: '重试' }).first().click() check('重试后历史回来', await shows(page, 'E 的答案')) check('草稿还在', (await page.getByRole('textbox', { name: '向数据提问' }).inputValue()) === '失败时问一句') ctl.failE = true // 取失败(500、断网)留在原地给重试;只有「这个对话不存在」(404)才把人带走 await page.goto(`${WEB}/chat/c0gone`) check('指向不存在的对话:说清楚为什么换了地方', await shows(page, '那个对话不在了')) check('落到一个真实的对话', await page.waitForURL(/\/chat\/c0[a-z]+$/, { timeout: 5000 }).then(() => !page.url().endsWith('c0gone'), () => false), page.url()) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('layout', '布局:长会话里输入框和发送键完整可见(1440×900,另看 1024×700)')) { for (const [width, height] of [[1440, 900], [1024, 700]]) { const { page, ctx } = await open(theme, { width, height }) await goto(page, 'c0longc') await shows(page, '第 6 个问题') await page.waitForTimeout(500) const m = await page.evaluate(() => { const ta = document.querySelector('textarea')?.getBoundingClientRect() const btn = document.querySelector('button[aria-label="发送"]')?.getBoundingClientRect() return { ta: ta && [ta.top, ta.bottom], btn: btn && [btn.top, btn.bottom], h: innerHeight, scroll: document.documentElement.scrollHeight, wide: document.documentElement.scrollWidth, w: innerWidth } }) const at = `${width}×${height}` check(`${at} 输入框底边在视口内`, !!m.ta && m.ta[1] <= m.h, JSON.stringify(m)) check(`${at} 发送键完整可见`, !!m.btn && m.btn[0] >= 0 && m.btn[1] <= m.h, JSON.stringify(m.btn)) check(`${at} 整页不多滚`, m.scroll <= m.h && m.wide <= m.w, `${m.wide}×${m.scroll} / ${m.w}×${m.h}`) await page.screenshot({ path: `${SHOTS}/long-${width}-${theme}.png` }) await ctx.close() } } if (section('busy', '忙不忙按会话算')) { const { page, ctx, errors } = await open(theme) const cancelsBefore = log.cancels.length await goto(page, 'c0busya') await send(page, '一个会跑很久的问题') check('A 在跑:按钮是停止', await page.getByRole('button', { name: '停止这一轮' }) .waitFor({ timeout: 8000 }).then(() => true, () => false)) // 停止键在建图时就出现了;要等运行真的起来、事件流接上,才谈得上"别的会话别碰它" await page.waitForFunction(() => !!window.__chat.getState().byConversation.c0busya?.at(-1)?.run?.id, null, { timeout: 8000 }).catch(() => {}) const runA = (await chatState(page)).byConversation.c0busya.at(-1).run?.id await until(() => ctl.wsClosed[runA] === false) await page.getByRole('button', { name: /^会话 B/ }).click() await shows(page, 'B 的答案') check('切到 B:按钮是发送,不是停止', await page.getByRole('button', { name: '发送' }).count() === 1 && await page.getByRole('button', { name: '停止这一轮' }).count() === 0) check('B 里说清 A 还在跑', await shows(page, '正在运行,这里可以照常提问')) const rowA = page.locator('aside[aria-label="对话列表"] div.group', { hasText: '会话 A' }) check('列表里 A 标着运行中', (await rowA.innerText()).includes('运行中')) await rowA.hover() check('A 正在跑,不能删', await rowA.getByRole('button', { name: /删除/ }).isDisabled()) const rowB = page.locator('aside[aria-label="对话列表"] div.group', { hasText: '会话 B' }) await rowB.hover() check('B 空闲,可以删', !(await rowB.getByRole('button', { name: /删除/ }).isDisabled())) await page.screenshot({ path: `${SHOTS}/busy-elsewhere-${theme}.png` }) check('在 B 里什么都没停:A 的事件流还开着', ctl.wsClosed[runA] === false, runA) check('A 的运行没被取消', log.cancels.length === cancelsBefore) await page.getByRole('link', { name: '去看看' }).click() await page.getByRole('button', { name: '停止这一轮' }).click() await until(() => log.cancels.length > cancelsBefore && ctl.wsClosed[runA] === true) check('停止只取消 A 自己的运行', log.cancels.slice(cancelsBefore).join() === runA, log.cancels.slice(cancelsBefore).join()) check('A 的事件流关了', ctl.wsClosed[runA] === true) const a = (await chatState(page)).byConversation.c0busya.at(-1) check('A 这一轮收成已取消', a.phase === 'cancelled', a.phase) check('已取消的轮次没有「接着跑」', !(await page.getByRole('button', { name: '接着跑' }).count())) check('已取消的轮次给「重跑这一轮」', await page.getByRole('button', { name: '重跑这一轮' }).count() === 1) const b = (await chatState(page)).byConversation.c0idleb check('B 的轮次没被动过', b.length === 1 && b[0].phase === 'done') await page.waitForTimeout(300) // 「去看看」刚切过来,卡片的入场动效还没播完 await page.screenshot({ path: `${SHOTS}/cancelled-${theme}.png` }) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('truncated', '长答案:事件里截断了,用 GET 运行的完整版')) { const { page, ctx, errors } = await open(theme) const patchesBefore = log.patches.length await goto(page, 'c0trunc') await send(page, '给我一份长答案') check('答完了', await page.waitForFunction( () => window.__chat.getState().byConversation.c0trunc?.at(-1)?.phase === 'done', null, { timeout: 10000 }) .then(() => true, () => false)) const t = (await chatState(page)).byConversation.c0trunc.at(-1) check('显示的是完整答案', String(t.output?.answer ?? '').length === FULL.length, `${String(t.output?.answer ?? '').length} / ${FULL.length}`) check('llm.token 没进 events', !t.events.some((e) => e.type === 'llm.token'), `${t.events.length} 条`) const saved = log.patches.slice(patchesBefore).filter((p) => typeof p.body.answer === 'string' && p.body.answer) check('落库的也是完整答案', saved.length > 0 && saved.every((p) => p.body.answer.length === FULL.length), saved.map((p) => p.body.answer.length).join(',')) // 落库是排队异步发的,交付之后再等它一下 await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta)) const metas = log.patches.slice(patchesBefore).map((p) => p.body.meta).filter(Boolean) const meta = metas.at(-1) check('可信度信息跟着落库(运行、类别、查库次数)', meta?.runId === 'run-long' && meta?.runClass === 'exploratory', JSON.stringify(metas)) // 后端有了单独的 meta 列:review 只放复核结论,不再夹带 check('meta 单独写,不再塞进 review', log.patches.slice(patchesBefore) .every((p) => !p.body.review || !('meta' in p.body.review)), JSON.stringify(log.patches.slice(patchesBefore).map((p) => Object.keys(p.body.review ?? {})))) check('完整答案取到了,meta 里没有「没取全」', !meta?.clipped, JSON.stringify(meta?.clipped)) await page.getByText('展开全部').first().click().catch(() => {}) check('完整结尾在页面上', await shows(page, '【完整结尾】')) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('restart', '服务重启:interrupted 且没有待审批,收尾而不是转圈')) { const { page, ctx, errors } = await open(theme) await goto(page, 'c0resti') await send(page, '跑到一半服务重启') check('说清是服务重启打断的', await shows(page, '服务重启,这一轮中断了', 8000)) await page.waitForTimeout(300) const t = (await chatState(page)).byConversation.c0resti.at(-1) check('这一轮收成挂起', t.phase === 'suspended', t.phase) // 只数这一轮自己和它在左栏的那一行:左栏里夹具的会话 L 本来就标着运行中,它转是对的 const spinning = await page.locator('[data-turn] .animate-spin').count() + await page.locator('aside[aria-label="对话列表"] div.group', { hasText: '会话 I' }).locator('.animate-spin').count() check('不再转圈', spinning === 0, `${spinning} 个转圈`) check('给「接着跑」', await page.getByRole('button', { name: '接着跑' }).count() === 1) check('给「重跑这一轮」', await page.getByRole('button', { name: '重跑这一轮' }).count() === 1) check('输入框可以接着问', await page.getByRole('button', { name: '停止这一轮' }).count() === 0) await page.screenshot({ path: `${SHOTS}/restart-${theme}.png` }) await page.getByRole('button', { name: '接着跑' }).click() check('接着跑:从断点续上并跑完', await page.waitForFunction( () => window.__chat.getState().byConversation.c0resti?.at(-1)?.phase === 'done', null, { timeout: 8000 }) .then(() => true, () => false), (await chatState(page)).byConversation.c0resti.at(-1).phase) check('续跑发给的是这一轮自己的运行', log.continues.at(-1) === 'run-restart', log.continues.join(',')) check('续上之后答案出来了', await shows(page, '接着跑完了:三条记录。')) check('续上之后补救按钮收起', await page.getByRole('button', { name: '接着跑' }).count() === 0) await page.waitForTimeout(300) await page.screenshot({ path: `${SHOTS}/restart-continued-${theme}.png` }) // 强杀:连结束标记都没有,连接直接断。重连上重启后的后端,照样收成中断 await goto(page, 'c0killn') await send(page, '一个跑着跑着服务被强杀的问题') check('连接直接断了:重连之后收成中断', await page.waitForFunction( () => window.__chat.getState().byConversation.c0killn?.at(-1)?.phase === 'suspended', null, { timeout: 8000 }) .then(() => true, () => false), (await chatState(page)).byConversation.c0killn?.at(-1)?.phase) check('强杀之后同样给「接着跑」', await page.getByRole('button', { name: '接着跑' }).count() === 1) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('resume', '隔了几天再接着跑:计时接着上次的走')) { const { page, ctx, errors } = await open(theme) await goto(page, 'c0suspo') check('从库里恢复的中断轮次认成中断', await shows(page, '服务重启,这一轮中断了')) const card = page.locator('[data-remedy="suspended"]') await card.getByRole('button', { name: '接着跑' }).click() const clock = page.locator('[data-turn="to1"] [title="已运行"]') await clock.waitFor({ timeout: 6000 }).catch(() => {}) const text = await clock.innerText().catch(() => '') check('计时从 42 秒接着走,不是从三天前算起', /^00:4\d/.test(text), text) await page.screenshot({ path: `${SHOTS}/resumed-${theme}.png` }) await page.getByRole('button', { name: '停止这一轮' }).click() check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('history', '历史:已取消、续跑被拒、没查库、放宽步数')) { const { page, ctx, errors } = await open(theme) await goto(page, 'c0histf') await shows(page, '步数用满的那一轮') await page.waitForTimeout(800) const turns = (await chatState(page)).byConversation.c0histf check('老数据里的「已取消」认成已取消', turns[0].phase === 'cancelled', turns[0].phase) const cancelledCard = page.locator('[data-remedy="cancelled"]') check('已取消的那一轮没有「接着跑」', await cancelledCard.count() === 1 && !(await cancelledCard.getByRole('button', { name: '接着跑' }).count())) check('失败那一轮按运行状态给「接着跑」', turns[1].runStatus === 'failed' && await page.locator('[data-remedy="failed"]').getByRole('button', { name: '接着跑' }).count() === 1) const failedCard = await page.locator('[data-turn="tf2"]').innerText() // 说法和记录页同一份(lib/explain):它把超时归成「等待超时」,原话收进技术细节 check('失败原因说人话、不露 Python 类名', /超时/.test(failedCard) && !failedCard.includes('KeyError'), failedCard.split('\n').slice(0, 6).join(' / ')) // 人话只翻一遍:翻好的「操作超时:查询超时…」再翻一遍,标题和原因会各写一次「操作超时」 check('报错标题和原因不重复', !failedCard.includes('操作超时:'), failedCard.split('\n').slice(2, 6).join(' / ')) const rejected = page.locator('[data-turn="tf5"] + * [data-remedy="failed"]') check('人驳回的失败:不给「接着跑」(原样续上只会再被驳回)', await rejected.count() === 1 && !(await rejected.getByRole('button', { name: '接着跑' }).count()), (await rejected.innerText().catch(() => '')).replace(/\n/g, ' / ')) check('没查库的老答案标出来了', await shows(page, '这一条没有查库')) const stepBtn = page.getByRole('button', { name: /放宽步数重跑(12 → 24 步)/ }) check('步数用满给出「放宽步数重跑」并写明目标值', await stepBtn.count() === 1) await page.locator('[data-turn="tf1"]').scrollIntoViewIfNeeded() await page.screenshot({ path: `${SHOTS}/history-${theme}.png` }) await page.locator('[data-remedy="failed"]').getByRole('button', { name: '接着跑' }).click() check('续跑被拒时说人话', await shows(page, '不能再接着跑')) await page.waitForTimeout(400) await page.screenshot({ path: `${SHOTS}/continue-refused-${theme}.png` }) const startsBefore = log.runStarts.length await stepBtn.click() check('放宽步数:原图直接重跑,步数调到 24', await page.waitForFunction( (n) => window.__chat.getState().byConversation.c0histf.at(-1).phase === 'done' && window.__chat.getState().byConversation.c0histf.at(-1).attempts?.length === 1, startsBefore, { timeout: 8000 }) .then(() => true, () => false)) const started = log.runStarts.slice(startsBefore).at(-1) check('发起的图里 agent 步数是 24', started?.graph?.nodes?.find((n) => n.type === 'agent')?.data?.config?.max_steps === 24, JSON.stringify(started?.graph?.nodes?.map((n) => n.data?.config))) check('上一次留档:第 1 次尝试 · 结论不可用', await shows(page, '第 1 次尝试')) await page.screenshot({ path: `${SHOTS}/retried-${theme}.png` }) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('build', '建流程就失败:说人话、原文收进技术细节、给补救')) { const { page, ctx, errors } = await open(theme) await goto(page, 'c0buildj') await send(page, '一个建流程就失败的问题') check('说出发生了什么', await shows(page, '模型交回的工作流结构不对')) check('说出怎么办', await shows(page, '再试一次,或者把需求说得更具体些')) const card = await page.locator('[data-turn]').last().innerText() check('原始异常不直接摆出来', !card.includes('validation errors'), card.split('\n').slice(0, 5).join(' / ')) check('原文在「技术细节」里', await page.locator('[data-turn] details', { hasText: '技术细节' }).count() > 0) const remedy = page.locator('[data-remedy="failed"]') check('没有运行就没有「接着跑」', !(await remedy.getByRole('button', { name: '接着跑' }).count())) check('给「重试这一轮」', await remedy.getByRole('button', { name: '重试这一轮' }).count() === 1) await remedy.getByRole('button', { name: '换个说法' }).click() const box = page.getByRole('textbox', { name: '向数据提问' }) await page.waitForTimeout(150) const filled = await box.inputValue() const focused = await box.evaluate((el) => el === document.activeElement) check('「换个说法」把原问题填回输入框并聚焦', filled === '一个建流程就失败的问题' && focused, `${filled} · ${focused ? '已聚焦' : '没聚焦'}`) await page.screenshot({ path: `${SHOTS}/build-failed-${theme}.png` }) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('approval', '停在审批上:审批卡立刻出现,不等 4 秒轮询')) { const { page, ctx, errors } = await open(theme) ctl.approvals = [] ctl.pendingApproval = { id: 'ap-1', run_id: 'run-wait', node_id: 'ag', mode: 'approve', title: '确认一下再查', payload: { title: '确认一下再查' }, status: 'pending', response: {}, created_at: new Date().toISOString(), workflow_name: '问数据', node_label: '查数', run_status: 'interrupted', run_class: 'exploratory', } ctl.interruptedAt = 0 await goto(page, 'c0waitk') await send(page, '一个要审批的问题') const seen = await page.locator('[data-approval-slot] button', { hasText: /通过|放行|批准/ }).first() .waitFor({ timeout: 6000 }).then(() => true, () => false) const lag = ctl.interruptedAt ? Date.now() - ctl.interruptedAt : -1 // 全局轮询是 4 秒一次;中断时立刻去拿的话,一秒内就该出来 check('审批卡在收到中断后马上出现(不等 4 秒轮询)', seen && lag >= 0 && lag < 1500, `${lag} ms`) await page.waitForFunction(() => window.__chat.getState().byConversation.c0waitk?.at(-1)?.phase === 'waiting', null, { timeout: 3000 }).catch(() => {}) const t = (await chatState(page)).byConversation.c0waitk.at(-1) check('这一轮是等待审批,不算占着会话', t.phase === 'waiting' && await page.getByRole('button', { name: '停止这一轮' }).count() === 0, t.phase) await page.screenshot({ path: `${SHOTS}/waiting-${theme}.png` }) ctl.approvals = [] check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('steps', '历史轮次的执行过程:点开、收起')) { const { page, ctx, errors } = await open(theme) await goto(page, 'c0idleb') await shows(page, 'B 的答案') const toggle = page.getByRole('button', { name: /执行过程/ }).first() check('默认只显示问题和答案', (await toggle.innerText()).includes('看执行过程')) await toggle.click() check('点开后能收起', await page.getByRole('button', { name: '收起执行过程' }) .waitFor({ timeout: 4000 }).then(() => true, () => false)) await page.getByRole('button', { name: '收起执行过程' }).click() check('收起之后按钮回到「看执行过程」', await page.getByRole('button', { name: '看执行过程' }).count() === 1) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('writing', '模型出字:只给计数和思考的最后一句,不进 events')) { for (const reducedMotion of ['no-preference', 'reduce']) { const { page, ctx, errors } = await open(theme, { reducedMotion }) ctl.thinkGo = ctl.writingGo = false ctl.writingAt = 0 await goto(page, 'c0writel') await send(page, '一个正在写的问题') const head = page.locator('[data-turn]').last() if (reducedMotion === 'no-preference') { const said = (text) => head.getByText(text, { exact: true }).first() .waitFor({ timeout: 8000 }).then(() => true, () => false) check('思考时头部写出最后一句完整的话', await said('正在思考:先看看 factory 里有哪些表')) await page.waitForTimeout(400) check('还在写的半句不上头部', !(await head.getByText('然后按月汇总出勤').count())) await page.screenshot({ path: `${SHOTS}/thinking-${theme}.png` }) ctl.thinkGo = true check('半句写完了才换上它', await said('正在思考:然后按月汇总出勤率,再和上个月比')) } ctl.thinkGo = true ctl.writingGo = true await until(() => ctl.writingAt > 0, 8000) if (reducedMotion === 'reduce') { // 关掉动效时一秒写回一次:半秒时还没出现,一秒多一点就有了 await page.waitForTimeout(Math.max(0, ctl.writingAt + 500 - Date.now())) check('关掉动效时计数不逐帧跳(半秒时还没写回)', !(await head.getByText('已生成').count())) } const shown = await head.getByText('正在撰写 · 已生成 1,234 字').first() .waitFor({ timeout: 3000 }).then(() => Date.now() - ctl.writingAt, () => -1) check(reducedMotion === 'reduce' ? '关掉动效时一秒左右写回计数' : '出字时头部写「正在撰写 · 已生成 1,234 字」', shown >= 0 && (reducedMotion === 'reduce' ? shown >= 800 : shown < 900), `${shown} ms`) const t = (await chatState(page)).byConversation.c0writel.at(-1) check('token 和思考增量都没进 events', !t.events.some((e) => e.type.startsWith('llm.token') || e.type === 'llm.thinking.delta'), t.events.map((e) => e.type).join(',')) check('答案原文没有提前露出来', !(await head.innerText()).includes('字字字')) await page.screenshot({ path: `${SHOTS}/writing-${reducedMotion === 'reduce' ? 'reduced-' : ''}${theme}.png` }) await page.getByRole('button', { name: '停止这一轮' }).click() await page.waitForTimeout(200) check('停下之后不再写「正在撰写」', !(await head.getByText('正在撰写').count())) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } } if (section('meta', '可信度信息刷新之后还在')) { const { page, ctx, errors } = await open(theme) await goto(page, 'c0metam') await shows(page, '一线 96.2%') await page.waitForTimeout(600) const card = page.locator('[data-turn="tm1"]') check('出具档位还在', await card.getByText('降档出具').count() > 0) check('探索运行的标注还在', await card.getByText('探索运行 · 不进正式归档').count() > 0) check('降档的原因还在', await card.getByText('叙述模板渲染为空').count() > 0) check('耗时还在', (await card.innerText()).includes('12.3 s')) check('这些都是从落库的 meta 读回来的,没有再去问运行', !log.runGets.includes('run-meta'), log.runGets.join(',')) await page.screenshot({ path: `${SHOTS}/meta-${theme}.png` }) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('list', '会话列表:分组、相对时间、完整标题、筛选、没打开过的也标状态')) { resetDb() const { page, ctx, errors } = await open(theme) await goto(page, 'c0idleb') const aside = page.locator('aside[aria-label="对话列表"]') await aside.getByText('会话 B').first().waitFor({ timeout: 6000 }) const heads = await aside.locator('section').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label'))) check('按今天 / 昨天 / 近 7 天 / 更早分组', heads.join(',') === '今天,昨天,近 7 天,更早', heads.join(',')) const rowA = aside.locator('div.group', { hasText: '会话 A' }) check('写相对时间', /分钟前|刚刚/.test(await rowA.innerText()), (await rowA.innerText()).replace(/\n/g, ' / ')) const tip = await aside.locator('div.group', { hasText: '会话 F' }).locator('button').first().getAttribute('title') check('悬停能看到完整标题', !!tip && tip.startsWith('会话 F:取消过、失败过、不可用的历史'), tip ?? '') // 这几条这次都没打开过,状态只能来自列表接口的 last_status const rowText = async (t) => (await aside.locator('div.group', { hasText: t }).innerText()).replace(/\n/g, ' / ') check('没打开过的:停在审批上的标「待审批」', (await rowText('会话 K')).includes('待审批'), await rowText('会话 K')) check('没打开过的:失败的标「失败」', (await rowText('会话 J')).includes('失败'), await rowText('会话 J')) check('没打开过的:服务重启挂起的标「已中断」', (await rowText('会话 I')).includes('已中断'), await rowText('会话 I')) check('没打开过的:在跑的标「运行中」', (await rowText('会话 L')).includes('运行中'), await rowText('会话 L')) check('正常完成的不标', !/待审批|失败|已中断|运行中/.test(await rowText('会话 M')), await rowText('会话 M')) const rowL = aside.locator('div.group', { hasText: '会话 L' }) await rowL.hover() check('列表说它在跑的,也不能删', await rowL.getByRole('button', { name: /删除/ }).isDisabled()) const box = aside.getByRole('searchbox', { name: '按标题或问过的话找对话' }) await box.fill('长会话') const hits = await aside.locator('div.group').allInnerTexts() check('筛选只留下匹配的对话', hits.length === 1 && hits[0].includes('会话 C'), hits.map((h) => h.split('\n')[0]).join(' | ')) await page.screenshot({ path: `${SHOTS}/list-filter-${theme}.png` }) await box.press('Escape') check('Esc 清掉筛选', (await aside.locator('div.group').count()) === Object.keys(CONVS).length) await box.fill('没有这样的对话') check('没有匹配时说一声', await aside.getByText('没有标题或问题里带「没有这样的对话」的对话').count() === 1) await box.fill('') await page.screenshot({ path: `${SHOTS}/list-status-${theme}.png` }) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('filter', '筛选框:有筛选词时不跟着列表变短一起消失')) { resetDb() ctl.few = true const { page, ctx, errors } = await open(theme) await goto(page, 'c0busya') const aside = page.locator('aside[aria-label="对话列表"]') const box = aside.getByRole('searchbox', { name: '按标题或问过的话找对话' }) check('8 个对话时有筛选框', await box.waitFor({ timeout: 6000 }).then(() => true, () => false)) await box.fill('会话 B') const row = aside.locator('div.group', { hasText: '会话 B' }) await row.hover() await row.getByRole('button', { name: /删除/ }).click() // 老版本删除前还要确认一次 if (await page.getByRole('dialog').waitFor({ timeout: 800 }).then(() => true, () => false)) { await page.getByRole('dialog').getByRole('button', { name: '删除' }).click() } await until(() => db.archived.has('c0idleb')) await page.waitForTimeout(300) check('删到 7 个:筛选词还在,筛选框也还在', await box.isVisible().catch(() => false) && (await box.inputValue().catch(() => '')) === '会话 B') await page.screenshot({ path: `${SHOTS}/filter-shrunk-${theme}.png` }) // 老版本框已经没了,清不掉:不在这里卡 30 秒,下面几条照样判红 await box.fill('', { timeout: 2000 }).catch(() => {}) await page.waitForTimeout(200) check('清掉筛选词:剩下 7 个都回来了', (await aside.locator('div.group').count()) === 7, String(await aside.locator('div.group').count())) check('没有筛选词、不够 8 个时,筛选框收起', !(await box.isVisible().catch(() => false))) check('没有运行时报错', errors.length === 0, errors[0] ?? '') ctl.few = false resetDb() await ctx.close() } if (section('delete', '删除:先进回收站,可以撤销')) { resetDb() const { page, ctx, errors } = await open(theme) await goto(page, 'c0idleb') await shows(page, 'B 的答案') const aside = page.locator('aside') const trashBtn = aside.getByRole('button', { name: /^打开回收站/ }) check('回收站入口写着里面有几个', /2/.test(await trashBtn.innerText().catch(() => '')), await trashBtn.innerText().catch(() => '没有入口')) const row = page.locator('aside[aria-label="对话列表"] div.group', { hasText: '会话 B' }) await row.hover() await row.getByRole('button', { name: /删除/ }).click() // 回收站能找回来:删除不再先弹一次确认,撤销和回收站就是后悔药 const asked = await page.getByRole('dialog').waitFor({ timeout: 800 }).then(() => true, () => false) check('删除不再先弹确认', !asked) if (asked) await page.getByRole('dialog').getByRole('button', { name: '删除' }).click() await until(() => log.archived.some(([id, b]) => id === 'c0idleb' && b.archived === true)) check('删除是归档,不是真删', log.archived.some(([id, b]) => id === 'c0idleb' && b.archived === true) && !log.deleted.includes('c0idleb')) const undo = page.getByRole('button', { name: '撤销' }).first() check('给了撤销', await undo.waitFor({ timeout: 4000 }).then(() => true, () => false)) check('提示里说去了回收站', await page.getByText(/已移到回收站/).count() > 0) check('回收站的数跟着变成 3', /3/.test(await trashBtn.innerText().catch(() => '')), await trashBtn.innerText().catch(() => '')) await undo.click().catch(() => {}) await until(() => log.archived.some(([id, b]) => id === 'c0idleb' && b.archived === false)) await page.waitForTimeout(200) check('撤销放回去了', log.archived.some(([id, b]) => id === 'c0idleb' && b.archived === false) && await page.locator('aside[aria-label="对话列表"]').getByText('会话 B').count() > 0) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('trash', '回收站:预览、恢复、彻底删除(要确认)')) { resetDb() const deletedBefore = log.deleted.length const { page, ctx, errors } = await open(theme) await goto(page, 'c0idleb') await shows(page, 'B 的答案') await page.locator('aside').getByRole('button', { name: /^打开回收站/ }).click() const bin = page.locator('aside[aria-label="回收站"]') check('打开回收站', await bin.waitFor({ timeout: 3000 }).then(() => true, () => false)) const rows = bin.locator('div.group') check('列出回收站里的对话', (await rows.count()) === 2, (await rows.allInnerTexts()).map((t) => t.split('\n')[0]).join(' | ')) check('顶行和页头一样高(48px)', await bin.evaluate((el) => Math.round(el.firstElementChild.getBoundingClientRect().height)) === 48) await page.screenshot({ path: `${SHOTS}/trash-${theme}.png` }) // 点开看一眼:内容照常显示,但不能接着问 await rows.filter({ hasText: '删掉的对话甲' }).locator('button').first().click() await page.waitForURL(/\/chat\/c0trash1$/, { timeout: 5000 }).catch(() => {}) check('能打开回收站里的对话看内容', await shows(page, '回收站里的答案')) check('说清它在回收站里', await shows(page, '这个对话在回收站里')) await page.getByRole('textbox', { name: '向数据提问' }).fill('回收站里还能问吗') check('在回收站里不能接着问', await page.getByRole('button', { name: '发送' }).isDisabled()) await page.waitForTimeout(800) // 轮次有入场动画,等它落定再截 await page.screenshot({ path: `${SHOTS}/trash-preview-${theme}.png` }) await page.locator('[data-trash-banner]').getByRole('button', { name: '恢复' }).click() await until(() => log.archived.some(([id, b]) => id === 'c0trash1' && b.archived === false)) await page.waitForTimeout(300) check('恢复:放回列表,提示条收起,可以接着问', !(await page.locator('[data-trash-banner]').count()) && !(await page.getByRole('button', { name: '发送' }).isDisabled())) check('恢复之后回到对话列表,它在里面', await page.locator('aside[aria-label="对话列表"]') .getByText('删掉的对话甲').count() > 0) // 彻底删除:要确认,确认后才发 DELETE await page.locator('aside').getByRole('button', { name: /^打开回收站/ }).click() const left = bin.locator('div.group', { hasText: '删掉的对话乙' }) await left.hover() await left.getByRole('button', { name: /彻底删除/ }).click() const dialog = page.getByRole('dialog') check('彻底删除前先确认', await dialog.waitFor({ timeout: 3000 }).then(() => true, () => false)) check('确认框说清找不回来', (await dialog.innerText().catch(() => '')).includes('找不回来')) await page.waitForTimeout(300) await page.screenshot({ path: `${SHOTS}/trash-purge-${theme}.png` }) await dialog.getByRole('button', { name: '彻底删除' }).click() await until(() => log.deleted.length > deletedBefore) check('确认后才真删', log.deleted.slice(deletedBefore).join() === 'c0trash2', log.deleted.slice(deletedBefore).join()) check('回收站空了就说一声', await shows(page, '回收站是空的')) await bin.getByRole('button', { name: /返回/ }).click() check('返回对话列表', await page.locator('aside[aria-label="对话列表"]').waitFor({ timeout: 3000 }).then(() => true, () => false)) check('没有运行时报错', errors.length === 0, errors[0] ?? '') resetDb() await ctx.close() } if (section('readonly', '回收站里只能看:不发起运行,正在跑的不给彻底删除')) { resetDb() ctl.trashMore = true ctl.approvals = [{ id: 'ap-bin', run_id: 'run-trash-wait', node_id: 'ag', mode: 'approve', title: '确认一下再查', payload: {}, status: 'pending', response: {}, created_at: new Date().toISOString() }] const { page, ctx, errors } = await open(theme) const counts = () => ({ starts: log.runStarts.length, continues: log.continues.length, gens: log.gens.length, posts: log.posts.length }) const before = counts() await goto(page, 'c0trash3') await page.locator('[data-trash-banner]').waitFor({ timeout: 6000 }).catch(() => {}) // 停在审批上的那一轮要核对完、成了「等待审批」,四种补救才都摆出来 await page.waitForFunction(() => window.__chat.getState().byConversation.c0trash3?.find((t) => t.id === 'ty1')?.phase === 'waiting', null, { timeout: 6000 }).catch(() => {}) await page.waitForTimeout(400) const STARTERS = /接着跑|重试这一轮|重跑这一轮|重新问一次|不限数据源重试|放宽步数|跑一下/ // 补救动作排在轮次卡片外面(AssistantStream 的动作槽),整页找 const starters = page.locator('button', { hasText: STARTERS }) const labels = await starters.evaluateAll((bs) => bs.map((b) => `${b.textContent.trim()}${b.disabled ? '' : '(可点)'}`)) const has = (re) => labels.some((l) => re.test(l)) check('会发起运行的按钮照样摆着(恢复之后能做什么看得到)', has(/跑一下/) && has(/接着跑/) && has(/重新问一次|重试这一轮/) && has(/放宽步数/), labels.join(' | ')) check('……但一个都点不了', labels.length > 0 && labels.every((l) => !l.endsWith('(可点)')), labels.join(' | ')) const why = await starters.evaluateAll((bs) => [...new Set(bs.map((b) => b.title))]) check('按钮上说清为什么点不了', why.length === 1 && /回收站/.test(why[0]), why.join(' | ')) check('换个说法照常能用(它不发起运行)', await page.locator('[data-remedy]').getByRole('button', { name: '换个说法' }).first().isEnabled().catch(() => false)) check('看执行过程照常能用', await page.getByRole('button', { name: '看执行过程' }).first().isEnabled().catch(() => false)) check('停在审批上的:不摆能点「通过」的审批卡,说清恢复之后再处理', !(await page.getByRole('button', { name: '通过' }).count()) && (await page.locator('[data-pending-approval]').innerText().catch(() => '')).includes('恢复这个对话之后')) // 按钮之外再绕一道:绕过禁用直接点、直接调 store,也一次运行都发不出去 await starters.evaluateAll((bs) => bs.forEach((b) => b.click())) await page.evaluate(() => { const s = window.__chat.getState() s.retryTurn('c0trash3', 'ty3') s.retryTurn('c0trash3', 'ty2', { rerun: true }) s.runNow('c0trash3', 'ty0') void s.continueTurn('c0trash3', 'ty2') s.ask('c0trash3', '回收站里偷偷问一句') }) await page.waitForTimeout(900) const after = counts() check('一次运行都没发起:不建图、不开轮、不 POST /runs、不接着跑', JSON.stringify(after) === JSON.stringify(before), `${JSON.stringify(before)} → ${JSON.stringify(after)}`) await page.locator('[data-turn="ty2"]').scrollIntoViewIfNeeded().catch(() => {}) await page.waitForTimeout(300) await page.screenshot({ path: `${SHOTS}/trash-readonly-${theme}.png` }) // 删掉之后又跑起来的:和列表里一样不给彻底删除 await page.locator('aside').getByRole('button', { name: /^打开回收站/ }).click() const bin = page.locator('aside[aria-label="回收站"]') const liveRow = bin.locator('div.group', { hasText: '删掉的对话丁' }) await liveRow.waitFor({ timeout: 3000 }).catch(() => {}) check('回收站里标出它在运行', (await liveRow.innerText().catch(() => '')).includes('运行中'), (await liveRow.innerText().catch(() => '')).replace(/\n/g, ' / ')) await liveRow.hover() const rowPurge = liveRow.getByRole('button', { name: /彻底删除/ }) check('正在跑的:行上的彻底删除点不了,说清为什么', await rowPurge.isDisabled().catch(() => false) && (await rowPurge.getAttribute('title')) === '这个对话正在运行,先停下来再删', await rowPurge.getAttribute('title').catch(() => '')) await liveRow.locator('button').first().click() const live = await page.waitForFunction(() => window.__chat.getState().byConversation.c0trash4?.[0]?.phase === 'running', null, { timeout: 6000 }).then(() => true, () => false) check('打开它:核对发现运行还活着,接了回去', live, (await chatState(page)).byConversation.c0trash4?.[0]?.phase) const bannerPurge = page.locator('[data-trash-banner]').getByRole('button', { name: '彻底删除' }) check('提示条上的彻底删除也点不了', await bannerPurge.isDisabled().catch(() => false)) const stop = page.getByRole('button', { name: '停止这一轮' }) check('在回收站里也停得下来', await stop.count() === 1) await page.waitForTimeout(300) await page.screenshot({ path: `${SHOTS}/trash-running-${theme}.png` }) // 清空:只删没在跑的;在跑的留下,确认框里先说 const deletedBefore = log.deleted.length await bin.getByRole('button', { name: '清空回收站' }).click() const dialog = page.getByRole('dialog') await dialog.waitFor({ timeout: 3000 }).catch(() => {}) const said = await dialog.innerText().catch(() => '') check('清空前说清在跑的这次不删', said.includes('删掉的对话丁') && said.includes('正在运行'), said.replace(/\n/g, ' / ')) await dialog.getByRole('button', { name: '全部删除' }).click().catch(() => {}) await until(() => log.deleted.length - deletedBefore >= 3, 4000) await page.waitForTimeout(300) const gone = log.deleted.slice(deletedBefore) check('清空:没在跑的都删了,在跑的没删', ['c0trash1', 'c0trash2', 'c0trash3'].every((id) => gone.includes(id)) && !gone.includes('c0trash4'), gone.join(',')) check('在跑的那条事件流没被关掉、运行没被丢下', ctl.wsClosed['run-trash-live'] === false && (await chatState(page)).byConversation.c0trash4?.[0]?.phase === 'running') check('它还在回收站里', await bin.locator('div.group', { hasText: '删掉的对话丁' }).count() === 1) // 停下来之后就能删了 await stop.click().catch(() => {}) await until(() => log.cancels.includes('run-trash-live'), 3000) await page.waitForTimeout(200) check('停下之后:彻底删除可以点了', await bannerPurge.isEnabled().catch(() => false)) await bannerPurge.click().catch(() => {}) await dialog.waitFor({ timeout: 3000 }).catch(() => {}) await dialog.getByRole('button', { name: '彻底删除' }).click().catch(() => {}) await until(() => log.deleted.slice(deletedBefore).includes('c0trash4'), 3000) check('确认之后删掉了', log.deleted.slice(deletedBefore).includes('c0trash4')) check('没有运行时报错', errors.length === 0, errors[0] ?? '') ctl.trashMore = false ctl.approvals = [] resetDb() await ctx.close() } if (section('recheck', '恢复的半路轮次:核对撞上 500 有出路、补交付不计实时、终态落库')) { const { page, ctx, errors } = await open(theme) ctl.run500 = true await goto(page, 'c0unkp') // 先等这一轮取回来:取回来之前「不在核对」是空洞地成立的 const settled = await page.waitForFunction(() => { const t = window.__chat.getState().byConversation.c0unkp?.[0] return !!t && t.hydrated !== 'pending' && t.phase !== 'checking' }, null, { timeout: 5000 }).then(() => true, () => false) check('核对撞上 500:不再一直停在「正在核对」', settled, (await chatState(page)).byConversation.c0unkp?.[0]?.phase) check('说清没核对上', await shows(page, '没核对上这一轮现在的状态')) const recheck = page.locator('[data-remedy]').getByRole('button', { name: '重新核对' }) check('给「重新核对」', await recheck.count() === 1) check('不给会另起运行的按钮', !(await page.getByRole('button', { name: /重试这一轮|重跑这一轮|接着跑/ }).count())) check('这时不转圈', !(await page.locator('[data-turn="tp1"] .animate-spin').count())) await page.screenshot({ path: `${SHOTS}/recheck-failed-${theme}.png` }) ctl.run500 = false await recheck.click().catch(() => {}) check('重新核对:按运行的真实状态收尾', await page.waitForFunction( () => window.__chat.getState().byConversation.c0unkp?.[0]?.phase === 'cancelled', null, { timeout: 5000 }) .then(() => true, () => false), (await chatState(page)).byConversation.c0unkp?.[0]?.phase) ctl.run500 = true // 三天前跑完、交付没赶上:补交付(等复核)期间不按实时运行计时 ctl.reviewDelay['run-late'] = 1800 await goto(page, 'c0lateq') await page.waitForFunction(() => window.__chat.getState().byConversation.c0lateq?.[0]?.hydrated === 'done', null, { timeout: 5000 }).catch(() => {}) await page.waitForTimeout(300) const mid = (await chatState(page)).byConversation.c0lateq?.[0] check('补交付期间不算「运行中」', mid?.phase !== 'running' && mid?.phase !== 'done', mid?.phase) const clock = await page.locator('[data-turn="tq1"] [title="已运行"]').allInnerTexts() check('补交付期间头部不走实时计时(不会从三天前算起)', clock.length === 0, clock.join(',')) await page.screenshot({ path: `${SHOTS}/late-delivery-${theme}.png` }) check('补交付完成', await shows(page, '补交付的答案:三条记录。', 5000)) delete ctl.reviewDelay['run-late'] // 停在半路的几轮:各自的终态查到之后要落库,下次进来不再核对 ctl.approvals = [{ id: 'ap-t', run_id: 'run-t-wait', node_id: 'ag', mode: 'approve', title: '确认一下', payload: {}, status: 'pending', response: {}, created_at: new Date().toISOString() }] const patchesBefore = log.patches.length await goto(page, 'c0termr') await until(() => ['tr1', 'tr2', 'tr3', 'tr4'].every((id) => log.patches.slice(patchesBefore).some((p) => p.turn === id && p.body.status === 'error')), 6000) await page.waitForTimeout(300) const saved = (id) => log.patches.slice(patchesBefore).filter((p) => p.turn === id).at(-1)?.body check('在别处取消的:落库成已取消', saved('tr1')?.status === 'error' && saved('tr1')?.error === '已取消' && saved('tr1')?.meta?.outcome === 'cancelled', JSON.stringify(saved('tr1'))) check('在别处失败的:落库成失败,原因跟着写', saved('tr2')?.status === 'error' && /鉴权/.test(saved('tr2')?.error ?? ''), JSON.stringify(saved('tr2'))?.slice(0, 160)) check('服务重启挂起的:落库成中断', saved('tr3')?.status === 'error' && saved('tr3')?.meta?.outcome === 'suspended', JSON.stringify(saved('tr3'))?.slice(0, 160)) check('运行被删掉的:落库成「上次没有跑完」', saved('tr4')?.status === 'error' && saved('tr4')?.error === '上次没有跑完', JSON.stringify(saved('tr4'))?.slice(0, 160)) check('还在等审批的不落库(它还没结束)', !saved('tr5'), JSON.stringify(saved('tr5'))) ctl.approvals = [] check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('clipped', '长答案没取全:记进 meta、刷新后补全,2000 字按码点数')) { const { page, ctx, errors } = await open(theme) const patchesBefore = log.patches.length await goto(page, 'c0clips') await send(page, '一份取不到运行的长答案') await page.waitForFunction(() => window.__chat.getState().byConversation.c0clips?.at(-1)?.phase === 'done', null, { timeout: 10000 }).catch(() => {}) const t = (await chatState(page)).byConversation.c0clips.at(-1) check('GET 运行失败:先交截断的那份并标出来', t.clipped === 'partial', t.clipped) await until(() => log.patches.slice(patchesBefore).some((p) => p.body.meta?.clipped === 'partial')) check('「没取全」写进 meta,刷新后还认得出来', log.patches.slice(patchesBefore).some((p) => p.body.meta?.clipped === 'partial'), JSON.stringify(log.patches.slice(patchesBefore).map((p) => p.body.meta?.clipped))) const runGetsBefore = log.runGets.length await goto(page, 'c0clipt') await shows(page, '多个成果键的长答案') await until(() => log.runGets.slice(runGetsBefore).includes('run-multi'), 4000) check('meta 说没取全:一进来就去补,不等滚进视口', log.runGets.slice(runGetsBefore).includes('run-multi'), log.runGets.slice(runGetsBefore).join(',')) await page.waitForFunction(() => String(window.__chat.getState().byConversation.c0clipt?.[0]?.output?.answer ?? '').includes('【完整结尾】'), null, { timeout: 4000 }).catch(() => {}) const tt1 = (await chatState(page)).byConversation.c0clipt[0] check('多个成果键的长答案补全了', String(tt1.output?.answer ?? '').includes('【完整结尾】') && !tt1.clipped, `${String(tt1.output?.answer ?? '').length} 字 · ${tt1.clipped}`) await page.waitForTimeout(300) const lost = await page.locator('[data-remedy]', { hasText: '截在了 2000 字' }).count() check('恰好 2000 个码点(UTF-16 不是 2000)也认得出被截断、补不回来', lost === 2, `${lost} 处`) await page.locator('[data-turn="tt2"]').scrollIntoViewIfNeeded().catch(() => {}) await page.screenshot({ path: `${SHOTS}/clipped-${theme}.png` }) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('launch', '发起运行失败:报错分得清标题和原因,补救说真话')) { const { page, ctx, errors } = await open(theme) ctl.launchFail = true await goto(page, 'c0launu') await send(page, '一个启动失败的问题') const alert = page.locator('[data-turn] [role="alert"]').first() await alert.waitFor({ timeout: 8000 }).catch(() => {}) const title = await alert.locator('.font-medium').first().innerText().catch(() => '') check('标题只是「连不上后端服务」,原因不糊进粗体', title === '连不上后端服务', title) const text = await alert.innerText().catch(() => '') check('不许诺页面上不存在的自动重试', !text.includes('自动重试'), text.replace(/\n/g, ' / ')) const rerun = page.locator('[data-remedy="failed"]').getByRole('button', { name: '重跑这一轮' }) check('流程已经搭好:给「重跑这一轮」,不让它重新搭', await rerun.count() === 1) check('说出怎么办:连上之后点「重跑这一轮」', text.includes('重跑这一轮'), text.replace(/\n/g, ' / ')) await page.screenshot({ path: `${SHOTS}/launch-failed-${theme}.png` }) ctl.launchFail = false const gensBefore = log.gens.length const startsBefore = log.runStarts.length await rerun.click().catch(() => {}) await until(() => log.runStarts.length > startsBefore, 5000) check('重跑:原图直接发起,不再去问 Copilot', log.runStarts.length > startsBefore && log.gens.length === gensBefore, `${log.runStarts.length - startsBefore} 次发起 · ${log.gens.length - gensBefore} 次建图`) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) ctl.launchFail = true check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('rerun', '重跑服务重启挂起的那一轮:旧运行顺手取消')) { const { page, ctx, errors } = await open(theme) await goto(page, 'c0suspo') await shows(page, '服务重启,这一轮中断了') const before = log.cancels.length const startsBefore = log.runStarts.length await page.locator('[data-remedy="suspended"]').getByRole('button', { name: '重跑这一轮' }).click() await until(() => log.runStarts.length > startsBefore, 5000) await until(() => log.cancels.slice(before).includes('run-susp'), 2000) check('挂起的旧运行被取消,不会一直挂在记录里', log.cancels.slice(before).includes('run-susp'), log.cancels.slice(before).join(',')) check('新的一次照常发起', log.runStarts.length > startsBefore) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('stale', '断在建图阶段:按最后一次落库算有没有动静;发起运行时 meta 跟上新运行')) { const { page, ctx, errors } = await open(theme) const patchesBefore = log.patches.length await goto(page, 'c0buildx') await shows(page, '重试之后二十分钟没动静的问题') await until(() => log.patches.slice(patchesBefore).some((p) => p.turn === 'tx8'), 3000) await page.waitForTimeout(400) const mine = () => log.patches.slice(patchesBefore) check('两天前问的、两分钟前刚重试过:不当成断了写回 error', !mine().some((p) => p.turn === 'tx7' && p.body.status === 'error'), JSON.stringify(mine().filter((p) => p.turn === 'tx7'))) check('重试之后二十分钟没动静的:认定断了,写回 error', mine().some((p) => p.turn === 'tx8' && p.body.status === 'error'), JSON.stringify(mine().filter((p) => p.turn === 'tx8')).slice(0, 200)) // 重试一次:搭好图时连 meta 一起写(有动静的凭据),发起运行时 meta.runId 换成新运行 const startsBefore = log.runStarts.length const retryAt = log.patches.length await page.locator('[data-remedy="failed"]').getByRole('button', { name: '重试这一轮' }).click() await until(() => log.runStarts.length > startsBefore, 6000) const newRun = log.runStarts.at(-1)?.id await until(() => log.patches.slice(retryAt).some((p) => p.turn === 'tx8' && p.body.run_id === newRun), 3000) const ps = log.patches.slice(retryAt).filter((p) => p.turn === 'tx8').map((p) => p.body) const graphSave = ps.find((b) => b.graph) check('搭好图时连 meta 一起写,带上此刻', !!graphSave?.meta && Math.abs(Date.now() - (graphSave.meta.at ?? 0)) < 30_000, JSON.stringify(graphSave?.meta ?? null)?.slice(0, 120)) const started = ps.find((b) => b.run_id === newRun) check('发起运行:run_id 列和 meta.runId 一起换成新运行,状态是 running', !!newRun && started?.meta?.runId === newRun && started?.status === 'running', JSON.stringify(started ?? null)?.slice(0, 160)) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('scope', '限定数据源:点一下只查这个库')) { const { page, ctx, errors } = await open(theme) await goto(page, 'c0emptg') await shows(page, '问你的数据') const chip = page.locator('[data-scope-chip="ds-plant"]') check('首屏的数据源标签可以点', await chip.waitFor({ timeout: 5000 }).then(() => true, () => false)) check('默认不限', (await chip.getAttribute('aria-pressed')) === 'false') await chip.click() check('点一下就限定成它', (await chip.getAttribute('aria-pressed')) === 'true') await page.waitForTimeout(300) // 颜色有过渡,等它换完再截 await page.screenshot({ path: `${SHOTS}/scope-hero-${theme}.png` }) const gensBefore = log.gens.length await send(page, '只查这个库的问题') await until(() => log.gens.length > gensBefore, 5000) check('请求里带上 datasource_ids', JSON.stringify(log.gens.at(-1)?.datasource_ids) === '["ds-plant"]', JSON.stringify(log.gens.at(-1)?.datasource_ids)) check('这一轮上写着只查了哪个库', await shows(page, '只查 factory')) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) await page.waitForTimeout(200) // 在对话里:输入框左边的范围选择 await goto(page, 'c0scopv') await shows(page, '上一问的答案') const picker = page.getByRole('button', { name: /数据源范围/ }) check('输入框旁有数据源范围', await picker.count() === 1) check('默认写「全部数据源」', (await picker.innerText().catch(() => '')).includes('全部数据源'), await picker.innerText().catch(() => '')) await picker.click() await page.getByRole('checkbox', { name: 'shop' }).check() check('选了之后按钮写「只查 shop」', (await picker.innerText().catch(() => '')).includes('只查 shop'), await picker.innerText().catch(() => '')) await page.screenshot({ path: `${SHOTS}/scope-picker-${theme}.png` }) await page.keyboard.press('Escape') const before2 = log.gens.length await send(page, '在对话里限定的问题') await until(() => log.gens.length > before2, 5000) check('对话里限定的也带上', JSON.stringify(log.gens.at(-1)?.datasource_ids) === '["ds-mig"]', JSON.stringify(log.gens.at(-1)?.datasource_ids)) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) await page.waitForTimeout(200) // 限定的库被删了、停用了:后端 400,把原因原样说出来 ctl.scope400 = true await send(page, '限定的库已经不在了') check('库不在了:说清原因', await shows(page, '限定的数据源都不在了')) await page.screenshot({ path: `${SHOTS}/scope-400-${theme}.png` }) // 带着原范围的「重试这一轮」只会再撞一次 400:补救得是放开范围 const loose = page.locator('[data-remedy="failed"]').getByRole('button', { name: '不限数据源重试' }) check('库不在了:补救是「不限数据源重试」', await loose.count() === 1) const before3 = log.gens.length await loose.click().catch(() => {}) await until(() => log.gens.length > before3, 5000) check('不限数据源重试:请求里不再带 datasource_ids', log.gens.length > before3 && !log.gens.at(-1)?.datasource_ids?.length, JSON.stringify(log.gens.at(-1)?.datasource_ids)) check('输入框旁的范围也一起放开', (await picker.innerText().catch(() => '')).includes('全部数据源'), await picker.innerText().catch(() => '')) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) ctl.scope400 = false // 库列表没取回来:从上一问接过来的范围照样会发出去,所以照样得摆在眼前 ctl.sourcesFail = true await goto(page, 'c0scopw') await shows(page, '上一问的答案 W') const picker2 = page.getByRole('button', { name: /数据源范围/ }) await picker2.waitFor({ timeout: 3000 }).catch(() => {}) check('库列表没取回来:范围照样摆在输入框旁', (await picker2.innerText().catch(() => '')).includes('只查 factory'), await picker2.innerText().catch(() => '没有范围按钮')) await picker2.click().catch(() => {}) const stray = page.locator('[data-scope-stray]') check('范围里的库照样列出来,注明没取到状态', (await stray.innerText().catch(() => '')).includes('没取到状态') && await stray.getByRole('checkbox').isChecked().catch(() => false), await stray.innerText().catch(() => '')) await page.waitForTimeout(250) await page.screenshot({ path: `${SHOTS}/scope-unconfirmed-${theme}.png` }) await stray.getByRole('checkbox').uncheck().catch(() => {}) check('取消勾选之后它还留在原处,按钮写回「全部数据源」', await stray.count() === 1 && (await picker2.innerText().catch(() => '')).includes('全部数据源'), await picker2.innerText().catch(() => '')) await stray.getByRole('checkbox').check().catch(() => {}) await page.keyboard.press('Escape') const before4 = log.gens.length await send(page, '库列表没取回来时的问题') await until(() => log.gens.length > before4, 5000) check('发出去的范围和看得见的一致', JSON.stringify(log.gens.at(-1)?.datasource_ids) === '["ds-plant"]', JSON.stringify(log.gens.at(-1)?.datasource_ids)) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {}) ctl.sourcesFail = false check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } if (section('composer', '画布助手:「在现有图上改 | 从头生成」分段控件')) { const { page, ctx, errors } = await open(theme) const WF = { id: 'wf-chat-check', name: '__chat_check_composer__', description: '检查脚本伪造的工作流', graph: GRAPH, tags: [], version: 1, is_template: false, status: 'draft', published_version: null, published_by: null, run_count: 0, created_at: iso(60), updated_at: iso(60), } await page.route(/\/api\/workflows(\?.*)?$/, (route) => (route.request().method() === 'GET' ? route.fulfill({ json: [WF] }) : route.fallback())) await page.route(/\/api\/workflows\/wf-chat-check(\/.*)?(\?.*)?$/, (route) => { const url = new URL(route.request().url()) if (route.request().method() !== 'GET') return route.abort() if (url.pathname.endsWith('/versions')) return route.fulfill({ json: [] }) return route.fulfill({ json: WF }) }) await page.goto(`${WEB}/studio/wf-chat-check`) await page.waitForFunction(() => window.__studio?.getState().workflow?.id === 'wf-chat-check', null, { timeout: 15000 }) .catch(() => {}) const group = page.getByRole('radiogroup', { name: '生成方式' }) check('有「生成方式」分段控件', await group.waitFor({ timeout: 6000 }).then(() => true, () => false)) const base = group.getByRole('radio', { name: '在现有图上改' }) const fresh = group.getByRole('radio', { name: '从头生成' }) check('默认「在现有图上改」', (await base.getAttribute('aria-checked')) === 'true' && (await fresh.getAttribute('aria-checked')) === 'false') const size = await fresh.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)).catch(() => 0) check('字不小于 11px', size >= 11, `${size}px`) await base.focus() await page.keyboard.press('ArrowRight') check('方向键切到「从头生成」', (await fresh.getAttribute('aria-checked')) === 'true' && await fresh.evaluate((el) => el === document.activeElement)) await page.waitForTimeout(300) // 底色有过渡,等它换完再截 await page.screenshot({ path: `${SHOTS}/composer-fresh-${theme}.png` }) const gensBefore = log.gens.length const box = page.getByRole('textbox', { name: '告诉助手要画什么流程' }) await box.fill('重新画一张三步的图') await box.press('Enter') await until(() => log.gens.length > gensBefore, 5000) check('从头生成:不带现有的图', log.gens.at(-1)?.base_graph === null, JSON.stringify(log.gens.at(-1)?.base_graph)?.slice(0, 60)) await page.waitForFunction(() => !window.__studio.getState().copilot.active, null, { timeout: 8000 }).catch(() => {}) await base.click() await box.fill('在最后加一步人工审批') await box.press('Enter') await until(() => log.gens.length > gensBefore + 1, 5000) check('在现有图上改:带上现有的图', !!log.gens.at(-1)?.base_graph?.nodes?.length) // 助手出错:出错的那一轮自己有报错块(怎么办、原文都在),输入框上面不再重复一条 await page.waitForFunction(() => !window.__studio.getState().copilot.active, null, { timeout: 8000 }).catch(() => {}) await box.fill('这一句会让建流程就失败') await box.press('Enter') const card = page.locator('[data-turn-error]', { hasText: '模型交回的工作流结构不对' }) const shown = await card.first().waitFor({ timeout: 6000 }).then(() => true, () => false) const said = await card.first().innerText().catch(() => '') check('助手出错:那一轮写出发生了什么和怎么办', shown && said.includes('再试一次,或者把需求说得更具体些'), said.replace(/\n/g, ' / ')) check('同一句报错只说一遍(输入框上面不再摆一条)', await card.count() === 1 && await page.locator('[data-copilot-error]').count() === 0, `${await card.count()} 块 · 错误条 ${await page.locator('[data-copilot-error]').count()} 条`) await page.waitForTimeout(300) await page.screenshot({ path: `${SHOTS}/composer-error-${theme}.png` }) check('没有运行时报错', errors.length === 0, errors[0] ?? '') await ctx.close() } } await browser.close() console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 问数据全部通过') process.exit(failed ? 1 : 0)