mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:31:59 +00:00
aa2b93f587
- 「不属于已保存工作流」那一段伪造的 404 说明原先抄的是旧的 NO_BASE_TEXT(更早结束并已封存),改成服务端现在的 「上一次成功的运行……比这次先发起、成功结束并已封存的运行」,假答复和接口约定保持一致 - FireHistory 的 CompareLink 去掉 data-fire-compare-previous:检查只看看得见的字,不靠这个标记
1212 lines
89 KiB
JavaScript
1212 lines
89 KiB
JavaScript
// 运行对比页(/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 = '同一工作流没有其他可换作基准的已封存运行'
|
||
|
||
/**
|
||
* 发起本次和基准的定时任务(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: '没有找到可作为基准的运行' } }
|
||
}
|
||
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
|
||
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 () => {
|
||
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: '同一工作流没有其他已封存的正式运行' } }) })
|
||
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())
|
||
check('找不到基准、下拉里有能选的:说可在上方选', await until(async () => (await noBase.innerText()).includes('可在上方选择')),
|
||
(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: '没有找到可以对比的上一次成功的运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、比这次先发起、成功结束并已封存的运行',
|
||
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)
|