运行对比页(前端):两次运行的指标变化按数据、口径、模板与 SQL 拆开讲

- 运行详情「更多」加「与上一次对比」:先问 compare/base 找默认基准再跳到对比页;没封存、还在运行的不给点;
  找不到基准时留在原页,提示服务端给的原因,并可去对比页手动选择基准
- 对比页(/runs/compare?target=…&base=…):顶部两次运行(名字、时间、类别与版本、定时来源、未封存标记,可点进
  各自详情)、互换、换基准下拉(运行列表接口按工作流取,未封存的列出但不能选);摘要条八类的个数照服务端的
  summary,点一类只看这一类;指标表用服务端渲染值,能严格拆分的并列口径效应和数据效应、画两截条形,并写明两者
  之和等于总变化,不能拆的写服务端给的原因;展开看变化说明、输入对比、维度归因前 10 项(没有占比时写原因);
  查询区标结构状态,只参数变列出字面量,结构变并排两条 SQL 逐词标出各自独有的部分;模板区写版本、变化的节点和
  配置项;说明照实列出
- 加载、找基准、409(未封存或封存核对不一致)、422(不是同一工作流)、404、找不到基准、空对比都写清楚;请求按
  「本次 + 基准」取,换基准、互换时丢掉过时的答复,开发模式的 StrictMode 下同一份对比也只请求一次
- 窄屏表格在自己的框里横向滚动(指标名一栏钉住,展开的明细按可见宽度排),深浅色下分类标签、差异高亮对比度都
  在 4.5 以上
- check-compare.mjs 换成真检查:page.route 假接口覆盖入口、摘要、分类、拆分、归因、SQL 并排、换基准与互换、
  过时答复、各类错误、窄屏和深浅色
This commit is contained in:
ThearchyHelios
2026-10-05 08:43:32 +08:00
parent 26595b70f7
commit e6c77ea4ee
16 changed files with 2310 additions and 18 deletions
+724 -6
View File
@@ -1,8 +1,726 @@
// 运行对比的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。
// 运行对比的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行
// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。
// 运行对比页(/runs/compare?target=…&base=…,pages/runs/ComparePage.tsx)的回归检查。
//
// 守的是这些「看着没坏、其实用不了」的地方:运行详情「更多」里没有入口、点了不先找基准就乱跳;摘要的个数和服务端
// 给的对不上;分类标签张冠李戴;口径效应和数据效应只摆两个数,不说两者之和等于总变化(或者自己算了一遍、和服务端
// 渲染的写法对不上);不能拆的一声不吭;展开后归因只给数没有占比时不说为什么;SQL 结构变了只给一条、差异不标;
// 换了基准、互换了却不重新请求,或者慢的旧答复盖掉新的;409(未封存、封存核对不一致)、422(不是同一工作流)、
// 404(找不到基准)只弹一句通用报错;窄屏(390px)被表格撑出横向滚动;浅色下分类标签、差异高亮看不清。
//
// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、
// compare/base、compare 都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。
// 运行对比的两个接口只读,这里的假答复严格照接口约定的字段名和形状。
//
// node scripts/check-compare.mjs
console.log('=== 运行对比 ===')
console.log(' ✓ 占位:功能开发中')
console.log('\n✓ 运行对比:占位通过')
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-compare.mjs
// COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。
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
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
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%)
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 ATTR_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 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: 0.0554, classification: 'data_only',
reasons: ['口径与 SQL 均未改动,变化来自数据'],
inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }],
attribution: { columns: ['区域'], measure: 'amount', rows: ATTR_ROWS, total_delta: 2531.5, note: null } }),
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: 0.0543, 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: 0.1216, classification: 'caliber_and_data',
reasons: ['口径卡「周报口径」中客单价的计算式有改动'],
decomposition: { caliber_effect: 5.8, data_effect: -1.3, caliber_rendered: '+5.8元', data_rendered: '-1.3元' },
inputs: [{ path: 'vars.kpi.gmv', base: 45678.5, target: 48210 }, { path: 'vars.kpi.order_cnt', base: 1234, target: 1234 }],
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: -0.1574, classification: 'sql_changed',
reasons: ['所依据的查询「退货明细」的筛选条件有改动'],
decomposition_note: '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分' }),
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: 0.0538, classification: 'params_only',
reasons: ['「拉取销量」只有日期参数不同'] }),
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: ['本次新增的指标'] }),
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: ['本次没有这个指标'] }),
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: 0.0645, classification: 'incomparable',
reasons: ['基准运行的口径卡没有记录输入'], decomposition_note: '基准运行的口径卡没有记录输入,无法拆分' }),
...[['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"
const QUERIES = [
{ node_id: 'pull', node_title: '拉取销量', ordinal: 0, 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: 0, 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 },
{ node_id: 'stores', node_title: '门店清单', ordinal: 0, 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: 1, 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: 0, 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: 0, 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 },
]
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'] },
{ 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']
const REASON = '默认基准:同一定时任务的上一次运行'
// ---------------------------------------------------------------- 假接口
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' }] }
/**
* 默认的假接口。opts.base(runId) / opts.compare(target, base, n) 可以换掉对应接口的答复,返回 {status, json, wait}
* (wait 是一个 Promise:等它落定再答,用来压住某一次请求)
*/
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 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: '没有找到可作为基准的运行' } }
}
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 && 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: [] }
return null
}
}
const browser = await chromium.launch({ executablePath: CHROME })
/**
* 开一页:/api 的请求先问 defaults(再由 opts 换掉其中几个),答不了的 GET 放行到服务端,非 GET 一律拦掉。
* 记下每次请求(「GET /api/runs/…?base=…」)和页面报错;原生对话框都算失败
*/
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 = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
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 }
}
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 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 })
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)
check('写明基准是怎么选的', (await text(page, '[data-compare-reason]')).trim() === REASON, await text(page, '[data-compare-reason]').catch(() => ''))
check('没有发出写请求', blocked.length === 0, blocked.join(';'))
check('页面没有报错', errors.length === 0, errors.join(';'))
})
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
}
})
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('变化百分比带正负号(0.0554 → +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 结构有改动,口径不同,不做拆分')
check('无法比较的也写原因', (await page.locator('tr[data-compare-metric="caliber:margin"] [data-compare-decomp-note]').innerText())
.includes('没有记录输入'))
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-more]').innerText()).includes('2'))
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]').count() === 0)
// 键盘: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 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)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
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:0"]')
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('已截断'))
const pull = page.locator('[data-compare-query="pull:0"]')
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:0"]')
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:1"] [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:0"]')
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:0"] [data-compare-sql="base"]').count() === 1)
await refund.scrollIntoViewIfNeeded()
await shot(page, 'queries-dark')
})
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', 'timeout']))
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'))
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}`)
})
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 () => {
const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: '基准运行的封存核对不一致,无法对比' } }) })
await settled(sealed.page, 'error')
const e409 = sealed.page.locator('[data-compare-error="409"]')
check('409:写明未封存或封存核对不一致,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()).includes('封存')
&& (await e409.innerText()).includes('基准运行的封存核对不一致,无法对比'), (await e409.innerText().catch(() => '')).replace(/\s+/g, ' '))
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 other = await open(comparePath(T, B), { compare: () => ({ status: 422, json: { detail: '两次运行不属于同一个工作流' } }) })
await settled(other.page, 'error')
const e422 = other.page.locator('[data-compare-error="422"]')
check('422:写明无法对比,带上原因和下一步', (await e422.innerText()).includes('两次运行不属于同一个工作流')
&& (await e422.innerText()).includes('同一工作流'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' '))
const gone = await open(comparePath(T, B), { compare: () => ({ status: 404, json: { detail: '运行记录不存在' } }) })
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: '同一工作流没有其他已封存的正式运行' } }) })
await settled(lone.page, 'error')
const noBase = lone.page.locator('[data-compare-error="no-base"]')
check('找不到基准:写明原因', (await noBase.locator('[data-compare-error-title]').innerText()).includes('未找到可作为基准的运行')
&& (await noBase.innerText()).includes('同一工作流没有其他已封存的正式运行'))
check('找不到基准:仍能手动选', await lone.page.locator('select[data-compare-base-picker]').isEnabled())
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 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 { 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:0"]')
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()
console.log(failed ? `\n✗ 运行对比:${failed} 项未通过` : '\n✓ 运行对比:全部通过')
process.exit(failed ? 1 : 0)