// 运行对比页(/runs/compare?target=…&base=…,pages/runs/ComparePage.tsx)的回归检查。 // // 守的是这些「看着没坏、其实用不了」的地方:运行详情「更多」里没有入口、点了不先找基准就乱跳;摘要的个数和服务端 // 给的对不上;分类标签张冠李戴;口径效应和数据效应只摆两个数,不说两者之和等于总变化(或者自己算了一遍、和服务端 // 渲染的写法对不上);不能拆的一声不吭;展开后归因只给数没有占比时不说为什么;SQL 结构变了只给一条、差异不标; // 换了基准、互换了却不重新请求,或者慢的旧答复盖掉新的;409(未封存、封存核对不一致)、422(不是同一工作流)、 // 404(找不到基准)只弹一句通用报错;窄屏(390px)被表格撑出横向滚动;浅色下分类标签、差异高亮看不清。 // 还有几处照服务端(engine/compare.py、api/compare.py)的实际输出才看得出来的:无法比较的指标原因只在 reasons 里; // 同一节点两条查询的 (node_id, ordinal) 相同;一边的 SQL 是 null;基准运行读不到;本次的记录读不到;不属于已保存 // 工作流的运行;查找基准期间离开运行详情(新页面画好之后、或者地址刚换新页面还没画出来时答复才回来,走过的地址里 // 都不能出现对比页)。下一步的提示要和页面上真能做的事对得上:下拉里没有能换的运行就不叫人去上方换,本次的记录 // 不存在时不给注定失败的「重试」。默认基准的说法照服务端的挑法(api/compare.py 的 pick_base)写「上一次成功的运行」 // ——上一次失败了,基准是再往前成功的那一次;定时任务触发历史里的「与上一次对比」和运行详情里的同名入口打开同一对 // 运行、写同一句来历,基准不是紧挨着的上一次时触发历史上看得见的字写明「上一次成功的运行」;找不到默认基准时标题不说 // 「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。 // // 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、 // compare/base、compare、定时任务列表、发起它们的定时任务和触发历史都在浏览器层答掉,别的 GET 放行(外壳要的工作流、 // 待审批),非 GET 一律拦掉。 // 运行对比的两个接口只读,这里的假答复严格照接口约定的字段名和形状。 // // node scripts/check-compare.mjs // 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-compare.mjs // COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。COMPARE_ONLY=查询,窄屏 只跑标题里带这几截的段。 import { mkdirSync } from 'node:fs' import { chromium } from '../frontend/node_modules/playwright-core/index.mjs' 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.COMPARE_SHOTS ?? '' let failed = 0 let ran = 0 const check = (name, cond, detail = '') => { console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) ran++ if (!cond) failed++ } // 只跑其中几段:COMPARE_ONLY=<段标题里的一截>,<…>,改样式、补用例时省时间(check-all 不往下传) const ONLY = (process.env.COMPARE_ONLY ?? '').split(',').filter(Boolean) const opened = new Set() async function section(name, fn) { if (ONLY.length && !ONLY.some((part) => name.includes(part))) return console.log(`\n=== ${name} ===`) try { await fn() } catch (e) { check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0]) } finally { for (const c of opened) await c.close().catch(() => {}) opened.clear() } } // ---------------------------------------------------------------- 夹具(虚构) const WF = 'wf0cmp0sample' const T = 'c0ffee0target00000000000000000001' const B = 'c0ffee0base0000000000000000000002' const B2 = 'c0ffee0older000000000000000000003' const U = 'c0ffee0open0000000000000000000004' const NAME = '区域销售周报' const day = (d, h = 9) => `2026-${d}T0${h - 8}:00:00Z` const runOf = (id, over = {}) => ({ id, workflow_id: WF, workflow_name: NAME, status: 'succeeded', input: {}, output: {}, error: null, usage: { wall_ms: 4000, active_ms: 4000, wait_ms: 0 }, run_class: 'formal', version: 4, version_hash: 'ab'.repeat(32), manifest_hash: 'cd'.repeat(32), manifest_seq: 40, started_by: '值班分析员', error_node_id: null, memory_scope: null, collection: null, schedule_id: 'sch0weekly', created_at: day('10-05'), started_at: day('10-05'), finished_at: '2026-10-05T01:02:00Z', ...over, }) const RUNS = { [T]: runOf(T), [B]: runOf(B, { version: 3, created_at: day('09-28'), started_at: day('09-28'), finished_at: '2026-09-28T01:02:00Z' }), [B2]: runOf(B2, { version: 3, created_at: day('09-21'), started_at: day('09-21'), finished_at: '2026-09-21T01:02:00Z' }), // 没封存的一次:能出现在下拉里,但不能选 [U]: runOf(U, { run_class: 'exploratory', version: null, manifest_hash: null, manifest_seq: null, schedule_id: null, created_at: day('10-03'), started_at: day('10-03'), finished_at: '2026-10-03T01:02:00Z' }), } const refOf = (id) => { const r = RUNS[id] return { id, workflow_name: r.workflow_name, version: r.version, run_class: r.run_class, started_at: r.started_at, finished_at: r.finished_at, schedule_id: r.schedule_id } } const REGIONS = ['青禾区', '白石区', '云杉区', '赤松区', '蓝湾区', '金穗区', '银杏区', '翠竹区', '月湖区', '星港区', '石桥区', '柳岸区'] // 各区的变化之和正好是总变化 2,531.5;占比照服务端给小数(0.4 是 40%)。服务端按变化的绝对值只给前 10 项, // 另有几项写在 note 里(ALL_ROWS 给「服务端多给了」那一节用) const DELTAS = [1012.6, 506.3, 379.7, 253.2, 126.6, 101.3, 63.3, 38.0, 25.3, 12.6, 7.6, 5.0] const ALL_ROWS = REGIONS.map((name, i) => ({ key: [name], base: 3000 + i * 100, target: 3000 + i * 100 + DELTAS[i], delta: DELTAS[i], share: Math.round(DELTAS[i] / 2531.5 * 10000) / 10000, })) const MORE_KEYS = '共 12 个维度值,列出变化最大的 10 个' // 服务端(engine/compare.py)的原话:SQL 结构变的一律带这句「为什么没拆」;无法比较的只写 reasons, // decomposition_note 为 null(新增、移除也一样)——夹具照这个形状给,检查才走得到真实的路径 const SQL_NOT_SPLIT = '取数的 SQL 或上游配置有变化,变化无法在口径与数据之间拆分' const NO_LINEAGE = '口径卡没有记录输入(生成于系统升级前),无法判断变化来源' const ADDED_WHY = '基准运行的口径卡「周报口径」里没有这个指标' const REMOVED_WHY = '这次运行的口径卡「周报口径」里没有这个指标' const metric = (over) => ({ card_id: 'caliber', card_title: '周报口径', unit: '', decimals: 1, reasons: [], decomposition: null, decomposition_note: null, inputs: [], attribution: null, delta_pct: null, ...over, }) const METRICS = [ metric({ metric_id: 'gmv', name: '销售额', unit: '元', base: 45678.5, target: 48210, base_rendered: '45,678.5元', target_rendered: '48,210.0元', delta: 2531.5, delta_rendered: '+2,531.5元', delta_pct: 5.54, classification: 'data_only', reasons: ['取数的 SQL 没有变化,查询结果有变化'], inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }], attribution: { columns: ['区域'], measure: 'amount', rows: ALL_ROWS.slice(0, 10), total_delta: 2531.5, note: MORE_KEYS } }), metric({ metric_id: 'orders', name: '订单数', unit: '单', decimals: 0, base: 1234, target: 1301, base_rendered: '1,234单', target_rendered: '1,301单', delta: 67, delta_rendered: '+67单', delta_pct: 5.43, classification: 'data_only' }), metric({ metric_id: 'aov', name: '客单价', unit: '元', base: 37.0, target: 41.5, base_rendered: '37.0元', target_rendered: '41.5元', delta: 4.5, delta_rendered: '+4.5元', delta_pct: 12.16, classification: 'caliber_and_data', reasons: ['表达式由「vars.kpi.gmv / vars.kpi.order_cnt」改为「vars.kpi.gmv / vars.kpi.paid_cnt」'], decomposition: { caliber_effect: 5.8, data_effect: -1.3, caliber_rendered: '+5.8元', data_rendered: '-1.3元' }, // 遮罩列的输入:服务端两边都不给值,另带 masked inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }, { path: 'vars.kpi.order_cnt', base: 1234, target: 1234 }, { path: 'vars.kpi.cost', base: null, target: null, masked: true }], attribution: { columns: ['区域', '渠道'], measure: 'order_cnt', total_delta: 67, note: '各区域订单数之和与指标的输入对不上,不给占比', rows: [{ key: ['青禾区', '门店'], base: 400, target: 440, delta: 40, share: null }, { key: ['白石区', '线上'], base: 300, target: 327, delta: 27, share: null }] } }), metric({ metric_id: 'refund_rate', name: '退货率', unit: '%', decimals: 2, base: 0.0235, target: 0.0198, base_rendered: '2.35%', target_rendered: '1.98%', delta: -0.0037, delta_rendered: '-0.37 个百分点', delta_pct: -15.74, classification: 'sql_changed', reasons: ['查询「退货明细」的 SQL 结构有变化:时间条件的列不同'], decomposition_note: SQL_NOT_SPLIT }), metric({ metric_id: 'visitors', name: '日均访客', unit: '人', decimals: 0, base: 5200, target: 5480, base_rendered: '5,200人', target_rendered: '5,480人', delta: 280, delta_rendered: '+280人', delta_pct: 5.38, classification: 'params_only', reasons: ['「拉取销量」只有日期参数不同'], // 归不了因:服务端给空的 rows 和一句说明 attribution: { columns: [], measure: 'visits', rows: [], total_delta: null, note: '查询结果被截断(基准运行只取回了前 500 行),不做维度归因' } }), metric({ metric_id: 'new_users', name: '新客数', unit: '人', decimals: 0, base: null, target: 320, base_rendered: '—', target_rendered: '320人', delta: null, delta_rendered: null, classification: 'added', reasons: [ADDED_WHY] }), metric({ metric_id: 'members', name: '会员数', unit: '人', decimals: 0, base: 8800, target: null, base_rendered: '8,800人', target_rendered: '—', delta: null, delta_rendered: null, classification: 'removed', reasons: [REMOVED_WHY] }), metric({ metric_id: 'margin', name: '毛利率', unit: '%', decimals: 1, base: 0.31, target: 0.33, base_rendered: '31.0%', target_rendered: '33.0%', delta: 0.02, delta_rendered: '+2.0 个百分点', delta_pct: 6.45, classification: 'incomparable', reasons: [NO_LINEAGE] }), ...[['stores', '门店数', '家', 56], ['complaints', '客诉数', '件', 12], ['sku', 'SKU 数', '个', 1500]].map(([id, name, unit, v]) => metric({ metric_id: id, name, unit, decimals: 0, base: v, target: v, base_rendered: `${v.toLocaleString('en-US')}${unit}`, target_rendered: `${v.toLocaleString('en-US')}${unit}`, delta: 0, delta_rendered: null, delta_pct: 0, classification: 'unchanged' })), ] const SUMMARY = { unchanged: 3, data_only: 2, caliber_and_data: 1, sql_changed: 1, params_only: 1, added: 1, removed: 1, incomparable: 1 } const PULL_BASE = "SELECT region, SUM(amount) AS gmv FROM sales WHERE sold_on >= '2026-09-21' AND sold_on < '2026-09-28' GROUP BY region" const PULL_TARGET = "SELECT region, SUM(amount) AS gmv FROM sales WHERE sold_on >= '2026-09-28' AND sold_on < '2026-10-05' GROUP BY region" // ordinal 照服务端从 1 数(同一节点里第几次查询);查询项的位置键(data-compare-query)是「节点:同一节点里排第几」 const QUERIES = [ { node_id: 'pull', node_title: '拉取销量', ordinal: 1, structure: 'params_only', base_sql: PULL_BASE, target_sql: PULL_TARGET, literals: [{ base: "'2026-09-21'", target: "'2026-09-28'" }, { base: "'2026-09-28'", target: "'2026-10-05'" }], base_rows: 12, target_rows: 12, base_truncated: false, target_truncated: false }, { node_id: 'refund', node_title: '退货明细', ordinal: 1, structure: 'changed', base_sql: "SELECT COUNT(*) AS n\nFROM refunds r\nWHERE r.created_at >= '2026-09-21'", target_sql: "SELECT COUNT(*) AS n\nFROM refunds r\nWHERE r.paid_at >= '2026-09-28' AND r.status = 'done'", literals: [], base_rows: 500, target_rows: 488, base_truncated: true, target_truncated: false, differences: ['时间条件的列不同:基准按 refunds.created_at,这次按 refunds.paid_at', "这次多了筛选条件 refunds.status = 'done'"] }, { node_id: 'stores', node_title: '门店清单', ordinal: 1, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM stores', target_sql: 'SELECT COUNT(*) AS n FROM stores', literals: [], base_rows: 1, target_rows: 1, base_truncated: false, target_truncated: false }, { node_id: 'pull', node_title: '拉取销量', ordinal: 2, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM visits', target_sql: 'SELECT COUNT(*) AS n FROM visits', literals: [], base_rows: 1, target_rows: 1, base_truncated: false, target_truncated: false }, { node_id: 'newbie', node_title: '新客查询', ordinal: 1, structure: 'added', base_sql: null, target_sql: "SELECT COUNT(*) AS n FROM users WHERE first_order_on >= '2026-09-28'", literals: [], base_rows: null, target_rows: 1, base_truncated: null, target_truncated: false }, { node_id: 'member', node_title: '会员查询', ordinal: 1, structure: 'removed', base_sql: 'SELECT COUNT(*) AS n FROM members', target_sql: null, literals: [], base_rows: 1, target_rows: null, base_truncated: false, target_truncated: null, problem: '基准运行的查询快照无法读取' }, ] const TEMPLATE = { base_version: 3, target_version: 4, same_graph: false, nodes: [ { node_id: 'caliber', title: '周报口径', change: 'config', keys: ['metrics'] }, { node_id: 'refund', title: '退货明细', change: 'config', keys: ['sql', 'timeout'], labels: ['SQL', '超时(秒)'] }, { node_id: 'newbie', title: '新客查询', change: 'added', keys: [] }, { node_id: 'member', title: '会员查询', change: 'removed', keys: [] }, ] } const NOTE = '基准运行中「退货明细」的查询结果已截断(只保存了前 500 行),按已保存的行比较' /** 以更早的一次(B2)为基准时多一条说明:换基准、过时答复的检查靠它认出页面上是哪一份 */ const NOTE_B2 = '以更早的一次运行为基准' function comparison(target, base) { return { target: refOf(target), base: refOf(base), summary: SUMMARY, template: TEMPLATE, metrics: METRICS, queries: QUERIES, notes: base === B2 ? [NOTE, NOTE_B2] : [NOTE] } } const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data: '口径与数据都变', sql_changed: 'SQL 结构变', params_only: '只参数变', added: '新增', removed: '移除', incomparable: '无法比较' } const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged'] // 默认基准只挑比本次先发起、成功结束并封存的运行(服务端 api/compare.py 的 pick_base):说法里都是「上一次成功的运行」 const REASON = '默认基准:同一定时任务上一次成功的运行' const REASON_WORKFLOW = '默认基准:同一工作流、同一运行类别上一次成功的运行' const FINDING = '正在查找上一次成功的运行' /** 409、422、缺基准的 404 时下拉里没有能换的运行:不叫人去上方换,照实说 */ const NO_OTHER = '同一工作流没有其他可换作基准的已封存运行' /** 服务端(api/compare.py 的 NO_BASE_TEXT)找不到默认基准时的原话 */ const NO_BASE_SERVER = '没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功结束并已封存的运行' /** * 发起本次和基准的定时任务(RUNS 里的 schedule_id)和它的触发历史:本次那一行带服务端自动对比的计数,基准是 B、不是 * 紧挨着的上一次(previous: false,假定 B 之后同一定时任务还有一次失败的;那一次不进 RUNS,换基准的下拉照旧)。 * 只给「两个入口拿同一个基准」那一段用:触发历史和运行详情「更多」里的同名入口,服务端用同一个函数挑基准 */ const SCHEDULE_ID = 'sch0weekly' const SCHEDULE = { id: SCHEDULE_ID, name: '区域销售周报 · 每周', workflow_id: WF, workflow_name: NAME, cron: '0 9 * * 1', timezone: 'Asia/Shanghai', run_class: 'formal', input_template: {}, enabled: true, overlap: 'skip', misfire_grace_s: 3600, webhook: null, next_run_at: '2026-10-12T01:00:00Z', last_fire: null, description_text: '每周一 09:00(Asia/Shanghai)', created_by: '值班分析员', created_at: day('09-01'), updated_at: day('09-01'), version: 2, } const FIRE_T = 'fire0target' const FIRES = [{ id: FIRE_T, fire_at: day('10-05'), kind: 'scheduled', status: 'started', run_id: T, run_status: 'succeeded', reason: null, notified: [], created_at: day('10-05'), compare: { base_run_id: B, previous: false, summary: SUMMARY }, }] // ---------------------------------------------------------------- 假接口 function events() { const t0 = Date.parse(day('10-05')) / 1000 return [ { seq: 1, type: 'run.started', node_id: null, ts: t0, data: { nodes: 2 } }, { seq: 2, type: 'run.finished', node_id: null, ts: t0 + 4, data: { output: {}, usage: {}, timing: { wall_ms: 4000, active_ms: 4000, wait_ms: 0 } } }, ] } const GRAPH = { nodes: [ { id: 'pull', type: 'tool', position: { x: 0, y: 0 }, data: { label: '拉取销量', config: {} } }, { id: 'caliber', type: 'metrics', position: { x: 200, y: 0 }, data: { label: '周报口径', config: {} } }, ], edges: [{ source: 'pull', target: 'caliber' }] } /** 运行对比的接口说「运行记录不存在」:带机读码(api/evidence.py 的 RUN_NOT_FOUND) */ const RUN_GONE = { status: 404, json: { detail: '运行记录不存在', code: 'run_not_found' } } /** 读一次运行(GET /api/runs/{id})时记录不存在:api/runs.py 只给 detail,不带机读码 */ const RECORD_GONE = { status: 404, json: { detail: '运行记录不存在' } } /** * 默认的假接口。opts.base(runId) / opts.compare(target, base, n) / opts.run(runId, n) / opts.list(workflowId) 可以换掉 * 对应接口的答复(返回 null 的照默认答),返回 {status, json, wait}(wait 是一个 Promise:等它落定再答,用来压住 * 某一次请求)。不在夹具里的运行一律答 404,不放行到服务端 */ function defaults(opts) { return async (method, url, n) => { const p = url.pathname if (method !== 'GET') return null if (p === '/api/runs') { const wf = url.searchParams.get('workflow_id') const own = opts.list?.(wf) if (own) return own const list = Object.values(RUNS).filter((r) => !wf || r.workflow_id === wf) .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)) return { json: list } } let m = p.match(/^\/api\/runs\/([^/]+)\/compare\/base$/) if (m) { if (opts.base) return opts.base(m[1]) return m[1] === T ? { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } } : { status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } } } m = p.match(/^\/api\/runs\/([^/]+)\/compare$/) if (m) { const base = url.searchParams.get('base') if (opts.compare) return opts.compare(m[1], base, n) return { json: comparison(m[1], base) } } m = p.match(/^\/api\/runs\/([^/]+)(\/[a-z]+)?$/) if (m && !m[2]) { const own = opts.run?.(m[1], n) if (own) return own if (!RUNS[m[1]]) return RECORD_GONE } if (m && RUNS[m[1]]) { if (!m[2]) return { json: RUNS[m[1]] } if (m[2] === '/events') return { json: events() } if (m[2] === '/graph') return { json: { graph: GRAPH, workflow_id: WF, version: RUNS[m[1]].version } } if (m[2] === '/artifacts') return { json: [] } } if (p === '/api/approvals' && url.searchParams.get('run_id')) return { json: [] } // 发起本次的定时任务和它的触发历史(只有直接打开那个定时任务的页面时才用到) if (p === `/api/schedules/${SCHEDULE_ID}`) return { json: SCHEDULE } if (p === `/api/schedules/${SCHEDULE_ID}/fires`) return { json: { items: FIRES, next_before: null } } // 查找基准期间离开运行详情去的那一页(定时任务列表):给空的列表,页面落定不靠服务端里有什么 if (p === '/api/schedules') return { json: { items: [] } } return null } } const browser = await chromium.launch({ executablePath: CHROME }) /** * 开一页:/api 的请求先问 defaults(再由 opts 换掉其中几个),答不了的 GET 放行到服务端,非 GET 一律拦掉。 * 记下每次请求(「GET /api/runs/…?base=…」)和页面报错;原生对话框都算失败。console 里的报错另记一份 * (React 的列表键冲突只打在 console 上,不抛错) */ async function open(path, { width = 1280, height = 900, theme = 'dark', waitUntil = 'networkidle', ...opts } = {}) { const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' }) opened.add(ctx) ctx.setDefaultTimeout(6000) ctx.setDefaultNavigationTimeout(30000) await ctx.addInitScript((t) => { try { localStorage.setItem('agentlab.theme', t) localStorage.removeItem('agentlab.health') } catch { /* noop */ } }, theme) const page = await ctx.newPage() const log = [] const blocked = [] const errors = [] const consoleErrors = [] page.on('pageerror', (e) => errors.push(e.message)) page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() }) page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text().slice(0, 200)) }) const answer = defaults(opts) let n = 0 await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => { const req = route.request() const url = new URL(req.url()) const key = `${req.method()} ${url.pathname}${url.search}` log.push(key) const out = await answer(req.method(), url, ++n) if (out) { if (out.wait) await out.wait return route.fulfill({ status: out.status ?? 200, json: out.json }).catch(() => {}) } if (req.method() === 'GET') return route.continue() blocked.push(key) return route.abort() }) await page.goto(`${WEB}${path}`, { waitUntil }) // 设置接口回来时会按服务端存的主题再切一次:之后照 lib/theme.ts 的 applyTheme 切到要查的这一套——切的那一帧关掉 // 过渡,不然带 transition 的按钮、摘要格还在慢慢变色,量出来、截下来的是半路的颜色 await page.evaluate((t) => { const root = document.documentElement root.classList.add('theme-switching') root.setAttribute('data-theme', t) void getComputedStyle(root).backgroundColor requestAnimationFrame(() => root.classList.remove('theme-switching')) }, theme) return { ctx, page, log, blocked, errors, consoleErrors } } /** React 的「两个子元素用了同一个键」告警 */ const keyClashes = (consoleErrors) => consoleErrors.filter((t) => t.includes('same key')) const comparePath = (target, base) => `/runs/compare?target=${target}${base ? `&base=${base}` : ''}` /** 等对比页落定:state 是 ok / error / no-target / finding… */ const settled = (page, state = 'ok') => page.locator(`[data-compare-page][data-compare-state="${state}"]`).waitFor({ timeout: 8000 }) const text = (page, sel) => page.locator(sel).first().innerText() const texts = (page, sel) => page.locator(sel).allInnerTexts() /** 有就取第一个的文字,没有给空串:不在这里干等超时,没找到的照常记成一项不通过 */ const maybeText = (loc) => loc.count().then((c) => (c ? loc.first().innerText() : '')) const has = (page, sel) => page.locator(sel).count().then((c) => c > 0) const compareHits = (log, target, base) => log.filter((k) => k === `GET /api/runs/${target}/compare?base=${base}`).length const until = async (fn, ms = 6000, step = 100) => { const end = Date.now() + ms let v = await fn() while (!v && Date.now() < end) { await new Promise((r) => setTimeout(r, step)); v = await fn() } return v } const shot = async (page, name) => { if (!SHOTS) return mkdirSync(SHOTS, { recursive: true }) await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: false }) } // ---------------------------------------------------------------- 检查 await section('从运行详情进入', async () => { const { page, log, blocked, errors } = await open(`/runs/${T}`) await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) // 走过的地址(含 pushState / replaceState):菜单要先问到基准再跳,不能先跳到只带本次的地址、再让对比页去找 const visited = [] page.on('framenavigated', (f) => { if (f === page.mainFrame()) visited.push(f.url()) }) await page.getByRole('button', { name: '更多操作' }).click() const item = page.locator('[data-menu=compare]') check('「更多」菜单里有「与上一次对比」,可点', await item.count() === 1 && (await item.innerText()).includes('与上一次对比') && !(await item.isDisabled())) await item.click() await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) await settled(page) const url = new URL(page.url()) check('跳到 /runs/compare,带着本次和基准', url.searchParams.get('target') === T && url.searchParams.get('base') === B, url.search) const iBase = log.indexOf(`GET /api/runs/${T}/compare/base`) const iCmp = log.indexOf(`GET /api/runs/${T}/compare?base=${B}`) check('先请求 compare/base,再请求 compare', iBase >= 0 && iCmp > iBase, `${iBase} → ${iCmp}`) check('compare/base 只请求一次(页面不再自己找一遍)', log.filter((k) => k.startsWith(`GET /api/runs/${T}/compare/base`)).length === 1) const compareUrls = visited.map((u) => new URL(u)).filter((u) => u.pathname === '/runs/compare') check('菜单问到基准之后才跳,一步到带基准的地址', compareUrls.length >= 1 && compareUrls.every((u) => u.searchParams.get('base') === B), compareUrls.map((u) => u.search).join(' → ')) check('写明基准是怎么选的', (await text(page, '[data-compare-reason]')).trim() === REASON, await text(page, '[data-compare-reason]').catch(() => '')) check('跳过去之后「正在查找」的提示收起', await until(async () => await page.locator('[data-toast]', { hasText: FINDING }).count() === 0)) check('没有发出写请求', blocked.length === 0, blocked.join(';')) check('页面没有报错', errors.length === 0, errors.join(';')) }) /** 查找基准期间离开去的那一页:定时任务列表。进去之后不再自己换地址(/data 会再换成 /data/databases),晚到的跳转盖不住 */ const AWAY = '/schedules' /** 那一页落定:列表读回来了(假接口给的空列表) */ const awaySettled = (page) => page.locator('[data-schedules-page="list"] [data-schedules-empty]').waitFor({ timeout: 8000 }) const NO_BASE = '同一工作流没有其他已封存的正式运行' /** * 在运行详情里点「与上一次对比」,compare/base 的答复(answer,默认找到 B)先压住不放。交回页面、放行的函数、这一请求 * 了结的 Promise(答完或者被取消,交回那个请求)、点了之后有没有挂出「正在查找」的提示,和点了之后走过的路径 * (framenavigated 连 pushState、replaceState 都算):晚到的答复哪怕只让页面在对比页一闪而过、又被别的跳转盖掉, * 走过的路径里也留着 */ async function findingBase(answer = { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } }) { let release const gate = new Promise((r) => { release = r }) const opened_ = await open(`/runs/${T}`, { base: () => ({ ...answer, wait: gate }) }) const { page, log } = opened_ const ended = new Promise((resolve) => { const done = (req) => { if (new URL(req.url()).pathname.endsWith('/compare/base')) resolve(req) } page.on('requestfinished', done) page.on('requestfailed', done) }) await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) const visited = [] page.on('framenavigated', (f) => { if (f === page.mainFrame()) visited.push(new URL(f.url()).pathname) }) await page.getByRole('button', { name: '更多操作' }).click() await page.locator('[data-menu=compare]').click() // 菜单点完就收起了:查找期间要有一条看得见的提示,接口慢的时候不像点了没反应 const noted = await until(async () => await page.locator('[data-toast]', { hasText: FINDING }).count() === 1, 3000) await until(async () => log.includes(`GET /api/runs/${T}/compare/base`), 3000) return { ...opened_, release, ended, noted, visited } } await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => { const { page, release, ended, noted, visited, errors } = await findingBase() check('点了之后有「正在查找上一次成功的运行」的提示', noted) // 改主意去了别的页面。等那一页画好、运行详情卸下之后再放行:答复晚到也不能把人拽回对比页 await page.evaluate((to) => { history.pushState({}, '', to); dispatchEvent(new PopStateEvent('popstate')) }, AWAY) await awaySettled(page) const unmounted = await until(async () => await page.locator('[data-run-detail]').count() === 0, 3000) release() // 等请求了结,再给跳转留足时间:主线程忙的时候晚到的跳转会落在固定的等待之后 await Promise.race([ended, new Promise((r) => setTimeout(r, 4000))]) const jumped = await until(async () => visited.includes('/runs/compare'), 2500) const at = new URL(page.url()) check('新页面画好之后答复才回来:一直留在新页面,走过的地址里从没有对比页', unmounted && !jumped && at.pathname === AWAY, `${visited.join(' → ')};现在 ${at.pathname}${at.search}`) check('离开之后「正在查找」的提示也收起', await page.locator('[data-toast]', { hasText: FINDING }).count() === 0) check('页面没有报错', errors.length === 0, errors.join(';')) }) await section('查找基准期间离开、立刻放行:地址已换,新页面还没画出来', async () => { // React Router 按过渡(startTransition)渲染换页:地址先换,新页面画好、运行详情卸下(这才取消请求)在后。这里只换 // 地址、先不通知路由,把这段空当撑住,答复就在这时放行——检查不靠时机碰运气。找到了基准的不跳,找不到的不弹提示 for (const [what, answer] of [['找到了基准', undefined], ['找不到基准', { status: 404, json: { detail: NO_BASE } }]]) { const { page, release, ended, noted, visited, errors } = await findingBase(answer) await page.evaluate((to) => { history.pushState({}, '', to) }, AWAY) release() const req = await Promise.race([ended, new Promise((r) => setTimeout(() => r(null), 4000))]) // 答复处理完的标志:菜单收尾时收起「正在查找」的提示 const handled = noted && await until(async () => await page.locator('[data-toast]', { hasText: FINDING }).count() === 0, 3000) const held = !!req && !req.failure() && handled && await page.locator(`[data-run-detail="${T}"]`).count() === 1 const jumped = await until(async () => visited.includes('/runs/compare'), 1000) const told = await page.locator('[data-toast]', { hasText: NO_BASE }).count() // 空当结束:通知路由,新页面画出来 await page.evaluate(() => { dispatchEvent(new PopStateEvent('popstate')) }) await awaySettled(page).catch(() => {}) const at = new URL(page.url()) check(`${what}:答复回来时运行详情还在、请求没被取消(撑住了空当)`, held, `请求${req ? (req.failure() ? '被取消' : '已答完') : '没有了结'},提示${handled ? '已收起' : '没收起'}`) check(`${what}:不跳到对比页,走过的地址里从没有对比页,留在新页面`, !jumped && at.pathname === AWAY, `${visited.join(' → ')};现在 ${at.pathname}${at.search}`) check(`${what}:不弹找不到基准的提示`, told === 0) check(`${what}:页面没有报错`, errors.length === 0, errors.join(';')) for (const c of opened) await c.close().catch(() => {}) opened.clear() } }) await section('不属于已保存工作流的运行:入口不可点并说明原因', async () => { const saved = RUNS[T] // 两种都没有 workflow_id:画布上没保存就运行的,和工作流删掉之后被置空的(名字还是原来的) for (const [what, over] of [['未保存的工作流', { workflow_name: '未保存的工作流' }], ['工作流已删除', {}]]) { RUNS[T] = { ...saved, workflow_id: null, ...over } try { const { page, log } = await open(`/runs/${T}`) await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) await page.getByRole('button', { name: '更多操作' }).click() const item = page.locator('[data-menu=compare]') const label = (await item.innerText()).replace(/\s+/g, ' ') check(`${what}:「与上一次对比」不可点,写明工作流未保存或已删除`, await item.isDisabled() && label.includes('未保存或已删除'), label) check(`${what}:不给「手动选择基准」,也不请求 compare/base`, !log.some((k) => k.includes('/compare')) && await page.getByRole('button', { name: '手动选择基准' }).count() === 0) } finally { RUNS[T] = saved } } }) await section('未封存的运行:入口不可点并说明原因', async () => { const saved = RUNS[T] RUNS[T] = { ...saved, manifest_hash: null, manifest_seq: null } try { const { page, log } = await open(`/runs/${T}`) await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) await page.getByRole('button', { name: '更多操作' }).click() const item = page.locator('[data-menu=compare]') check('未封存:「与上一次对比」不可点,写明要先封存', await item.isDisabled() && (await item.innerText()).includes('封存')) check('未封存:没有请求 compare/base', !log.some((k) => k.includes('/compare'))) } finally { RUNS[T] = saved } }) /** 在运行详情里点「更多」→「与上一次对比」,等对比页落定 */ async function viaMenu(page) { await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) await page.getByRole('button', { name: '更多操作' }).click() await page.locator('[data-menu=compare]').click() await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) await settled(page) } await section('默认基准的说法:上一次成功的运行', async () => { // 服务端只挑比本次先发起、成功结束并封存的运行:上一次失败、取消了时基准是再往前成功的那一次,「上一次运行」是错的 for (const [reason, want] of [['same_schedule', REASON], ['same_workflow', REASON_WORKFLOW]]) { const base = () => ({ json: { base_run_id: B, reason, base: refOf(B) } }) const menu = await open(`/runs/${T}`, { base }) await viaMenu(menu.page) const fromMenu = (await maybeText(menu.page.locator('[data-compare-reason]'))).trim() const self = await open(comparePath(T), { base }) await settled(self.page) const fromPage = (await maybeText(self.page.locator('[data-compare-reason]'))).trim() check(`${reason}:从运行详情进入、只带本次打开,都写「${want}」`, fromMenu === want && fromPage === want, `${fromMenu} | ${fromPage}`) const pages = [await menu.page.locator('[data-compare-page]').innerText(), await self.page.locator('[data-compare-page]').innerText()] check(`${reason}:页面上不出现「上一次运行」这类不分成败的说法`, pages.every((t) => !t.includes('上一次运行'))) check(`${reason}:页面没有报错`, ![...menu.errors, ...self.errors].length, [...menu.errors, ...self.errors].join(';')) for (const c of opened) await c.close().catch(() => {}) opened.clear() } }) await section('运行详情与触发历史的「与上一次对比」:同一个基准、同一句来历', async () => { // 两处的基准由服务端用同一个函数挑(api/compare.py 的 pick_base):上一次失败了,两处都是再往前成功的 B。两处真的 // 一致由 backend/tests/test_scheduler_compare.py 守,这里守前端两个入口都原样用服务端给的基准 const menu = await open(`/runs/${T}`) await viaMenu(menu.page) const hist = await open(`/schedules/${SCHEDULE_ID}?tab=history`) const link = hist.page.locator(`[data-fire-id="${FIRE_T}"] [data-fire-compare]`) await link.waitFor({ timeout: 10000 }) const label = (await link.locator('[data-fire-compare-label]').innerText()).trim() check('触发历史:基准不是紧挨着的上一次,链接上看得见的字写「与上一次成功的运行对比」', label === '与上一次成功的运行对比', label) await link.click() await hist.page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) await settled(hist.page) const [a, b] = [new URL(menu.page.url()), new URL(hist.page.url())] check('两个入口打开的是同一对运行(本次和基准都一样)', a.searchParams.get('target') === T && b.searchParams.get('target') === T && a.searchParams.get('base') === B && b.searchParams.get('base') === B, `${a.search} | ${b.search}`) const [ra, rb] = [(await maybeText(menu.page.locator('[data-compare-reason]'))).trim(), (await maybeText(hist.page.locator('[data-compare-reason]'))).trim()] check('两个入口写的默认基准一字不差:同一定时任务上一次成功的运行', ra === REASON && rb === REASON, `${ra} | ${rb}`) const tiles = (page) => page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) => els.map((e) => `${e.getAttribute('data-compare-class')}=${e.querySelector('[data-compare-count]')?.textContent?.trim()}`).join(' ')) const [ta, tb] = [await tiles(menu.page), await tiles(hist.page)] check('两边的变化摘要一致', !!ta && ta === tb, `${ta} | ${tb}`) check('没有发出写请求', ![...menu.blocked, ...hist.blocked].length, [...menu.blocked, ...hist.blocked].join(';')) check('页面没有报错', ![...menu.errors, ...hist.errors].length, [...menu.errors, ...hist.errors].join(';')) }) await section('两次运行与摘要', async () => { const { page, errors } = await open(comparePath(T, B)) await settled(page) const baseCard = page.locator('[data-compare-run="base"]') const targetCard = page.locator('[data-compare-run="target"]') check('基准、本次各一张卡,写着名字、版本和类别', (await baseCard.innerText()).includes(NAME) && (await baseCard.innerText()).includes('正式运行 v3') && (await targetCard.innerText()).includes('正式运行 v4'), `${(await baseCard.innerText()).replace(/\s+/g, ' ')} | ${(await targetCard.innerText()).replace(/\s+/g, ' ')}`) check('两张卡都能点进各自的运行详情', await baseCard.locator('a[data-compare-run-link]').getAttribute('href') === `/runs/${B}` && await targetCard.locator('a[data-compare-run-link]').getAttribute('href') === `/runs/${T}`) check('定时任务发起的运行标出来', (await targetCard.innerText()).includes('定时任务发起')) check('直接打开带基准的地址:不写「默认基准」', await page.locator('[data-compare-reason]').count() === 0) const tiles = await page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) => els.map((e) => [e.getAttribute('data-compare-class'), e.querySelector('[data-compare-count]')?.textContent?.trim(), e.textContent])) check('摘要按约定的八类排好', JSON.stringify(tiles.map((t) => t[0])) === JSON.stringify(ORDER), tiles.map((t) => t[0]).join(',')) check('摘要的个数和服务端给的一致', tiles.every(([k, n]) => Number(n) === SUMMARY[k]), tiles.map(([k, n]) => `${k}=${n}`).join(' ')) check('摘要的标签写对', tiles.every(([k, , t]) => t.includes(LABEL[k]))) await shot(page, 'wide-dark') check('页面没有报错', errors.length === 0, errors.join(';')) }) await section('指标表', async () => { const { page } = await open(comparePath(T, B)) await settled(page) const rows = page.locator('tr[data-compare-metric]') check('每个指标一行', await rows.count() === METRICS.length, String(await rows.count())) const chips = await rows.evaluateAll((els) => els.map((e) => [e.getAttribute('data-compare-metric'), e.getAttribute('data-class'), e.querySelector('[data-compare-chip]')?.textContent?.trim()])) check('分类标签和分类对得上', chips.every(([id, k, label]) => METRICS.find((m) => `caliber:${m.metric_id}` === id)?.classification === k && label === LABEL[k]), chips.map((c) => c.join('/')).join(' ')) const gmv = page.locator('tr[data-compare-metric="caliber:gmv"]') check('基准 → 本次、变化量用服务端渲染的写法', (await gmv.locator('[data-compare-base]').innerText()) === '45,678.5元' && (await gmv.locator('[data-compare-target]').innerText()) === '48,210.0元' && (await gmv.locator('[data-compare-delta]').innerText()) === '+2,531.5元') check('变化百分比照服务端的百分数写、带正负号(5.54 → +5.5%)', (await gmv.locator('[data-compare-pct]').innerText()) === '+5.5%', await gmv.locator('[data-compare-pct]').innerText().catch(() => '')) const refund = page.locator('tr[data-compare-metric="caliber:refund_rate"]') check('下降的百分比写负号', (await refund.locator('[data-compare-pct]').innerText()) === '-15.7%') const aov = page.locator('tr[data-compare-metric="caliber:aov"]') check('能拆分的:口径效应、数据效应并列', (await aov.locator('[data-compare-caliber]').innerText()).includes('+5.8元') && (await aov.locator('[data-compare-data]').innerText()).includes('-1.3元'), (await aov.locator('[data-compare-decomp]').innerText().catch(() => '')).replace(/\s+/g, ' ')) check('能拆分的:画了两段条形', await aov.locator('[data-compare-decomp] [data-compare-bar] > i').count() === 2) check('表头说明两者之和等于总变化', (await text(page, '[data-compare-legend]')).includes('两者之和等于总变化')) check('不能拆的:写出服务端给的原因', (await refund.locator('[data-compare-decomp-note]').innerText()) === SQL_NOT_SPLIT) // 「拆分或原因」一栏(第 5 个 td):无法比较、新增、移除的服务端只给 reasons、不给 decomposition_note, // 这一栏不能只剩一个「—」,要展开才知道为什么 const splitCell = (id) => page.locator(`tr[data-compare-metric="caliber:${id}"] td`).nth(4).innerText().then((s) => s.trim()) check('无法比较的:「拆分或原因」写出原因(服务端只放在 reasons 里)', await splitCell('margin') === NO_LINEAGE, await splitCell('margin')) check('新增、移除的:「拆分或原因」写出原因', await splitCell('new_users') === ADDED_WHY && await splitCell('members') === REMOVED_WHY, `${await splitCell('new_users')} | ${await splitCell('members')}`) check('分类的悬停说明和服务端的分类口径一致', (await aov.locator('[data-compare-chip]').getAttribute('title')).includes('小数位') && (await refund.locator('[data-compare-chip]').getAttribute('title')).includes('上游'), `${await aov.locator('[data-compare-chip]').getAttribute('title')} | ${await refund.locator('[data-compare-chip]').getAttribute('title')}`) check('新增的:基准写「—」,本次照写', (await page.locator('tr[data-compare-metric="caliber:new_users"] [data-compare-base]').innerText()) === '—' && (await page.locator('tr[data-compare-metric="caliber:new_users"] [data-compare-target]').innerText()) === '320人') check('未变的:变化写「无变化」', (await page.locator('tr[data-compare-metric="caliber:stores"] [data-compare-delta]').innerText()) === '无变化') check('只数据变、不拆分的:不编造拆分', await gmv.locator('[data-compare-decomp], [data-compare-decomp-note]').count() === 0) }) await section('展开:输入对比与维度归因', async () => { const { page, errors } = await open(comparePath(T, B)) await settled(page) const gmvBtn = page.locator('tr[data-compare-metric="caliber:gmv"] button[data-compare-expand]') check('默认收起', (await gmvBtn.getAttribute('aria-expanded')) === 'false' && await page.locator('[data-compare-detail]').count() === 0) await gmvBtn.click() const detail = page.locator('tr[data-compare-detail="caliber:gmv"]') await detail.waitFor() check('展开后标为展开、指向明细', (await gmvBtn.getAttribute('aria-expanded')) === 'true' && (await gmvBtn.getAttribute('aria-controls')) === await detail.getAttribute('id')) check('列出变化说明', (await detail.locator('[data-compare-reasons]').innerText()).includes('查询结果有变化')) const input = detail.locator('[data-compare-inputs] tr[data-compare-input="vars.kpi.gmv"]') check('输入对比:路径、两边的值,变了的标出来', (await input.innerText()).includes('45,678.5') && (await input.innerText()).includes('48,210') && (await input.getAttribute('data-changed')) !== null, (await input.innerText().catch(() => '')).replace(/\s+/g, ' ')) const attrRows = detail.locator('[data-compare-attr] tr[data-compare-attr-row]') check('维度归因只列前 10 项', await attrRows.count() === 10, String(await attrRows.count())) check('服务端说另有几项没列:照写', (await detail.locator('[data-compare-attr-note]').innerText()) === MORE_KEYS && await detail.locator('[data-compare-attr-more]').count() === 0) const first = attrRows.first() check('归因一行:键、两边的值、变化、占比', (await first.innerText()).includes('青禾区') && (await first.innerText()).includes('+1,012.6') && (await first.locator('[data-compare-share]').innerText()) === '40.0%', (await first.innerText()).replace(/\s+/g, ' ')) check('写明按哪一列归因', (await detail.locator('[data-compare-attr]').innerText()).includes('amount')) check('有占比时不写缺占比的原因', !(await detail.locator('[data-compare-attr-note]').innerText()).includes('占比')) check('写出合计变化', (await detail.locator('[data-compare-attr-total]').innerText()).includes('+2,531.5')) // 键盘:Tab 到客单价的展开按钮,回车展开 const aovBtn = page.locator('tr[data-compare-metric="caliber:aov"] button[data-compare-expand]') await aovBtn.focus() await page.keyboard.press('Enter') const aov = page.locator('tr[data-compare-detail="caliber:aov"]') await aov.waitFor() check('键盘回车也能展开', (await aovBtn.getAttribute('aria-expanded')) === 'true') check('拆分写成一句:口径效应、数据效应,两者之和等于总变化', (await aov.locator('[data-compare-decomp-sum]').innerText()).trim() === '口径效应 +5.8元,数据效应 -1.3元,两者之和等于总变化 +4.5元', await aov.locator('[data-compare-decomp-sum]').innerText().catch(() => '')) check('没有变的输入不标', (await aov.locator('tr[data-compare-input="vars.kpi.order_cnt"]').getAttribute('data-changed')) === null) const masked = aov.locator('tr[data-compare-input="vars.kpi.cost"]') check('遮罩列的输入:两边都写「已遮罩」,不标变了', (await masked.locator('td').allInnerTexts()).every((t) => t.trim() === '已遮罩') && (await masked.getAttribute('data-changed')) === null, (await masked.innerText().catch(() => '')).replace(/\s+/g, ' ')) const shares = await aov.locator('[data-compare-attr] [data-compare-share]').allInnerTexts() check('没有占比:占比一栏写「—」', shares.length === 2 && shares.every((s) => s.trim() === '—'), shares.join(',')) check('没有占比:写出原因', (await aov.locator('[data-compare-attr-note]').innerText()).includes('各区域订单数之和与指标的输入对不上')) check('多个维度列:键按列写全', (await aov.locator('[data-compare-attr] tr[data-compare-attr-row]').first().innerText()).includes('门店')) await shot(page, 'expanded-dark') // 再按一次收起 await aovBtn.press('Enter') check('再按一次收起', await page.locator('tr[data-compare-detail="caliber:aov"]').count() === 0) // 没有归因、没有输入的指标:照实说 await page.locator('tr[data-compare-metric="caliber:orders"] button[data-compare-expand]').click() const orders = page.locator('tr[data-compare-detail="caliber:orders"]') check('没有维度归因、没有输入时照实说', (await orders.locator('[data-compare-attr-none]').count()) === 1 && (await orders.locator('[data-compare-inputs-none]').count()) === 1) // 归不了因(查询结果截断):服务端给空的 rows 和一句说明,只写说明,不画空表 await page.locator('tr[data-compare-metric="caliber:visitors"] button[data-compare-expand]').click() const visitors = page.locator('tr[data-compare-detail="caliber:visitors"]') check('归不了因:写服务端的说明,不画空表', (await visitors.locator('[data-compare-attr-note]').innerText()).includes('被截断') && await visitors.locator('[data-compare-attr] table').count() === 0 && await visitors.locator('[data-compare-attr-total]').count() === 0) // 不能拆的:明细里也写「为什么没拆」——窄屏上表格那一格横着滚也看不全,明细按框的可见宽度排 await page.locator('tr[data-compare-metric="caliber:refund_rate"] button[data-compare-expand]').click() const refundDetail = page.locator('tr[data-compare-detail="caliber:refund_rate"]') await refundDetail.waitFor() check('不能拆的:明细里也写出原因', (await maybeText(refundDetail.locator('[data-compare-detail-note]'))).trim() === SQL_NOT_SPLIT, await maybeText(refundDetail.locator('[data-compare-detail-note]'))) // 服务端只对只数据变、只参数变、口径与数据都变的做维度归因:别的类不是「没有明细」,是有意不做,写明为什么 check('SQL 结构变的:写明为什么不做维度归因', (await maybeText(refundDetail.locator('[data-compare-attr-none]'))).includes('不做维度归因'), await maybeText(refundDetail.locator('[data-compare-attr-none]'))) await page.locator('tr[data-compare-metric="caliber:margin"] button[data-compare-expand]').click() const marginNone = page.locator('tr[data-compare-detail="caliber:margin"] [data-compare-attr-none]') await marginNone.waitFor() check('无法比较的:写明为什么不做维度归因', (await maybeText(marginNone)).includes('无法判断变化来源'), await maybeText(marginNone)) check('页面没有报错', errors.length === 0, errors.join(';')) }) await section('归因超过 10 项时只列前 10 项', async () => { const { page } = await open(comparePath(T, B), { compare: (t, b) => { const data = comparison(t, b) return { json: { ...data, metrics: data.metrics.map((m) => (m.metric_id === 'gmv' ? { ...m, attribution: { ...m.attribution, rows: ALL_ROWS, note: null } } : m)) } } } }) await settled(page) await page.locator('tr[data-compare-metric="caliber:gmv"] button[data-compare-expand]').click() const detail = page.locator('tr[data-compare-detail="caliber:gmv"]') check('只列 10 项', await detail.locator('[data-compare-attr] tr[data-compare-attr-row]').count() === 10) check('写明另有几项没列', (await detail.locator('[data-compare-attr-more]').innerText()).includes('另有 2 项')) }) await section('摘要筛选', async () => { const { page } = await open(comparePath(T, B)) await settled(page) const tile = page.locator('[data-compare-summary] [data-compare-class="sql_changed"]') await tile.click() check('点一类:只剩这一类的指标,按钮标为按下', await page.locator('tr[data-compare-metric]').count() === 1 && (await tile.getAttribute('aria-pressed')) === 'true' && await page.locator('tr[data-compare-metric="caliber:refund_rate"]').count() === 1) check('写明正在筛选', (await text(page, '[data-compare-filter]')).includes('SQL 结构变')) await tile.press('Enter') check('再按一次(键盘)回到全部', await page.locator('tr[data-compare-metric]').count() === METRICS.length && (await tile.getAttribute('aria-pressed')) === 'false') await page.locator('[data-compare-summary] [data-compare-class="unchanged"]').click() await page.locator('[data-compare-filter-clear]').click() check('「显示全部」清掉筛选', await page.locator('tr[data-compare-metric]').count() === METRICS.length) }) await section('查询', async () => { const { page } = await open(comparePath(T, B)) await settled(page) const qs = page.locator('[data-compare-query]') check('每条查询一项', await qs.count() === QUERIES.length, String(await qs.count())) const refund = page.locator('[data-compare-query="refund:1"]') check('结构变:标「结构变」', (await refund.getAttribute('data-structure')) === 'changed' && (await refund.locator('[data-compare-structure]').innerText()).trim() === '结构变') const baseSql = refund.locator('[data-compare-sql="base"]') const targetSql = refund.locator('[data-compare-sql="target"]') const [bb, tb] = [await baseSql.boundingBox(), await targetSql.boundingBox()] check('结构变:两条 SQL 并排(基准在左、本次在右)', !!bb && !!tb && Math.abs(bb.y - tb.y) < 2 && bb.x + bb.width <= tb.x + 1, JSON.stringify({ bb, tb })) check('两边都是原文', (await baseSql.innerText()).includes('r.created_at') && (await targetSql.innerText()).includes("r.status = 'done'")) const del = (await baseSql.locator('mark[data-diff="del"]').allInnerTexts()).join(' ') const add = (await targetSql.locator('mark[data-diff="add"]').allInnerTexts()).join(' ') check('高亮基准独有的部分', del.includes('created_at') && del.includes("'2026-09-21'") && !del.includes('SELECT'), del) check('高亮本次独有的部分', add.includes('paid_at') && add.includes("r.status = 'done'") && !add.includes('FROM'), add) check('相同的部分不高亮', !(await baseSql.locator('mark').allInnerTexts()).join(' ').includes('refunds')) check('截断照实标', (await refund.locator('[data-compare-rows]').innerText()).includes('已截断')) check('结构变:列出服务端说的差在哪', JSON.stringify(await refund.locator('[data-compare-differences] li').allInnerTexts()) === JSON.stringify(QUERIES[1].differences)) check('快照读不出来:照服务端的说明写在那一条上', (await page.locator('[data-compare-query="member:1"] [data-compare-query-problem]').innerText()) === '基准运行的查询快照无法读取') const pull = page.locator('[data-compare-query="pull:1"]') const lits = await pull.locator('[data-compare-literals] li').allInnerTexts() check('只参数变:列出变化的参数', lits.length === 2 && lits[0].includes("'2026-09-21'") && lits[0].includes("'2026-09-28'") && lits[1].includes("'2026-10-05'"), lits.join(' | ')) check('只参数变:默认不并排摆两条 SQL', await pull.locator('[data-compare-sql]').count() === 0) const same = page.locator('[data-compare-query="stores:1"]') check('相同的:默认不展开 SQL', (await same.locator('[data-compare-structure]').innerText()).trim() === '相同' && await same.locator('[data-compare-sql]').count() === 0) await same.locator('button[data-compare-sql-toggle]').click() check('相同的:点开能看 SQL', (await same.locator('[data-compare-sql]').first().innerText()).includes('FROM stores')) check('同一节点的第二条查询写「第 2 条」', (await page.locator('[data-compare-query="pull:2"] [data-compare-nth]').innerText()).includes('第 2 条') && (await pull.locator('[data-compare-nth]').innerText()).includes('第 1 条')) check('只有一条查询的节点不写第几条', await refund.locator('[data-compare-nth]').count() === 0) const added = page.locator('[data-compare-query="newbie:1"]') check('新增、移除的查询各只有一边的 SQL', await added.locator('[data-compare-sql="target"]').count() === 1 && await added.locator('[data-compare-sql="base"]').count() === 0 && await page.locator('[data-compare-query="member:1"] [data-compare-sql="base"]').count() === 1) const head = (await page.locator('[data-compare-queries] header').innerText()).replace(/\s+/g, ' ') check('查询的个数按「条」写(和「第 N 条」一致)', head.includes(`${QUERIES.length} 条`), head) check('「结构变」的悬停说明也算上数据源不同、SQL 读不出来', (await refund.locator('[data-compare-structure]').getAttribute('title')).includes('数据源'), await refund.locator('[data-compare-structure]').getAttribute('title')) await refund.scrollIntoViewIfNeeded() await shot(page, 'queries-dark') }) await section('查询的边界:照服务端配对、读快照的实际输出', async () => { // 服务端配对(engine/compare.py 的 _pair):配上的一对用本次的 node_id / ordinal,落单的基准查询用基准的。Agent 在基准里 // 查了两次、这次只查一次且和基准的第 2 次同构时,配上的一对是 agent·1,落单的基准第 1 次也是 agent·1——(node_id, ordinal) // 不唯一。一边的 SQL 拿不到时服务端标「结构变」,那一边的 SQL 是 null const AGENT = [ { node_id: 'agent', node_title: '分析助手', ordinal: 1, structure: 'same', base_sql: 'SELECT channel, SUM(amount) AS gmv FROM sales GROUP BY channel', target_sql: 'SELECT channel, SUM(amount) AS gmv FROM sales GROUP BY channel', literals: [], base_rows: 3, target_rows: 3, base_truncated: false, target_truncated: false, differences: [] }, { node_id: 'agent', node_title: '分析助手', ordinal: 1, structure: 'removed', base_sql: 'SELECT region, SUM(amount) AS gmv FROM sales GROUP BY region', target_sql: null, literals: [], base_rows: 12, target_rows: null, base_truncated: false, target_truncated: null, differences: [] }, { node_id: 'pull', node_title: '拉取销量', ordinal: 1, structure: 'changed', base_sql: null, target_sql: 'SELECT region, amount FROM sales WHERE sold_on >= 20260928', literals: [], base_rows: null, target_rows: 12, base_truncated: null, target_truncated: false, differences: ['基准运行的查询快照无法读取,无法比较 SQL'], problem: '基准运行的查询快照无法读取' }, ] const { page, errors, consoleErrors } = await open(comparePath(T, B), { compare: (t, b) => ({ json: { ...comparison(t, b), queries: AGENT } }) }) await settled(page) const items = await page.locator('[data-compare-query]').evaluateAll((els) => els.map((e) => ({ key: e.getAttribute('data-compare-query'), structure: e.getAttribute('data-structure'), nth: e.querySelector('[data-compare-nth]')?.textContent?.trim() ?? null, }))) const agent = items.filter((i) => i.key?.startsWith('agent')) check('同一节点的两条查询 (node_id, ordinal) 相同:各写「第 1 条」「第 2 条」,按列表的先后数', JSON.stringify(agent.map((i) => i.nth)) === JSON.stringify(['第 1 条', '第 2 条']), JSON.stringify(items)) check('每一项的位置键都不重复', new Set(items.map((i) => i.key)).size === items.length, items.map((i) => i.key).join(',')) check('没有 React 列表键冲突的告警', keyClashes(consoleErrors).length === 0, keyClashes(consoleErrors)[0] ?? '') const pull = page.locator('[data-compare-query="pull:1"]') const baseText = (await maybeText(pull.locator('[data-compare-sql="base"]'))).trim() const marks = await pull.locator('mark[data-diff]').count() check('一边的 SQL 读不出来:不做逐词比较,另一边不标「独有」', marks === 0, `${marks} 处标记`) check('一边的 SQL 读不出来:这一边写明无法读取', baseText.includes('无法读取'), baseText) check('一边的 SQL 读不出来:另一边照原文显示', (await maybeText(pull.locator('[data-compare-sql="target"]'))).includes('sold_on >= 20260928')) check('页面没有报错', errors.length === 0, errors.join(';')) }) await section('SQL 逐词比较:注释里的撇号不吞掉后面的语句', async () => { const { page } = await open(comparePath(T, B)) await settled(page) // 纯函数:直接 import Vite 现编的 sqlDiff.ts。模型写的英文注释里常有 it's、user's const out = await page.evaluate(async () => { const { diffSql } = await import('/src/pages/runs/compare/sqlDiff.ts') const changed = (parts) => parts.filter((p) => p.changed).map((p) => p.text.trim()) const line = diffSql("SELECT a -- it's a note\nFROM t WHERE x = 1", "SELECT a -- it's a note\nFROM t WHERE x = 2") const block = diffSql("SELECT a /* user's pick */ FROM t WHERE x = 1", "SELECT a /* user's pick */ FROM t WHERE x = 2") const edited = diffSql('SELECT a -- old note\nFROM t', 'SELECT a -- new note\nFROM t') return { line: [changed(line.base), changed(line.target)], block: [changed(block.base), changed(block.target)], edited: [changed(edited.base), changed(edited.target)] } }) check('行注释里有撇号:只标出真正不同的 1 和 2', JSON.stringify(out.line) === JSON.stringify([['1'], ['2']]), JSON.stringify(out.line)) check('块注释里有撇号:只标出真正不同的 1 和 2', JSON.stringify(out.block) === JSON.stringify([['1'], ['2']]), JSON.stringify(out.block)) check('注释改了:整段注释算一处不同,后面没改的语句不标', JSON.stringify(out.edited) === JSON.stringify([['-- old note'], ['-- new note']]), JSON.stringify(out.edited)) }) await section('模板与说明', async () => { const { page } = await open(comparePath(T, B)) await settled(page) check('模板:版本 v3 → v4', (await text(page, '[data-compare-versions]')).includes('v3') && (await text(page, '[data-compare-versions]')).includes('v4')) const node = page.locator('[data-compare-node="refund"]') check('变化的节点:名字、变化类型、配置项(用服务端给的叫法,键名在悬停提示里)', (await node.getAttribute('data-change')) === 'config' && (await node.innerText()).includes('退货明细') && (await node.innerText()).includes('配置变更') && JSON.stringify(await node.locator('[data-compare-key]').allInnerTexts()) === JSON.stringify(['SQL', '超时(秒)']) && JSON.stringify(await node.locator('[data-compare-key]').evaluateAll((els) => els.map((e) => e.getAttribute('title')))) === JSON.stringify(['sql', 'timeout'])) check('没给叫法的配置项照写键名', JSON.stringify(await page.locator('[data-compare-node="caliber"] [data-compare-key]').allInnerTexts()) === JSON.stringify(['metrics'])) check('新增、移除的节点写明', (await text(page, '[data-compare-node="newbie"]')).includes('新增节点') && (await text(page, '[data-compare-node="member"]')).includes('移除节点')) check('说明照实列出', JSON.stringify(await texts(page, '[data-compare-notes] li')) === JSON.stringify([NOTE])) }) await section('换基准、互换', async () => { const { page, log } = await open(comparePath(T, B)) await settled(page) const picker = page.locator('select[data-compare-base-picker]') const options = await picker.locator('option').evaluateAll((els) => els.map((o) => [o.value, o.disabled, o.textContent])) check('下拉列出同一工作流的其他运行(不含本次)', options.some(([v]) => v === B) && options.some(([v]) => v === B2) && !options.some(([v]) => v === T), options.map((o) => o[0].slice(0, 12)).join(',')) const open_ = options.find(([v]) => v === U) check('没封存的那次不能选,写明原因', !!open_ && open_[1] === true && open_[2].includes('未封存'), JSON.stringify(open_)) check('当前基准是选中的', await picker.inputValue() === B) check('列运行用的是运行列表接口(按工作流)', log.some((k) => k.startsWith('GET /api/runs?') && k.includes(`workflow_id=${WF}`))) await picker.selectOption(B2) await until(async () => new URL(page.url()).searchParams.get('base') === B2) await until(async () => (await texts(page, '[data-compare-notes] li')).includes(NOTE_B2)) check('换基准:地址跟着变', new URL(page.url()).searchParams.get('base') === B2) check('换基准:重新请求对比', compareHits(log, T, B2) === 1) check('换基准:页面换成新的一份', (await texts(page, '[data-compare-notes] li')).includes(NOTE_B2)) check('换基准:基准卡跟着换', (await text(page, '[data-compare-run="base"]')).includes('9/21')) const lists = () => log.filter((k) => k.startsWith('GET /api/runs?') && k.includes(`workflow_id=${WF}`)).length const listsBefore = lists() await page.locator('button[data-compare-swap]').click() await until(async () => new URL(page.url()).searchParams.get('target') === B2) await until(async () => compareHits(log, B2, T) === 1) const u = new URL(page.url()) check('互换:本次和基准对调', u.searchParams.get('target') === B2 && u.searchParams.get('base') === T, u.search) check('互换:重新请求对比', compareHits(log, B2, T) === 1) await settled(page) check('互换:两张卡跟着对调', (await page.locator('[data-compare-run="target"] a[data-compare-run-link]').getAttribute('href')) === `/runs/${B2}`) check('互换:同一工作流的运行列表不重取,下拉照常可用', lists() === listsBefore && await page.locator('select[data-compare-base-picker]').isEnabled() && await page.locator('select[data-compare-base-picker]').inputValue() === T, `${listsBefore} → ${lists()}`) }) await section('换基准:失败、取消的运行写明状态,默认基准只取成功结束的', async () => { // 默认基准跳过失败、取消的运行(服务端 pick_base),手动选不限:下拉和基准卡上得看得出哪次没成功,人读懂「上一次成功的 // 运行」之后才挑得出成功的那一次。两次都封存过,都能选 const F = 'c0ffee0failed0000000000000000005' const C = 'c0ffee0cancel0000000000000000006' RUNS[F] = runOf(F, { status: 'failed', version: 3, created_at: day('10-01'), started_at: day('10-01'), finished_at: '2026-10-01T01:01:00Z' }) RUNS[C] = runOf(C, { status: 'cancelled', version: 3, created_at: day('10-02'), started_at: day('10-02'), finished_at: '2026-10-02T01:01:00Z' }) try { const { page, errors } = await open(comparePath(T, F)) await settled(page) const picker = page.locator('select[data-compare-base-picker]') await until(async () => (await picker.locator('option').count()) > 2) const options = Object.fromEntries(await picker.locator('option').evaluateAll((els) => els.map((o) => [o.value, { text: o.textContent ?? '', disabled: o.disabled }]))) check('下拉:失败的那次写「失败」,照样能选', !!options[F]?.text.includes('· 失败') && !options[F].disabled, JSON.stringify(options[F])) check('下拉:取消的那次写「已取消」,照样能选', !!options[C]?.text.includes('· 已取消') && !options[C].disabled, JSON.stringify(options[C])) check('下拉:成功结束的不另写状态', !!options[B] && !/失败|已取消|已完成/.test(options[B].text), options[B]?.text) const card = page.locator('[data-compare-run="base"]') check('下拉下面的说明写明默认基准只取成功结束的运行', (await card.innerText()).includes('默认基准只取成功结束的运行'), (await card.innerText()).replace(/\s+/g, ' ')) const status = card.locator('[data-compare-run-status]') check('基准卡:失败的那次标出「失败」', await status.count() === 1 && (await status.innerText()).trim() === '失败', await status.innerText().catch(() => '没有')) check('本次卡:成功结束的不标状态', await page.locator('[data-compare-run="target"] [data-compare-run-status]').count() === 0) await picker.selectOption(B) await until(async () => new URL(page.url()).searchParams.get('base') === B) await settled(page) check('换成成功结束的那次:基准卡不再标状态', await until(async () => await card.locator('[data-compare-run-status]').count() === 0)) check('页面没有报错', errors.length === 0, errors.join(';')) } finally { delete RUNS[F] delete RUNS[C] } }) await section('过时的答复不盖掉新的', async () => { let release const gate = new Promise((r) => { release = r }) const { page, log } = await open(comparePath(T, B), { waitUntil: 'domcontentloaded', compare: async (target, base) => { if (base === B) return { json: comparison(target, base), wait: gate } return { json: comparison(target, base) } } }) await page.locator('[data-compare-page]').waitFor({ timeout: 10000 }) const loading = await until(() => has(page, '[data-compare-loading][aria-busy="true"]')) check('加载中:标为忙', loading) await until(async () => (await page.locator('select[data-compare-base-picker] option').count()) > 1) await page.locator('select[data-compare-base-picker]').selectOption(B2) await settled(page) release() await page.waitForTimeout(600) check('慢的旧答复回来后,页面仍是新基准的那一份', (await texts(page, '[data-compare-notes] li')).includes(NOTE_B2) && new URL(page.url()).searchParams.get('base') === B2) check('两次都发出去了', compareHits(log, T, B) === 1 && compareHits(log, T, B2) === 1) }) /** 「其他错误」那一节:第一次对比回 500,之后照常 */ let flakyCalls = 0 await section('出错与找不到基准', async () => { // 409 照服务端(api/compare.py)的原话和机读码:封存核对不一致的原因里已经写了去记录页点「核对」 const broken = '作为基准的运行的封存核对不一致:封存后有事件被修改、删除或插入,不能用于对比。可在记录页点「核对」查看核对结果' const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: broken, code: 'compare_seal_broken' } }) }) await settled(sealed.page, 'error') const e409 = sealed.page.locator('[data-compare-error="409"]') // 叫人去上方换基准的那句下一步,要等下拉读到同一工作流的运行、知道有没有能换的才写 await until(() => has(sealed.page, '[data-compare-error="409"] [data-compare-error-hint]'), 3000) const t409 = (await e409.innerText().catch(() => '')).replace(/\s+/g, ' ') check('409 封存核对不一致:标题照实写,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()) === '无法对比:封存核对不一致' && t409.includes(broken), t409) check('409:下一步只指向界面上有的入口(不提「封存凭证条」,可在上方更换基准)', !t409.includes('封存凭证条') && t409.includes('上方更换基准'), t409) check('409:给出去两次运行详情核对的入口', await e409.locator(`a[href="/runs/${B}"]`).count() === 1 && await e409.locator(`a[href="/runs/${T}"]`).count() === 1) check('409:两张运行卡仍在,下拉仍能换基准', await sealed.page.locator('[data-compare-run="base"]').count() === 1 && await sealed.page.locator('select[data-compare-base-picker]').isEnabled()) check('409:不显示摘要、指标', await sealed.page.locator('[data-compare-summary], tr[data-compare-metric]').count() === 0) // 本次自己没封存:换基准也没用,服务端的原因里已经写了要等运行结束,不再叫人去换基准 const savedT = RUNS[T] RUNS[T] = { ...savedT, manifest_hash: null, manifest_seq: null } try { const open409 = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: '这次运行尚未结束并封存:对比只用封存的运行记录,运行结束后才能对比', code: 'compare_unsealed' } }) }) await settled(open409.page, 'error') const u409 = open409.page.locator('[data-compare-error="409"]') const tu = (await u409.innerText().catch(() => '')).replace(/\s+/g, ' ') check('409 本次未封存:标题写「尚未结束并封存」,不叫人去换基准', (await u409.locator('[data-compare-error-title]').innerText()).includes('尚未结束并封存') && !tu.includes('更换基准'), tu) } finally { RUNS[T] = savedT } const mismatch = '两次运行不属于同一个工作流(「区域销售周报」与「门店日报」),不能对比' const other = await open(comparePath(T, B), { compare: () => ({ status: 422, json: { detail: mismatch, code: 'compare_mismatch' } }) }) await settled(other.page, 'error') const e422 = other.page.locator('[data-compare-error="422"]') await until(() => has(other.page, '[data-compare-error="422"] [data-compare-error-hint]'), 3000) check('422:写明无法对比,带上原因和下一步', (await e422.innerText()).includes('两次运行不属于同一个工作流') && (await e422.innerText()).includes('同一工作流的另一次运行'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' ')) // 下拉里除了本次和当前的基准,没有别的已封存的运行:换基准这条路走不通,409、422 都不再叫人去上方换,照实说没有。 // 409 的基准和本次同属一个工作流(下拉里有它,可它正是出问题的那一次),另一次没封存;422 的基准属于别的工作流 for (const [code, answer, list] of [ ['409', { status: 409, json: { detail: broken, code: 'compare_seal_broken' } }, [RUNS[T], RUNS[B], RUNS[U]]], ['422', { status: 422, json: { detail: mismatch, code: 'compare_mismatch' } }, [RUNS[T], RUNS[U]]], ]) { const only = await open(comparePath(T, B), { compare: () => answer, list: () => ({ json: list }) }) await settled(only.page, 'error') const said = await until(async () => (await maybeText(only.page.locator(`[data-compare-error="${code}"] [data-compare-error-hint]`))).trim(), 3000) const box = (await only.page.locator(`[data-compare-error="${code}"]`).innerText()).replace(/\s+/g, ' ') check(`${code}、下拉里没有能换的:不叫人去上方换基准,写明没有其他可换作基准的已封存运行`, said === NO_OTHER && !box.includes('上方'), box) } const gone = await open(comparePath(T, B), { compare: () => RUN_GONE }) await settled(gone.page, 'error') check('404:写明运行记录不存在', (await gone.page.locator('[data-compare-error="404"]').innerText()).includes('运行记录不存在')) const flaky = await open(comparePath(T, B), { compare: () => (flakyCalls++ === 0 ? { status: 500, json: { detail: '对比服务暂时不可用' } } : { json: comparison(T, B) }) }) await settled(flaky.page, 'error') await flaky.page.locator('[data-compare-error] button', { hasText: '重试' }).click() await settled(flaky.page) check('其他错误:给「重试」,重试后正常显示', await flaky.page.locator('tr[data-compare-metric]').count() === METRICS.length) // 只带本次:页面自己找基准,找不到时写原因,下拉照样能选。标题只说没有默认基准:失败、取消但封存了的运行不当默认 // 基准,却照样能手动选,写成「没有可作为基准的运行」就和下面「可在上方选择」打架 const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } }) }) await settled(lone.page, 'error') const noBase = lone.page.locator('[data-compare-error="no-base"]') check('找不到基准:标题写「没有默认基准」,后面跟服务端的原因', (await noBase.locator('[data-compare-error-title]').innerText()).trim() === '没有默认基准' && (await noBase.innerText()).includes(NO_BASE_SERVER), (await noBase.innerText()).replace(/\s+/g, ' ')) check('找不到基准:仍能手动选', await lone.page.locator('select[data-compare-base-picker]').isEnabled()) check('找不到基准、下拉里有能选的:说可在上方选', await until(async () => (await noBase.innerText()).includes('可在上方选择')), (await noBase.innerText()).replace(/\s+/g, ' ')) check('找不到基准:不说「没有可作为基准的运行」这类和「可在上方选择」矛盾的话', !/可作为基准的运行|没有可以对比/.test(await noBase.innerText()), (await noBase.innerText()).replace(/\s+/g, ' ')) // 同一工作流只有一次没封存的:下拉里没有能选的,不能再叫人去上方选 const bare = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行', code: 'compare_no_base' } }), list: () => ({ json: [RUNS[T], RUNS[U]] }) }) await settled(bare.page, 'error') const bareBox = bare.page.locator('[data-compare-error="no-base"]') await until(async () => (await bare.page.locator('select[data-compare-base-picker] option').count()) > 1) const tb = (await bareBox.innerText()).replace(/\s+/g, ' ') check('找不到基准、下拉里没有能选的:不说「可在上方选择」,写明同一工作流没有已封存的运行', !tb.includes('可在上方') && tb.includes('已封存'), tb) await lone.page.locator('select[data-compare-base-picker]').selectOption(B2) await settled(lone.page) check('手动选了之后照常对比', new URL(lone.page.url()).searchParams.get('base') === B2 && compareHits(lone.log, T, B2) === 1) const missing = await open(comparePath(T), { base: () => RUN_GONE, run: (id) => (id === T ? RECORD_GONE : null) }) await settled(missing.page, 'error') const gone404 = missing.page.locator('[data-compare-error="404"]') check('找基准时本次就不存在:写运行记录不存在、链接可能已失效,给去记录页的入口', await gone404.count() === 1 && (await missing.page.locator('[data-compare-error-title]').innerText()) === '运行记录不存在' && (await gone404.innerText()).includes('链接可能已失效') && await gone404.locator('a[href="/runs"]').count() === 1, (await gone404.innerText().catch(() => '')).replace(/\s+/g, ' ')) // 本次的记录不存在:不知道是哪个工作流、列不出候选,重试几次都一样。换基准的位置照实说,不给「重试」;地址里带了 // 基准的(对比回 404)也一样,整页都没有注定失败的「重试」 const goneWithBase = await open(comparePath(T, B), { compare: () => RUN_GONE, run: (id) => (id === T ? RECORD_GONE : null) }) await settled(goneWithBase.page, 'error') for (const [what, p] of [['只带本次', missing.page], ['带着基准', goneWithBase.page]]) { const area = p.locator('[data-compare-run="base"]') const noted = await until(() => has(p, '[data-compare-run="base"] [data-compare-pick-note="target-gone"]'), 3000) check(`本次不存在(${what}):换基准的位置写明列不出同一工作流的运行,整页不给「重试」`, noted && (await area.innerText()).includes('本次运行的记录不存在') && await p.getByRole('button', { name: '重试' }).count() === 0, (await area.innerText()).replace(/\s+/g, ' ')) } // 只带本次、找得到:自己补上基准,写明怎么选的 const auto = await open(comparePath(T)) await settled(auto.page) check('只带本次:自己找到基准、补进地址', new URL(auto.page.url()).searchParams.get('base') === B && (await text(auto.page, '[data-compare-reason]')).trim() === REASON) // 运行详情里找不到基准:提示原因,给「手动选择基准」 const menu = await open(`/runs/${T}`, { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行' } }) }) await menu.page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) await menu.page.getByRole('button', { name: '更多操作' }).click() await menu.page.locator('[data-menu=compare]').click() const toast = menu.page.locator('[data-toast]', { hasText: '同一工作流没有其他已封存的正式运行' }) check('运行详情里找不到基准:提示原因、留在原页', await until(() => toast.count().then((c) => c === 1)) && new URL(menu.page.url()).pathname === `/runs/${T}`) await toast.getByRole('button', { name: '手动选择基准' }).click() await menu.page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) check('「手动选择基准」去到对比页,只带本次', new URL(menu.page.url()).searchParams.get('target') === T && !new URL(menu.page.url()).searchParams.get('base')) const none = await open('/runs/compare') await settled(none.page, 'no-target') check('什么都没带:说明从哪里进来', (await text(none.page, '[data-compare-page]')).includes('与上一次对比')) }) await section('基准运行读不到(删了、链接过期)', async () => { const GONE = 'c0ffee0gone0000000000000000000009' const { page, log, errors } = await open(comparePath(T, GONE), { compare: (t, b) => (b === GONE ? { status: 404, json: { detail: '作为对比基准的运行记录不存在', code: 'run_not_found' } } : { json: comparison(t, b) }) }) await settled(page, 'error') const picker = page.locator('select[data-compare-base-picker]') await until(async () => (await picker.locator('option').count()) > 1) const current = await picker.evaluate((s) => ({ value: s.value, text: s.selectedOptions[0]?.textContent ?? '' })) // 受控下拉的值对不上任何选项时浏览器会选中第一项:显示成另一次运行被选中,而选它又不触发 change check('下拉如实显示当前的基准(短 id,写明读不到),不显示成别的运行', current.value === GONE && current.text.includes('不存在'), JSON.stringify(current)) const box = (await page.locator('[data-compare-error="404"]').innerText()).replace(/\s+/g, ' ') check('缺的是基准:提示在上方另选基准,不叫人回记录页', box.includes('在上方选择另一次运行作为基准') && !box.includes('链接可能已失效'), box) await picker.selectOption(B) await settled(page) check('另选一次之后照常对比', new URL(page.url()).searchParams.get('base') === B && compareHits(log, T, B) === 1) check('页面没有报错', errors.length === 0, errors.join(';')) // 同一工作流只剩本次和一次没封存的:下拉里没有能换的,缺基准时同样不叫人去上方另选 const bare = await open(comparePath(T, GONE), { compare: () => ({ status: 404, json: { detail: '作为对比基准的运行记录不存在', code: 'run_not_found' } }), list: () => ({ json: [RUNS[T], RUNS[U]] }) }) await settled(bare.page, 'error') const said = await until(async () => (await maybeText(bare.page.locator('[data-compare-error="404"] [data-compare-error-hint]'))).trim(), 3000) const bareBox = (await bare.page.locator('[data-compare-error="404"]').innerText()).replace(/\s+/g, ' ') check('缺的是基准、下拉里没有能换的:不叫人去上方另选,写明没有其他可换作基准的已封存运行', said === NO_OTHER && !bareBox.includes('上方'), bareBox) }) await section('本次的运行记录读不到:换基准不一直停在「正在读取」', async () => { let calls = 0 const { page, errors } = await open(comparePath(T, B), { run: (id) => (id === T && calls++ === 0 ? { status: 500, json: { detail: '服务暂时不可用' } } : null) }) await settled(page) const failed = page.locator('[data-compare-run="base"] [data-compare-pick-failed]') check('读不到本次的记录、不知道是哪个工作流:写明读不到同一工作流的运行,不显示加载中', await until(() => failed.count().then((c) => c === 1), 3000) && await page.locator('select[data-compare-base-picker]').count() === 0, (await page.locator('[data-compare-run="base"]').innerText()).replace(/\s+/g, ' ')) await failed.getByRole('button', { name: '重试' }).click() const picker = page.locator('select[data-compare-base-picker]') check('点「重试」重新读本次的记录,之后下拉照常可用', await until(async () => (await picker.count()) === 1 && await picker.isEnabled(), 4000) && await picker.inputValue() === B) check('页面没有报错', errors.length === 0, errors.join(';')) }) await section('不属于已保存工作流的运行:对比页的说明前后一致', async () => { const saved = RUNS[T] RUNS[T] = { ...saved, workflow_id: null, workflow_name: '未保存的工作流' } try { // 只带本次:服务端找不到基准(这种运行没有可比的) const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } }) }) await settled(lone.page, 'error') await until(async () => (await lone.page.locator('[data-compare-run="base"]').innerText()).includes('未保存或已删除'), 3000) const card = (await lone.page.locator('[data-compare-run="base"]').innerText()).replace(/\s+/g, ' ') const box = (await lone.page.locator('[data-compare-error]').innerText()).replace(/\s+/g, ' ') check('基准卡:写明工作流未保存或已删除(不说「临时构建」)', card.includes('未保存或已删除') && !card.includes('临时构建'), card) check('说明框:写明原因,不叫人去上方选', box.includes('未保存或已删除') && !box.includes('上方'), box) // 地址里带了基准:服务端回 422 const other = await open(comparePath(T, B), { compare: () => ({ status: 422, json: { detail: '这次运行不属于任何已保存的工作流,无法确认两次运行是同一个工作流,不能对比', code: 'compare_mismatch' } }) }) await settled(other.page, 'error') await until(async () => (await other.page.locator('[data-compare-error]').innerText()).includes('未保存或已删除'), 3000) const b422 = (await other.page.locator('[data-compare-error]').innerText()).replace(/\s+/g, ' ') check('422:不再叫人选另一次运行作为基准', b422.includes('未保存或已删除') && !b422.includes('作为基准') && !b422.includes('上方'), b422) } finally { RUNS[T] = saved } }) await section('空的对比', async () => { const { page } = await open(comparePath(T, B), { compare: (t, b) => ({ json: { ...comparison(t, b), metrics: [], queries: [], notes: [], summary: Object.fromEntries(ORDER.map((k) => [k, 0])), template: { base_version: 4, target_version: 4, same_graph: true, nodes: [] } } }) }) await settled(page) check('没有指标:照实说', await page.locator('[data-compare-empty="metrics"]').count() === 1) check('没有查询:照实说', await page.locator('[data-compare-empty="queries"]').count() === 1) check('执行图相同:照实说', await page.locator('[data-compare-same-graph]').count() === 1) check('没有说明时不留空的「说明」一节', await page.locator('[data-compare-notes]').count() === 0) check('摘要全是 0,不能点', (await page.locator('[data-compare-summary] [data-compare-class]:not([disabled])').count()) === 0) }) /** 对比页自己有没有横向滚动(表格、SQL 在自己的框里横向滚动不算),以及它的右边有没有出视口 */ const overflow = (page) => page.evaluate(() => { const root = document.querySelector('[data-compare-page]') if (!root) return { root: 99, right: 99 } const r = root.getBoundingClientRect() const wide = [...root.querySelectorAll('*')].filter((el) => { const b = el.getBoundingClientRect() return b.width > 0 && b.right > r.right + 1 && !el.closest('[data-compare-scroll]') }).map((el) => el.tagName + (el.getAttribute('data-compare-run') ?? '')) return { root: root.scrollWidth - root.clientWidth, right: Math.round(r.right - innerWidth), wide: wide.slice(0, 3) } }) await section('窄屏', async () => { for (const theme of ['dark', 'light']) { const { page, errors } = await open(comparePath(T, B), { width: 390, height: 844, theme }) await settled(page) let o = await overflow(page) check(`390px(${theme}):页面不横向溢出`, o.root <= 1 && o.right <= 0 && !o.wide.length, JSON.stringify(o)) await page.locator('tr[data-compare-metric="caliber:gmv"] button[data-compare-expand]').click() await page.locator('tr[data-compare-metric="caliber:aov"] button[data-compare-expand]').click() o = await overflow(page) check(`390px(${theme}):展开归因后也不横向溢出`, o.root <= 1 && o.right <= 0 && !o.wide.length, JSON.stringify(o)) const scroller = await page.evaluate(() => { const box = document.querySelector('[data-compare-metrics] [data-compare-scroll]') return box ? { sw: box.scrollWidth, cw: box.clientWidth, focusable: box.tabIndex >= 0 } : null }) check(`390px(${theme}):指标表在自己的框里横向滚动,键盘可聚焦`, !!scroller && scroller.sw > scroller.cw && scroller.focusable, JSON.stringify(scroller)) const refund = page.locator('[data-compare-query="refund:1"]') const [bb, tb] = [await refund.locator('[data-compare-sql="base"]').boundingBox(), await refund.locator('[data-compare-sql="target"]').boundingBox()] check(`390px(${theme}):两条 SQL 上下排、都在屏幕里`, !!bb && !!tb && tb.y >= bb.y + bb.height - 1 && bb.x >= 0 && bb.x + bb.width <= 390, JSON.stringify({ bb, tb })) o = await overflow(page) check(`390px(${theme}):查询区不横向溢出`, o.root <= 1 && !o.wide.length, JSON.stringify(o)) await shot(page, `narrow-${theme}`) check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';')) } }) /** 两个颜色的对比度(WCAG):颜色是 getComputedStyle 给的 rgb / rgba / color(srgb …),半透明的叠在底色上 */ function contrast(fg, bg, base) { const parse = (c) => { const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/) if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])] const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/) return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null } const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3])) const lum = ([r, g, b]) => { const f = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 } return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b) } const b0 = parse(base); const b1 = parse(bg); const f0 = parse(fg) if (!b0 || !b1 || !f0) return 0 const under = over(b1, b0.slice(0, 3)) const a = lum(over(f0, under)); const b = lum(under) return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) } await section('深浅色可读', async () => { for (const theme of ['dark', 'light']) { const { page } = await open(comparePath(T, B), { theme }) await settled(page) const colors = await page.evaluate(() => { /** 往上找第一个不透明的底色 */ const baseOf = (el) => { for (let p = el.parentElement; p; p = p.parentElement) { const c = getComputedStyle(p).backgroundColor if (c && !/rgba\(0, 0, 0, 0\)|transparent/.test(c)) return c } return getComputedStyle(document.body).backgroundColor } const pick = (sel) => { const el = document.querySelector(sel) if (!el) return null const s = getComputedStyle(el) return { fg: s.color, bg: s.backgroundColor, base: baseOf(el) } } return { chips: Object.fromEntries([...document.querySelectorAll('tr[data-compare-metric] [data-compare-chip]')] .map((el) => [el.closest('tr').getAttribute('data-class'), (() => { const s = getComputedStyle(el) return { fg: s.color, bg: s.backgroundColor, base: baseOf(el) } })()])), del: pick('[data-compare-sql="base"] mark[data-diff="del"]'), add: pick('[data-compare-sql="target"] mark[data-diff="add"]'), note: pick('[data-compare-decomp-note]'), } }) const low = Object.entries(colors.chips).map(([k, c]) => [k, contrast(c.fg, c.bg, c.base)]).filter(([, r]) => r < 4.5) check(`${theme}:分类标签的文字对比度都 ≥ 4.5`, Object.keys(colors.chips).length === 8 && low.length === 0, low.map(([k, r]) => `${k} ${r.toFixed(2)}`).join(',')) for (const k of ['del', 'add', 'note']) { const c = colors[k] const r = c ? contrast(c.fg, c.bg, c.base) : 0 check(`${theme}:${{ del: '基准独有的高亮', add: '本次独有的高亮', note: '不能拆分的原因' }[k]}对比度 ≥ 4.5`, r >= 4.5, c ? `${r.toFixed(2)}(${c.fg} on ${c.bg} / ${c.base})` : '没找到') } await shot(page, `wide-${theme}`) } }) await browser.close() // 段过滤一段都没对上:报成没通过,不让一个写错的过滤词顶着「全部通过」 if (!ran) failed++ console.log(failed ? `\n✗ 运行对比:${ran ? `${failed} 项未通过` : '一项都没有检查'}` : '\n✓ 运行对比:全部通过') process.exit(failed ? 1 : 0)