Files
AgentLab/scripts/check-chat.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

835 lines
48 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.
// 问数据页的检查:会话、轮次、运行全部在浏览器这一侧伪造,页面走的是真的
// 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. 首屏例句不拿系统表造句;可信度信息刷新后还在。
//
// 所有写请求都被拦在浏览器里,不碰任何库。
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 conv = (id, title, turns, minsAgo) => ({
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,
})
const LONG = (n) => Array.from({ length: n }, (_, i) =>
`${i + 1}. 这一段是很长的结论正文,用来把会话撑到超过一屏,检查输入框会不会被挤出视口。`).join('\n')
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),
histF: conv('c0histf', '会话 F:取消过、失败过、不可用的历史', 4, 60 * 30),
emptyG: conv('c0emptg', '新对话', 0, 60 * 24 * 3),
truncH: conv('c0trunc', '会话 H:长答案', 0, 60 * 24 * 10),
restI: conv('c0resti', '会话 I:跑到一半服务重启', 0, 60 * 24 * 12),
buildJ: conv('c0buildj', '会话 J:建流程就失败', 0, 60 * 24 * 13),
waitK: conv('c0waitk', '会话 K:停在审批上', 0, 60 * 24 * 14),
writeL: conv('c0writel', '会话 L:模型正在出字', 0, 60 * 24 * 15),
metaM: conv('c0metam', '会话 M:刷新之后的可信度', 1, 60 * 24 * 16),
killN: conv('c0killn', '会话 N:服务被强杀', 0, 60 * 24 * 17),
suspO: conv('c0suspo', '会话 O:三天前被重启打断的', 1, 60 * 24 * 3),
}
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' }),
// 没有运行、没有图,却有答案: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),
review: { meta: { v: 1, runId: 'run-susp', runStatus: 'interrupted', outcome: 'suspended', ms: 42000 } },
})],
// 出具档位、运行类别、耗时、查库次数只存在 review.meta 里:刷新后得从这里读回来,
// 而不是再去问一遍运行
c0metam: [turn('tm1', '上个月各产线的出勤率', {
answer: '一线 96.2%,二线 91.0%。', graph: GRAPH, run_id: 'run-meta',
review: { 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: [] },
} },
})],
}
const RUNS = {
'run-cancelled': { status: 'cancelled', output: {}, error: null },
'run-failed': { status: 'failed', output: {}, error: '查询超时' },
'run-broken': { status: 'succeeded', output: { answer: '只查了一半的结论' }, error: null },
}
const FULL = '这是完整答案的开头。' + '正文'.repeat(1920) + '【完整结尾】' // 3,850 字左右
const SOURCES = [
{ id: 'ds-mig', name: 'shop', kind: 'mysql', description: '' },
{ id: 'ds-bi', name: 'bi', kind: 'oracle', description: '示例 BI 库:采购、物料、生产、设备' },
]
const TABLES = {
'ds-mig': ['__drizzle_migrations', '_migrations', 'admin_audit_log', 'app_log', 'test_sales', 'user', 'orders'],
'ds-bi': ['ANALYTICS.test_sales', 'ANALYTICS.job_etl_audit', 'ANALYTICS.v_demo_table'],
}
// ---------------------------------------------------------------- 伪造的后端
const log = { patches: [], cancels: [], continues: [], runStarts: [], archived: [], posts: [], approvalGets: [], runGets: [] }
const ctl = { slowMs: 0, failE: true, wsClosed: {}, approvals: [], thinkGo: false, writingGo: false, writingAt: 0 }
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') return json(Object.values(CONVS))
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 = Object.values(CONVS).find((c) => c.id === id)
if (!known && id !== 'c0new') return json({ detail: '这个对话不存在,可能已经被删了' }, 404)
return json({ ...known, turns: DETAIL[id] ?? [] })
}
if (m && method === 'PATCH') { log.archived.push([m[1], body()]); return json({ ...CONVS[m[1]], ...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') {
if (String(body().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') {
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 ?? '')
const id = conversation.includes('长答案') ? '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 (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 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-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: '先看看 bi 里有哪些表。' }),
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) => !/__drizzle|_migrations|test_|etl_audit|_log\b/.test(h)), hints.join(' | '))
check('描述里的业务词造了句', hints.some((h) => /bi:/.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.review?.meta))
const metas = log.patches.slice(patchesBefore).map((p) => p.body.review?.meta).filter(Boolean)
const meta = metas.at(-1)
check('可信度信息跟着落库(运行、类别、查库次数)', meta?.runId === 'run-long' && meta?.runClass === 'exploratory',
JSON.stringify(metas))
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)
const spinning = await page.locator('main .animate-spin, [role="main"] .animate-spin').count()
+ await page.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()
check('失败原因说人话、不露 Python 类名', failedCard.includes('查询超时') && !failedCard.includes('KeyError'),
failedCard.split('\n').slice(0, 4).join(' / '))
// 人话只翻一遍:翻好的「操作超时:查询超时…」再翻一遍,标题和原因会各写一次「操作超时」
check('报错标题和原因不重复', !failedCard.includes('操作超时:'), failedCard.split('\n').slice(2, 6).join(' / '))
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('正在思考:先看看 bi 里有哪些表'))
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', '会话列表:分组、相对时间、完整标题、筛选')) {
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 ?? '')
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)
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await ctx.close()
}
if (section('delete', '删除:确认,且可以撤销')) {
const { page, ctx, errors } = await open(theme)
await goto(page, 'c0idleb')
await shows(page, 'B 的答案')
const row = page.locator('aside[aria-label="对话列表"] div.group', { hasText: '会话 B' })
await row.hover()
await row.getByRole('button', { name: /删除/ }).click()
check('删除前先确认', await page.getByRole('dialog').waitFor({ timeout: 3000 }).then(() => true, () => false))
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))
check('给了撤销', await page.getByRole('button', { name: '撤销' }).first()
.waitFor({ timeout: 4000 }).then(() => true, () => false))
await page.getByRole('button', { name: '撤销' }).first().click()
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()
}
}
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 问数据全部通过')
process.exit(failed ? 1 : 0)