mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:01:58 +00:00
运行对比页复核修正(第 1 轮):照服务端的实际输出改查询编号、无法比较的原因、读不到的基准和未保存工作流的说法
- 查询区不再拿服务端的 (node_id, ordinal) 当键和编号:配上的一对取本次的序号、落单的基准查询取基准的序号, Agent 换了查询先后时两者会撞上,React 报键冲突,「第 N 条」重复或缺号。改按同一节点在列表里出现的先后编号, 键是「节点:第几」。 - 「拆分或原因」一栏:无法比较、新增、移除的指标,服务端只把原因放在 reasons 里,这一栏原来只剩「—」,现在写 reasons 的第一句。不能拆的「为什么没拆」也写进展开的明细,窄屏上那一格横着滚也看不全。服务端有意不做维度归因 的几类,写明为什么不做。检查夹具改成服务端的实际形状(decomposition_note 为 null):原来那一项是靠编出来的字段 通过的。 - 换基准下拉:当前的基准读不到时单列一项,写短 id 和「记录不存在」或「无法读取」,不再显示成另一次运行被选中、 选它又没反应。本次的记录读不到时写明原因并给重试,不再一直停在「正在读取」。运行卡读取失败也给重试,记录不存在 的照实写。 - 不属于已保存工作流的运行(没保存过,或工作流已删除):「与上一次对比」不可点并写明原因,对比页也不再叫人去上方 选基准。服务端对这两种运行都不比。 - 错误说明:409 按机读码分「尚未结束并封存」和「封存核对不一致」,去掉界面上没有的「封存凭证条」。基准不存在时 提示在上方另选。找不到默认基准时,下拉里有能选的运行才说「可在上方选择」。 - 菜单查找基准超过 300 毫秒就挂一条提示;离开运行详情时取消请求,晚到的答复不再把人拽到对比页。 - 一边的 SQL 拿不到时不做逐词比较,另一边不再整条标成「独有」。SQL 注释整段算一个词,注释里的撇号不再吞掉后面的 语句。 - 分类、结构的悬停说明照服务端的分类口径改写;查询按「条」计数,和「第 N 条」一致。 - check-compare 按服务端的实际输出补上这些场景,并加段过滤 COMPARE_ONLY;check-all 里 compare 的耗时估计按实测 改为 45 秒。
This commit is contained in:
@@ -2751,8 +2751,11 @@ export const RUN_COMPARE_TEXT = {
|
||||
// 入口:运行详情的「更多」菜单
|
||||
menu: '与上一次对比',
|
||||
menuSealedOnly: '运行结束并封存后才能对比',
|
||||
/** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */
|
||||
menuNoWorkflow: '工作流未保存或已删除,无法对比',
|
||||
menuFinding: '正在查找上一次运行…',
|
||||
pickManually: '手动选择基准',
|
||||
retry: '重试',
|
||||
// 顶部的两次运行
|
||||
base: '基准',
|
||||
target: '本次',
|
||||
@@ -2763,6 +2766,7 @@ export const RUN_COMPARE_TEXT = {
|
||||
unsealed: '未封存',
|
||||
unsealedHint: '这次运行没有封存清单,不能参与对比',
|
||||
runFailed: '无法读取这次运行',
|
||||
runMissing: '运行记录不存在',
|
||||
noBase: '未选择',
|
||||
reason: {
|
||||
same_schedule: '默认基准:同一定时任务的上一次运行',
|
||||
@@ -2773,25 +2777,37 @@ export const RUN_COMPARE_TEXT = {
|
||||
pickLoading: '正在读取同一工作流的运行…',
|
||||
pickFailed: '无法读取同一工作流的运行',
|
||||
pickNone: '同一工作流没有其他运行',
|
||||
pickUnsaved: '临时构建的工作流没有其他运行可选',
|
||||
pickUnsaved: '工作流未保存或已删除,没有可对比的运行',
|
||||
pickPlaceholder: '选择一次运行作为基准',
|
||||
/** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */
|
||||
pickOption: (time: string, cls: string, id: string, sealed: boolean) =>
|
||||
`${time} · ${cls} · ${id}${sealed ? '' : '(未封存)'}`,
|
||||
/** 当前的基准读不到时下拉里的那一项(短 id):如实显示选的是哪一次 */
|
||||
pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`,
|
||||
// 页面状态
|
||||
noTarget: '未指定要对比的运行',
|
||||
noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次运行',
|
||||
toRuns: '前往记录',
|
||||
finding: '正在查找基准运行…',
|
||||
loading: '正在对比两次运行…',
|
||||
errSealed: '无法对比:有运行未封存或封存核对未通过',
|
||||
errSealedHint: '两次运行都需已结束并封存,且封存核对一致。可在运行详情的封存凭证条上核对',
|
||||
// 对比失败:标题说是哪一种,服务端的原因原样跟在后面,最后一句下一步只写界面上真有的入口,服务端已经写了的不再重复
|
||||
errSealed: '无法对比:有运行未封存或封存核对不一致',
|
||||
errUnsealed: '无法对比:有运行尚未结束并封存',
|
||||
errSealBroken: '无法对比:封存核对不一致',
|
||||
/** 409 时不知道(或者知道不是)本次的问题:服务端的原因里写着是哪一次 */
|
||||
errSealedHint: '若是基准运行的问题,可在上方更换基准',
|
||||
errMismatch: '无法对比这两次运行',
|
||||
errMismatchHint: '请选择同一工作流的另一次运行作为基准',
|
||||
errMismatchHint: '可在上方选择同一工作流的另一次运行作为基准',
|
||||
/** 本次没有 workflow_id:哪一次都比不了,不给下一步 */
|
||||
errUnsaved: '无法对比:本次运行的工作流未保存或已删除',
|
||||
errMissing: '运行记录不存在',
|
||||
errMissingHint: '链接可能已失效,请从记录页重新打开',
|
||||
/** 本次的记录读得到、对比却回 404:缺的是基准 */
|
||||
errBaseMissingHint: '可在上方选择另一次运行作为基准',
|
||||
errNoBase: '未找到可作为基准的运行',
|
||||
errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准',
|
||||
/** 下拉里一次能选的都没有(只有本次、或者都没封存) */
|
||||
errNoBaseNone: '同一工作流还没有其他已封存的运行',
|
||||
viewBase: '查看基准运行',
|
||||
viewTarget: '查看本次运行',
|
||||
// 摘要
|
||||
@@ -2807,11 +2823,12 @@ export const RUN_COMPARE_TEXT = {
|
||||
incomparable: '无法比较',
|
||||
unchanged: '未变',
|
||||
} as Record<string, string>,
|
||||
// 照服务端(engine/compare.py)的分类口径写:口径变了含小数位、单位、显示格式,SQL 结构变含上游节点改配置
|
||||
classHint: {
|
||||
data_only: '口径与 SQL 都没有改动,变化来自数据',
|
||||
caliber_and_data: '口径卡的计算式有改动,数据也变了;能严格拆分的分别给出口径效应和数据效应',
|
||||
sql_changed: '所依据的查询 SQL 结构有改动,口径不同,不做拆分',
|
||||
params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)不同',
|
||||
caliber_and_data: '口径(计算式、口径版本、小数位、单位或显示格式)有改动,数据可能也有变化;能严格拆分的分别给出口径效应和数据效应',
|
||||
sql_changed: '取数的 SQL 或上游节点的配置有改动,无法在口径与数据之间拆分',
|
||||
params_only: '所依据的查询 SQL 结构相同,只有参数(如日期)或运行输入不同',
|
||||
added: '本次有、基准没有的指标',
|
||||
removed: '基准有、本次没有的指标',
|
||||
incomparable: '缺少比较所需的信息,无法判断变化来自哪里',
|
||||
@@ -2824,6 +2841,8 @@ export const RUN_COMPARE_TEXT = {
|
||||
metrics: '指标',
|
||||
metricsEmpty: '两次运行的口径卡上都没有指标',
|
||||
count: (n: number) => `${formatNumber(n)} 个`,
|
||||
/** 查询按「条」数,和「第 N 条」一致 */
|
||||
queryCount: (n: number) => `${formatNumber(n)} 条`,
|
||||
colMetric: '指标',
|
||||
colBase: '基准',
|
||||
colTarget: '本次',
|
||||
@@ -2850,6 +2869,17 @@ export const RUN_COMPARE_TEXT = {
|
||||
attrMore: (n: number) => `另有 ${formatNumber(n)} 项未列出`,
|
||||
attrTotal: (value: string) => `合计变化 ${value}`,
|
||||
attrNone: '没有可按维度归因的明细',
|
||||
/**
|
||||
* 服务端只对只数据变、只参数变、口径与数据都变的指标做维度归因(两边的行说的是同一回事)。别的类不是「没有明细」,
|
||||
* 是有意不做:写明为什么
|
||||
*/
|
||||
attrSkipped: {
|
||||
sql_changed: '取数的 SQL 或上游配置有改动,两边的行不能直接对照,不做维度归因',
|
||||
incomparable: '无法判断变化来源,不做维度归因',
|
||||
added: '基准没有这个指标,不做维度归因',
|
||||
removed: '本次没有这个指标,不做维度归因',
|
||||
unchanged: '数值没有变化,不做维度归因',
|
||||
} as Record<string, string>,
|
||||
/** 服务端没给占比、也没说为什么时的说法 */
|
||||
attrNoShare: '无法计算各项占比',
|
||||
colKey: '维度',
|
||||
@@ -2867,7 +2897,8 @@ export const RUN_COMPARE_TEXT = {
|
||||
structureHint: {
|
||||
same: 'SQL 完全相同',
|
||||
params_only: 'SQL 结构相同,只有字面量不同',
|
||||
changed: 'SQL 结构不同:表、连接、筛选条件、分组或聚合有改动',
|
||||
// 服务端在只换了数据源、有一边的 SQL 读不出来时也标「结构变」
|
||||
changed: 'SQL 结构或数据源不同,或有一边的 SQL 无法读取',
|
||||
added: '本次新增的查询,基准中没有',
|
||||
removed: '基准中有、本次没有的查询',
|
||||
} as Record<string, string>,
|
||||
@@ -2883,6 +2914,8 @@ export const RUN_COMPARE_TEXT = {
|
||||
baseSql: '基准的 SQL',
|
||||
targetSql: '本次的 SQL',
|
||||
bothSql: '两次相同的 SQL',
|
||||
/** 快照读不出来、又拿不到执行时的参数:这一边没有 SQL,不能拿空串去和另一边比 */
|
||||
sqlUnreadable: '这一边的 SQL 无法读取',
|
||||
onlyBase: '基准独有',
|
||||
onlyTarget: '本次独有',
|
||||
showSql: '查看 SQL',
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { RunComparison } from '../../types'
|
||||
import { useCandidates, useComparison, useDefaultBase, useRunRecord } from './compare/data'
|
||||
import { comparePath, type CompareVia } from './compare/entry'
|
||||
import { Metrics } from './compare/Metrics'
|
||||
import { compareErrorKind } from './compare/model'
|
||||
import { CompareError } from './compare/parts'
|
||||
import { compareErrorKind, isGone } from './compare/model'
|
||||
import { CompareError, type CompareErrorContext } from './compare/parts'
|
||||
import { Queries } from './compare/Queries'
|
||||
import { RunPair } from './compare/RunPair'
|
||||
import { Summary } from './compare/Summary'
|
||||
@@ -51,6 +51,8 @@ export function ComparePage() {
|
||||
const workflowId = targetRun.state === 'ok' ? targetRun.data?.workflow_id : lastWorkflow.current
|
||||
const candidates = useCandidates(workflowId)
|
||||
const cmp = useComparison(target, base)
|
||||
// 不属于已保存的工作流:没保存过,或者工作流删掉之后 workflow_id 被置空(名字还是原来的)。服务端两种都不比
|
||||
const unsaved = targetRun.state === 'ok' && !workflowId
|
||||
|
||||
const go = (t: string, b: string) => navigate(comparePath(t, b), { replace: true })
|
||||
|
||||
@@ -59,6 +61,16 @@ export function ComparePage() {
|
||||
else if (!base) state = finder.state === 'error' ? 'error' : 'finding'
|
||||
else state = cmp.state === 'ok' ? 'ok' : cmp.state === 'error' ? 'error' : 'loading'
|
||||
|
||||
// 错误说明的下一步按页面已经知道的挑(parts.tsx 的 errorCopy):下拉里没有能选的就不叫人去上方选
|
||||
const errorContext: CompareErrorContext = {
|
||||
targetKnown: targetRun.state === 'ok',
|
||||
targetUnsaved: unsaved,
|
||||
targetUnsealed: targetRun.state === 'ok' && !targetRun.data?.manifest_hash,
|
||||
targetGone: targetRun.state === 'error' && isGone(targetRun.error),
|
||||
baseGone: baseRun.state === 'error' && isGone(baseRun.error),
|
||||
pickable: candidates.state === 'ok' ? (candidates.data ?? []).some((r) => r.id !== target && !!r.manifest_hash) : null,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader icon={<GitCompareArrows size={13} />} title={RUN_COMPARE_TEXT.title} subtitle={RUN_COMPARE_TEXT.hint} />
|
||||
@@ -70,7 +82,7 @@ export function ComparePage() {
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-6xl flex-col gap-3 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<RunPair
|
||||
target={target} base={base} targetRun={targetRun} baseRun={baseRun} candidates={candidates}
|
||||
unsaved={targetRun.state === 'ok' && !workflowId} reason={reason}
|
||||
unsaved={unsaved} reason={reason}
|
||||
onPick={(b) => go(target, b)} onSwap={() => { if (base) go(base, target) }}
|
||||
/>
|
||||
{state === 'finding' && <Pending text={RUN_COMPARE_TEXT.finding} />}
|
||||
@@ -80,7 +92,7 @@ export function ComparePage() {
|
||||
kind={compareErrorKind(base ? cmp.error : finder.error, !base)}
|
||||
error={base ? cmp.error : finder.error}
|
||||
onRetry={base ? cmp.reload : finder.reload}
|
||||
target={target} base={base}
|
||||
target={target} base={base} context={errorContext}
|
||||
/>
|
||||
)}
|
||||
{state === 'ok' && cmp.data && <Comparison key={`${target}|${base}`} data={cmp.data} />}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { NONE } from '../../../lib/format'
|
||||
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
|
||||
import type { RunCompareAttribution, RunCompareDecomposition, RunCompareMetric } from '../../../types'
|
||||
import { Block, ClassChip } from './parts'
|
||||
import { changeText, metricKey, sameValue, shareText, signedNumber, valueText } from './model'
|
||||
import { changeText, metricKey, sameValue, shareText, signedNumber, splitReason, valueText } from './model'
|
||||
|
||||
/** 维度归因列前几项:后端按变化从大到小排好,多的只写还有几项 */
|
||||
const ATTR_TOP = 10
|
||||
@@ -20,7 +20,7 @@ const STICKY = 'sticky left-0 z-[1] min-w-[9em] shadow-[inset_-1px_0_0_var(--bor
|
||||
|
||||
/**
|
||||
* 指标表:基准 → 本次、变化量和百分比(渲染值照服务端给的写)、分类、能严格拆分的口径效应和数据效应(并列的数加一段
|
||||
* 两截的条形),不能拆的写服务端给的原因。每行能展开:变化说明、输入对比、维度归因前 10 项。
|
||||
* 两截的条形),不能拆、比不了的写原因(splitReason)。每行能展开:变化说明、输入对比、维度归因前 10 项。
|
||||
*
|
||||
* 表格比窄屏宽,在自己的框里横向滚动(框能用键盘聚焦),页面本身不横向溢出。展开的明细贴着框的左边、宽度等于框的
|
||||
* 可见宽度:窄屏上不用横着滚也读得全
|
||||
@@ -104,6 +104,7 @@ function MetricRows({ m, k, detailId, open, onToggle }: {
|
||||
m: RunCompareMetric; k: string; detailId: string; open: boolean; onToggle: () => void
|
||||
}) {
|
||||
const pct = m.delta_pct != null && m.delta_pct !== 0 ? changeText(m.delta_pct) : null
|
||||
const why = m.decomposition ? null : splitReason(m)
|
||||
return (
|
||||
<Fragment>
|
||||
<tr className={clsx('border-b align-top last:border-0', open && 'bg-hover')} data-compare-metric={k} data-class={m.classification}>
|
||||
@@ -129,9 +130,10 @@ function MetricRows({ m, k, detailId, open, onToggle }: {
|
||||
<td className="px-2.5 py-2">
|
||||
{m.decomposition ? (
|
||||
<Split d={m.decomposition} total={totalText(m)} />
|
||||
) : m.decomposition_note ? (
|
||||
<p className="max-w-[280px] text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-decomp-note="">
|
||||
{m.decomposition_note}
|
||||
) : why ? (
|
||||
<p className="max-w-[280px] text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-decomp-note=""
|
||||
data-from={m.decomposition_note ? 'note' : 'reasons'}>
|
||||
{why}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-faint">{NONE}</span>
|
||||
@@ -199,7 +201,11 @@ function Split({ d, total }: { d: RunCompareDecomposition; total: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 展开的明细:变化说明、拆分写成一句话、输入对比、维度归因。贴着滚动框的左边,宽度等于框的可见宽度 */
|
||||
/**
|
||||
* 展开的明细:变化说明、拆分写成一句话(不能拆的写服务端说的为什么)、输入对比、维度归因。贴着滚动框的左边,宽度
|
||||
* 等于框的可见宽度:窄屏上表格里「拆分或原因」那一格比钉在左边的指标名右侧剩下的地方宽,横着滚到哪儿都看不全,
|
||||
* 这里读得全
|
||||
*/
|
||||
function MetricDetail({ m }: { m: RunCompareMetric }) {
|
||||
return (
|
||||
<div className="sticky left-0 box-border grid gap-4 px-3 py-3 lg:grid-cols-2" style={{ width: 'var(--compare-view, 100%)' }}>
|
||||
@@ -212,15 +218,17 @@ function MetricDetail({ m }: { m: RunCompareMetric }) {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{m.decomposition && (
|
||||
{m.decomposition ? (
|
||||
<p className="text-xs leading-relaxed text-fg" data-compare-decomp-sum="">
|
||||
{T.splitSum(m.decomposition.caliber_rendered, m.decomposition.data_rendered, totalText(m))}
|
||||
</p>
|
||||
)}
|
||||
) : m.decomposition_note ? (
|
||||
<p className="text-xs leading-relaxed text-dim [overflow-wrap:anywhere]" data-compare-detail-note="">{m.decomposition_note}</p>
|
||||
) : null}
|
||||
<Inputs m={m} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Attribution a={m.attribution} />
|
||||
<Attribution a={m.attribution} classification={m.classification} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -270,14 +278,17 @@ function Inputs({ m }: { m: RunCompareMetric }) {
|
||||
/**
|
||||
* 维度归因:按键对齐两边查询结果的行,列前 10 项的键、两边的值、变化和占比。占比只在度量列之和等于指标的输入时才有,
|
||||
* 没有时那一栏写「—」,下面照服务端的说明写原因(没给说明时写一句通用的)。归不了因的(查询结果截断、度量列遮罩、
|
||||
* 快照读不出来)服务端给空的 rows 和一句说明:不画空表,只写这句话
|
||||
* 快照读不出来)服务端给空的 rows 和一句说明:不画空表,只写这句话。服务端有意不做归因的几类(SQL 结构变、无法
|
||||
* 比较、新增、移除、未变)写为什么不做,不说成「没有明细」
|
||||
*/
|
||||
function Attribution({ a }: { a: RunCompareAttribution | null }) {
|
||||
function Attribution({ a, classification }: { a: RunCompareAttribution | null; classification: string }) {
|
||||
if (!a) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1 text-2xs font-medium text-faint">{T.attribution}</h3>
|
||||
<p className="text-2xs text-faint" data-compare-attr-none="">{T.attrNone}</p>
|
||||
<p className="text-2xs leading-relaxed text-faint [overflow-wrap:anywhere]" data-compare-attr-none="">
|
||||
{T.attrSkipped[classification] ?? T.attrNone}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import clsx from 'clsx'
|
||||
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
|
||||
import type { RunCompareQuery } from '../../../types'
|
||||
import { Block, StructureChip } from './parts'
|
||||
import { queryKey, queryNumbers, valueText } from './model'
|
||||
import { queryItems, valueText } from './model'
|
||||
import { diffSql, type DiffPart } from './sqlDiff'
|
||||
|
||||
/** 基准独有、本次独有的高亮:颜色之外,删去的划线、新加的下划线,色弱、灰度打印也分得出 */
|
||||
@@ -26,17 +26,14 @@ const MARK = {
|
||||
*/
|
||||
export function Queries({ queries }: { queries: RunCompareQuery[] }) {
|
||||
const id = useId()
|
||||
const numbers = useMemo(() => queryNumbers(queries), [queries])
|
||||
const items = useMemo(() => queryItems(queries), [queries])
|
||||
return (
|
||||
<Block id={`${id}-queries`} title={T.queries} count={T.count(queries.length)} attr={{ 'data-compare-queries': '' }}>
|
||||
<Block id={`${id}-queries`} title={T.queries} count={T.queryCount(queries.length)} attr={{ 'data-compare-queries': '' }}>
|
||||
{!queries.length ? (
|
||||
<p className="py-4 text-center text-xs text-faint" data-compare-empty="queries">{T.queriesEmpty}</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{queries.map((q) => {
|
||||
const k = queryKey(q)
|
||||
return <QueryItem key={k} q={q} k={k} nth={numbers.get(k) ?? null} />
|
||||
})}
|
||||
{items.map((it) => <QueryItem key={it.key} q={it.q} k={it.key} nth={it.nth} />)}
|
||||
</ul>
|
||||
)}
|
||||
</Block>
|
||||
@@ -91,22 +88,37 @@ function QueryItem({ q, k, nth }: { q: RunCompareQuery; k: string; nth: number |
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{q.structure === 'changed' && <SideBySide base={q.base_sql ?? ''} target={q.target_sql ?? ''} />}
|
||||
{q.structure === 'added' && <SqlPane which="target" label={T.targetSql} sql={q.target_sql ?? ''} />}
|
||||
{q.structure === 'removed' && <SqlPane which="base" label={T.baseSql} sql={q.base_sql ?? ''} />}
|
||||
{q.structure === 'changed' && <SideBySide base={q.base_sql} target={q.target_sql} />}
|
||||
{q.structure === 'added' && <SqlPane which="target" label={T.targetSql} sql={q.target_sql} />}
|
||||
{q.structure === 'removed' && <SqlPane which="base" label={T.baseSql} sql={q.base_sql} />}
|
||||
{foldable && showSql && (
|
||||
<div id={sqlId}>
|
||||
{q.structure === 'same'
|
||||
? <SqlPane which="both" label={T.bothSql} sql={q.target_sql ?? q.base_sql ?? ''} />
|
||||
: <SideBySide base={q.base_sql ?? ''} target={q.target_sql ?? ''} />}
|
||||
? <SqlPane which="both" label={T.bothSql} sql={q.target_sql ?? q.base_sql} />
|
||||
: <SideBySide base={q.base_sql} target={q.target_sql} />}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** 两条 SQL 并排,逐词标出各自独有的部分 */
|
||||
function SideBySide({ base, target }: { base: string; target: string }) {
|
||||
/**
|
||||
* 两条 SQL 并排,逐词标出各自独有的部分。有一边拿不到 SQL(快照读不出来、又没有执行时的参数)时不比:拿空串去比,
|
||||
* 另一边会整条被标成「独有」,像是整条新写的,其实只是不知道。这时两边原样摆着,拿不到的那一边写明无法读取
|
||||
*/
|
||||
function SideBySide({ base, target }: { base: string | null; target: string | null }) {
|
||||
if (base == null || target == null) {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<SqlPane which="base" label={T.baseSql} sql={base} />
|
||||
<SqlPane which="target" label={T.targetSql} sql={target} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <Diffed base={base} target={target} />
|
||||
}
|
||||
|
||||
function Diffed({ base, target }: { base: string; target: string }) {
|
||||
const diff = useMemo(() => diffSql(base, target), [base, target])
|
||||
return (
|
||||
<div className="mt-2">
|
||||
@@ -123,21 +135,26 @@ function SideBySide({ base, target }: { base: string; target: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦 */
|
||||
/**
|
||||
* 一条 SQL。折行显示,不横向撑开;太长时在框里纵向滚动,框能用键盘聚焦。sql 为 null(这一边拿不到 SQL)时
|
||||
* 框里写明无法读取,不摆一个空框
|
||||
*/
|
||||
function SqlPane({ which, label, sql, parts }: {
|
||||
which: 'base' | 'target' | 'both'; label: string; sql?: string; parts?: DiffPart[]
|
||||
which: 'base' | 'target' | 'both'; label: string; sql?: string | null; parts?: DiffPart[]
|
||||
}) {
|
||||
const kind = which === 'base' ? 'del' : 'add'
|
||||
const missing = !parts && sql == null
|
||||
return (
|
||||
<figure className="m-0 mt-1.5 min-w-0">
|
||||
<figcaption className="mb-0.5 text-2xs text-faint">{label}</figcaption>
|
||||
<pre className="mono max-h-64 overflow-y-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere] focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]"
|
||||
data-compare-sql={which} tabIndex={0} aria-label={label}>
|
||||
<pre className={clsx('max-h-64 overflow-y-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-2xs leading-relaxed [overflow-wrap:anywhere] focus-visible:outline-2 focus-visible:outline-[color:var(--accent)]',
|
||||
missing ? 'font-sans text-faint' : 'mono text-dim')}
|
||||
data-compare-sql={which} data-unreadable={missing ? '' : undefined} tabIndex={0} aria-label={label}>
|
||||
{parts
|
||||
? parts.map((p, i) => (p.changed
|
||||
? <mark key={i} data-diff={kind} className="rounded-sm" style={MARK[kind]}>{p.text}</mark>
|
||||
: <Fragment key={i}>{p.text}</Fragment>))
|
||||
: sql}
|
||||
: missing ? T.sqlUnreadable : sql}
|
||||
</pre>
|
||||
</figure>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useId, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowLeftRight, CalendarClock } from 'lucide-react'
|
||||
import { ArrowLeftRight, CalendarClock, RotateCw } from 'lucide-react'
|
||||
import { Skeleton, useTicker } from '../../../components/ui'
|
||||
import { formatDateTime, formatTime, shortId } from '../../../lib/format'
|
||||
import { RUN_COMPARE_TEXT as T, runClassLabel, runName } from '../../../lib/terms'
|
||||
import type { RunWithSchedule } from '../../../types'
|
||||
import { ClassChip as RunClassChip } from '../parts'
|
||||
import type { Loaded } from './data'
|
||||
import { isGone } from './model'
|
||||
|
||||
/**
|
||||
* 顶部的两次运行:左边基准、右边本次(窄屏上下排),中间「互换」。每张卡写名字、时间、类别和版本,点名字进各自的
|
||||
@@ -18,7 +19,7 @@ export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved,
|
||||
targetRun: Loaded<RunWithSchedule>
|
||||
baseRun: Loaded<RunWithSchedule>
|
||||
candidates: Loaded<RunWithSchedule[]>
|
||||
/** 本次的工作流没保存:没有同一工作流的其他运行可选 */
|
||||
/** 本次不属于已保存的工作流(没保存过、或者工作流已删除):没有同一工作流的其他运行可选 */
|
||||
unsaved: boolean
|
||||
/** 默认基准是怎么选的(只在从「与上一次对比」、或只带本次打开时有) */
|
||||
reason: string | null
|
||||
@@ -29,8 +30,8 @@ export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved,
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
|
||||
<RunCard role="base" id={base} run={baseRun}>
|
||||
<BasePicker target={target} base={base} baseRun={baseRun.data} candidates={candidates} unsaved={unsaved}
|
||||
onPick={onPick} />
|
||||
<BasePicker target={target} base={base} baseRun={baseRun} targetRun={targetRun} candidates={candidates}
|
||||
unsaved={unsaved} onPick={onPick} />
|
||||
</RunCard>
|
||||
<div className="flex items-center justify-center">
|
||||
<button type="button" className="btn btn-sm" data-compare-swap="" disabled={!base} onClick={onSwap}
|
||||
@@ -54,6 +55,7 @@ function RunCard({ role, id, run, children }: {
|
||||
const now = useTicker(60_000)
|
||||
const r = run.data
|
||||
const when = r ? r.started_at ?? r.created_at ?? null : null
|
||||
const gone = run.state === 'error' && isGone(run.error)
|
||||
return (
|
||||
<div className="min-w-0 rounded-lg border bg-panel px-3 py-2.5" data-compare-run={role}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5 text-2xs">
|
||||
@@ -85,10 +87,14 @@ function RunCard({ role, id, run, children }: {
|
||||
</div>
|
||||
</>
|
||||
) : run.state === 'error' ? (
|
||||
// 读不到运行记录(删了、连不上):对比本身照样请求,能不能比由对比接口说
|
||||
<div className="mt-1 text-xs text-faint">
|
||||
<Link to={`/runs/${id}`} data-compare-run-link="" className="mono hover:underline">{shortId(id)}</Link>
|
||||
<span className="ml-1.5">{T.runFailed}</span>
|
||||
// 读不到运行记录:对比本身照样请求,能不能比由对比接口说。记录不存在的重试也没用,只写不存在;
|
||||
// 别的(连不上、服务端出错)给重试
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-faint" data-compare-run-failed="">
|
||||
{gone
|
||||
? <span className="mono" title={id}>{shortId(id)}</span>
|
||||
: <Link to={`/runs/${id}`} data-compare-run-link="" className="mono hover:underline">{shortId(id)}</Link>}
|
||||
<span>{gone ? T.runMissing : T.runFailed}</span>
|
||||
{!gone && <RetryButton onClick={run.reload} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1.5" aria-busy="true"><Skeleton rows={2} height={12} gap={8} /></div>
|
||||
@@ -98,24 +104,49 @@ function RunCard({ role, id, run, children }: {
|
||||
)
|
||||
}
|
||||
|
||||
function RetryButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="btn btn-xs" onClick={onClick}>
|
||||
<RotateCw size={10} aria-hidden /> {T.retry}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。
|
||||
* 当前的基准不在这几次里(更早、或者属于别的工作流)时也放进去,下拉不至于显示成别的运行
|
||||
*
|
||||
* 当前的基准不在这几次里(更早、属于别的工作流、读不到)时也单列一项:受控的下拉值对不上任何选项时浏览器会选中
|
||||
* 第一项,看着像另一次运行被选中,而原生下拉再选当前显示的那一项不触发 change,最自然的补救点了没反应。读不到的
|
||||
* 写短 id 加原因,下拉如实反映当前选的是哪一次,其余每一项都能触发 change。
|
||||
*
|
||||
* 列不出候选时说清楚、给重试,不停在「正在读取」:同一工作流的运行没取到;或者本次的记录没读到,不知道是哪个工作流
|
||||
* (候选的键是空的,不会自己去取)
|
||||
*/
|
||||
function BasePicker({ target, base, baseRun, candidates, unsaved, onPick }: {
|
||||
target: string; base: string | null; baseRun?: RunWithSchedule; candidates: Loaded<RunWithSchedule[]>; unsaved: boolean
|
||||
onPick: (id: string) => void
|
||||
function BasePicker({ target, base, baseRun, targetRun, candidates, unsaved, onPick }: {
|
||||
target: string; base: string | null; baseRun: Loaded<RunWithSchedule>; targetRun: Loaded<RunWithSchedule>
|
||||
candidates: Loaded<RunWithSchedule[]>; unsaved: boolean; onPick: (id: string) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
const now = useTicker(60_000)
|
||||
if (unsaved) return <p className="mt-2 text-2xs text-faint">{T.pickUnsaved}</p>
|
||||
if (candidates.state === 'error') return <p className="mt-2 text-2xs text-faint">{T.pickFailed}</p>
|
||||
if (unsaved) return <p className="mt-2 text-2xs text-faint" data-compare-pick-note="unsaved">{T.pickUnsaved}</p>
|
||||
const failed = candidates.state === 'error' ? candidates.reload
|
||||
: candidates.state === 'idle' && targetRun.state === 'error' ? targetRun.reload : null
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-faint" data-compare-pick-failed="">
|
||||
<span>{T.pickFailed}</span>
|
||||
<RetryButton onClick={failed} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const loading = candidates.state !== 'ok'
|
||||
const list = (candidates.data ?? []).filter((r) => r.id !== target)
|
||||
if (base && baseRun && !list.some((r) => r.id === base)) list.unshift(baseRun)
|
||||
if (!loading && !list.length) return <p className="mt-2 text-2xs text-faint">{T.pickNone}</p>
|
||||
if (!loading && !list.length) return <p className="mt-2 text-2xs text-faint" data-compare-pick-note="none">{T.pickNone}</p>
|
||||
const label = (r: RunWithSchedule) => T.pickOption(formatTime(r.started_at ?? r.created_at ?? null, new Date(now)),
|
||||
runClassLabel(r.run_class, r.version), shortId(r.id), !!r.manifest_hash)
|
||||
const current = base && !list.some((r) => r.id === base) ? base : null
|
||||
const currentLabel = (b: string) => (baseRun.data ? label(baseRun.data)
|
||||
: baseRun.state === 'error' ? T.pickGone(shortId(b), isGone(baseRun.error)) : shortId(b))
|
||||
return (
|
||||
<div className="mt-2.5 border-t pt-2">
|
||||
<label className="label" htmlFor={id}>{T.pickBase}</label>
|
||||
@@ -123,6 +154,7 @@ function BasePicker({ target, base, baseRun, candidates, unsaved, onPick }: {
|
||||
aria-describedby={`${id}-hint`} onChange={(e) => { if (e.target.value) onPick(e.target.value) }}>
|
||||
{loading && <option value={base ?? ''}>{T.pickLoading}</option>}
|
||||
{!loading && !base && <option value="" disabled>{T.pickPlaceholder}</option>}
|
||||
{!loading && current && <option value={current}>{currentLabel(current)}</option>}
|
||||
{!loading && list.map((r) => (
|
||||
// 当前的基准即使没封存也要能显示出来(选中项);别的没封存的一律不能选
|
||||
<option key={r.id} value={r.id} disabled={!r.manifest_hash && r.id !== base}>{label(r)}</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { GitCompareArrows } from 'lucide-react'
|
||||
import { ApiError, api } from '../../../api/client'
|
||||
@@ -13,36 +13,69 @@ export interface CompareVia { base: string; reason: string }
|
||||
export const comparePath = (target: string, base?: string | null) =>
|
||||
`/runs/compare?${new URLSearchParams(base ? { target, base } : { target })}`
|
||||
|
||||
/** 查找超过这么久才挂「正在查找」的提示:答复快的不闪一下 */
|
||||
const NOTE_DELAY_MS = 300
|
||||
|
||||
/**
|
||||
* 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务的上一次,没有就同一工作流、同一类别的
|
||||
* 上一次),找到了再跳到对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
|
||||
* 没封存、还在运行的不给点:对比只认已封存的运行,点了也是 409。
|
||||
* 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据
|
||||
* 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。
|
||||
*
|
||||
* 菜单点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、
|
||||
* 收起提示,晚到的答复不再把人带去对比页。
|
||||
*
|
||||
* 写成 hook(返回造菜单项的函数):运行详情里有提前返回,菜单项本身只能在拿到运行之后造,查找中的状态得在那之前就挂上
|
||||
*/
|
||||
export function useCompareEntry(): (run: Run, live: boolean) => MenuItem {
|
||||
const navigate = useNavigate()
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 在路上的那一次:stop 收起(或者不再挂出)「正在查找」的提示
|
||||
const pending = useRef<{ ctrl: AbortController; stop: () => void } | null>(null)
|
||||
useEffect(() => () => {
|
||||
const p = pending.current
|
||||
pending.current = null
|
||||
p?.ctrl.abort()
|
||||
p?.stop()
|
||||
}, [])
|
||||
return useCallback((run: Run, live: boolean): MenuItem => {
|
||||
const blocked = live || !run.manifest_hash
|
||||
const orphan = !run.workflow_id
|
||||
const unsealed = live || !run.manifest_hash
|
||||
return {
|
||||
key: 'compare',
|
||||
label: T.menu,
|
||||
icon: <GitCompareArrows size={12} />,
|
||||
disabled: blocked || busy,
|
||||
hint: blocked ? T.menuSealedOnly : busy ? T.menuFinding : undefined,
|
||||
disabled: orphan || unsealed || busy,
|
||||
hint: orphan ? T.menuNoWorkflow : unsealed ? T.menuSealedOnly : busy ? T.menuFinding : undefined,
|
||||
onSelect: () => {
|
||||
const ctrl = new AbortController()
|
||||
let note: number | null = null
|
||||
const timer = setTimeout(() => {
|
||||
note = toast.info(T.menuFinding, { sticky: true, key: `compare-finding:${run.id}` })
|
||||
}, NOTE_DELAY_MS)
|
||||
const stop = () => {
|
||||
clearTimeout(timer)
|
||||
if (note != null) toast.dismiss(note)
|
||||
}
|
||||
pending.current = { ctrl, stop }
|
||||
setBusy(true)
|
||||
api.compare.base(run.id).then((found) => {
|
||||
api.compare.base(run.id, { signal: ctrl.signal }).then((found) => {
|
||||
if (ctrl.signal.aborted) return
|
||||
const via: CompareVia = { base: found.base_run_id, reason: found.reason }
|
||||
navigate(comparePath(run.id, found.base_run_id), { state: via })
|
||||
}, (e: unknown) => {
|
||||
if (ctrl.signal.aborted) return
|
||||
if (e instanceof ApiError && e.kind === 'http' && e.status === 404) {
|
||||
toast.info(e.message, { action: { label: T.pickManually, onClick: () => navigate(comparePath(run.id)) } })
|
||||
} else {
|
||||
toast.error(e)
|
||||
}
|
||||
}).finally(() => setBusy(false))
|
||||
}).finally(() => {
|
||||
stop()
|
||||
if (pending.current?.ctrl !== ctrl) return
|
||||
pending.current = null
|
||||
setBusy(false)
|
||||
})
|
||||
},
|
||||
}
|
||||
}, [navigate, busy])
|
||||
|
||||
@@ -34,8 +34,19 @@ export function classTone(k: string): { color: string; soft: boolean } {
|
||||
/** 一个指标在表里的键:同一张卡里指标 id 唯一,几张卡之间可能重名 */
|
||||
export const metricKey = (m: Pick<RunCompareMetric, 'card_id' | 'metric_id'>) => `${m.card_id}:${m.metric_id}`
|
||||
|
||||
/** 一条查询的键:同一节点里按服务端的序号区分 */
|
||||
export const queryKey = (q: Pick<RunCompareQuery, 'node_id' | 'ordinal'>) => `${q.node_id}:${q.ordinal}`
|
||||
/**
|
||||
* 「拆分或原因」一栏没有拆分时写的那句话:服务端写了「为什么没拆」就用它。无法比较、新增、移除的服务端不写这一项,
|
||||
* 原因只在 reasons 里——退回写 reasons 的第一句(全部在展开的变化说明里),不让这一栏只剩一个「—」、要展开才知道
|
||||
* 为什么。SQL 结构变的服务端一向写着,万一没写,reasons 说的就是哪条查询变了,也是不拆的原因。只数据变、只参数变、
|
||||
* 未变的本来就用不着拆;口径与数据都变的服务端总会给拆分或原因,它的 reasons 说的是口径改了什么、不是为什么不拆,
|
||||
* 都不退回
|
||||
*/
|
||||
const REASON_IN_CELL = new Set<string>(['incomparable', 'added', 'removed', 'sql_changed'])
|
||||
|
||||
export function splitReason(m: Pick<RunCompareMetric, 'classification' | 'decomposition_note' | 'reasons'>): string | null {
|
||||
if (m.decomposition_note) return m.decomposition_note
|
||||
return REASON_IN_CELL.has(m.classification) && m.reasons?.length ? m.reasons[0] : null
|
||||
}
|
||||
|
||||
const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
|
||||
|
||||
@@ -92,19 +103,29 @@ export function sameValue(a: unknown, b: unknown): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询区的一项:位置键(同一对比里唯一)和同一节点里的「第 N 条」(节点只有一条时为 null) */
|
||||
export interface QueryItem {
|
||||
q: RunCompareQuery
|
||||
key: string
|
||||
nth: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一节点有几条查询时,界面按 1 起数写「第 N 条」。服务端的 ordinal 只用来排先后:它从 0 还是从 1 数、中间有没有
|
||||
* 空号(只有一边有的那条),都不该漏到界面上
|
||||
* 查询区按服务端给的先后列:服务端已经按本次的先后排好,基准多出来的接在后面。同一节点有几条时,按在列表里出现的
|
||||
* 先后从 1 数「第 N 条」,键是「节点:第几」。
|
||||
*
|
||||
* 不拿服务端的 ordinal 当键、也不按它编号:配上的一对取本次的序号,落单的基准查询取基准的序号,Agent 换了查询的先后
|
||||
* 时两者会撞上(同一节点两条都是 1),按它当键 React 会报键冲突、编号会重复或缺号
|
||||
*/
|
||||
export function queryNumbers(queries: RunCompareQuery[]): Map<string, number | null> {
|
||||
const byNode = new Map<string, RunCompareQuery[]>()
|
||||
for (const q of queries) byNode.set(q.node_id, [...(byNode.get(q.node_id) ?? []), q])
|
||||
const out = new Map<string, number | null>()
|
||||
for (const list of byNode.values()) {
|
||||
const sorted = [...list].sort((a, b) => a.ordinal - b.ordinal)
|
||||
sorted.forEach((q, i) => out.set(queryKey(q), list.length > 1 ? i + 1 : null))
|
||||
}
|
||||
return out
|
||||
export function queryItems(queries: RunCompareQuery[]): QueryItem[] {
|
||||
const total = new Map<string, number>()
|
||||
for (const q of queries) total.set(q.node_id, (total.get(q.node_id) ?? 0) + 1)
|
||||
const seen = new Map<string, number>()
|
||||
return queries.map((q) => {
|
||||
const n = (seen.get(q.node_id) ?? 0) + 1
|
||||
seen.set(q.node_id, n)
|
||||
return { q, key: `${q.node_id}:${n}`, nth: (total.get(q.node_id) ?? 0) > 1 ? n : null }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,6 +136,14 @@ export type CompareErrorKind = '409' | '422' | '404' | 'no-base' | 'other'
|
||||
|
||||
/** 服务端「运行记录不存在」的机读码(api/evidence.py 的 RUN_NOT_FOUND,运行对比的接口沿用) */
|
||||
const RUN_NOT_FOUND = 'run_not_found'
|
||||
/** 409 的两种机读码(api/compare.py):没结束封存、封存核对不一致。标题按它分开写 */
|
||||
export const UNSEALED = 'compare_unsealed'
|
||||
export const SEAL_BROKEN = 'compare_seal_broken'
|
||||
|
||||
/** 请求回的是 404(记录不存在):和连不上、服务端出错分开说,前者重试也没用 */
|
||||
export function isGone(e: unknown): boolean {
|
||||
return e instanceof ApiError && e.kind === 'http' && e.status === 404
|
||||
}
|
||||
|
||||
export function compareErrorKind(e: unknown, finding: boolean): CompareErrorKind {
|
||||
if (!(e instanceof ApiError) || e.kind !== 'http') return 'other'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { ApiError } from '../../../api/client'
|
||||
import { ErrorState } from '../../../components/ui'
|
||||
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
|
||||
import { classTone, type CompareErrorKind } from './model'
|
||||
import { SEAL_BROKEN, UNSEALED, classTone, type CompareErrorKind } from './model'
|
||||
|
||||
/** 对比页的一节:带标题的卡片。count 写在标题旁(「11 个」),hint 是一句说明 */
|
||||
export function Block({ id, title, count, hint, children, attr }: {
|
||||
@@ -44,7 +45,7 @@ export function ClassChip({ value }: { value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 查询的结构状态。结构变了用警示色并铺底(口径不同),新增用强调色,其余中性色 */
|
||||
/** 查询的结构状态。结构变了用警示色并铺底(数不能直接比),新增用强调色,其余中性色 */
|
||||
export function StructureChip({ value }: { value: string }) {
|
||||
const warn = value === 'changed'
|
||||
const color = warn ? 'var(--warn)' : value === 'added' ? 'var(--accent)' : 'var(--text-dim)'
|
||||
@@ -55,19 +56,61 @@ export function StructureChip({ value }: { value: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const ERROR_TEXT: Record<Exclude<CompareErrorKind, 'other'>, { title: string; hint: string }> = {
|
||||
'409': { title: T.errSealed, hint: T.errSealedHint },
|
||||
'422': { title: T.errMismatch, hint: T.errMismatchHint },
|
||||
'404': { title: T.errMissing, hint: T.errMissingHint },
|
||||
'no-base': { title: T.errNoBase, hint: T.errNoBaseHint },
|
||||
/** 页面上已经知道的几件事:错误说明的下一步按它挑,只指向这一刻真走得通的路 */
|
||||
export interface CompareErrorContext {
|
||||
/** 本次的运行记录读到了 */
|
||||
targetKnown: boolean
|
||||
/** 本次不属于已保存的工作流(没保存过、或者工作流已删除):和哪一次都比不了 */
|
||||
targetUnsaved: boolean
|
||||
/** 本次的运行记录上没有封存清单 */
|
||||
targetUnsealed: boolean
|
||||
/** 本次、基准的运行记录是不是 404 */
|
||||
targetGone: boolean
|
||||
baseGone: boolean
|
||||
/** 换基准的下拉里有没有能选的运行(已封存、不是本次);还不知道(在读、读失败)时为 null */
|
||||
pickable: boolean | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比失败时的说明:先说是哪一种(未封存、不是同一工作流、运行不存在、找不到基准),再原样带上服务端的原因,
|
||||
* 最后一句下一步。未封存、封存核对不一致的,给两次运行详情的入口:封存凭证条上能核对。别的错误按通用的出错态画
|
||||
* 标题和下一步。标题说是哪一种;服务端的原因原样跟在标题后面(CompareError 里),下一步只写界面上真有的入口、
|
||||
* 服务端已经写了的不再重复:
|
||||
* - 409:按机读码分没结束封存、封存核对不一致。本次自己没封存时换基准也没用,不给下一步;否则说出在基准上时可以
|
||||
* 在上方换(服务端的原因里写着是哪一次)。去记录页核对的入口是下面两个「查看」按钮,服务端的原因里也写了「核对」;
|
||||
* - 422:本次不属于已保存的工作流时哪一次都比不了,标题直说、不给下一步;否则在上方另选同一工作流的一次;
|
||||
* - 404:缺的是本次(targetGone)才是链接失效;缺的是基准(基准的记录 404,或者本次的记录读得到)就在上方另选;
|
||||
* 还不知道是哪一边时先不说;
|
||||
* - 找不到默认基准:本次不属于已保存的工作流同 422;下拉里有能选的才说可以在上方选,一次都没有就照实说
|
||||
*/
|
||||
export function CompareError({ kind, error, onRetry, target, base }: {
|
||||
function errorCopy(kind: Exclude<CompareErrorKind, 'other'>, error: unknown, targetGone: boolean, ctx: CompareErrorContext): {
|
||||
title: string; hint: string | null
|
||||
} {
|
||||
const code = error instanceof ApiError ? error.code : undefined
|
||||
switch (kind) {
|
||||
case '409': {
|
||||
const title = code === SEAL_BROKEN ? T.errSealBroken : code === UNSEALED ? T.errUnsealed : T.errSealed
|
||||
return { title, hint: code === UNSEALED && ctx.targetUnsealed ? null : T.errSealedHint }
|
||||
}
|
||||
case '422':
|
||||
return ctx.targetUnsaved ? { title: T.errUnsaved, hint: null } : { title: T.errMismatch, hint: T.errMismatchHint }
|
||||
case '404':
|
||||
return {
|
||||
title: T.errMissing,
|
||||
hint: targetGone ? T.errMissingHint : ctx.baseGone || ctx.targetKnown ? T.errBaseMissingHint : null,
|
||||
}
|
||||
case 'no-base':
|
||||
if (ctx.targetUnsaved) return { title: T.errUnsaved, hint: null }
|
||||
return { title: T.errNoBase, hint: ctx.pickable == null ? null : ctx.pickable ? T.errNoBaseHint : T.errNoBaseNone }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比失败时的说明:先说是哪一种(未封存、封存核对不一致、不是同一工作流、运行不存在、找不到基准),再原样带上
|
||||
* 服务端的原因,最后一句下一步(errorCopy)。未封存、封存核对不一致的,给两次运行详情的入口(运行详情上有「核对」);
|
||||
* 本次的记录不存在时给去记录页的入口。别的错误按通用的出错态画,带重试
|
||||
*/
|
||||
export function CompareError({ kind, error, onRetry, target, base, context }: {
|
||||
kind: CompareErrorKind; error: unknown; onRetry: () => void; target: string | null; base: string | null
|
||||
context: CompareErrorContext
|
||||
}) {
|
||||
if (kind === 'other') {
|
||||
return (
|
||||
@@ -76,7 +119,9 @@ export function CompareError({ kind, error, onRetry, target, base }: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { title, hint } = ERROR_TEXT[kind]
|
||||
// 缺的是不是本次:只带本次的地址(还没有基准)出的错来自找默认基准,那里的 404 只会是本次
|
||||
const targetGone = !base || context.targetGone
|
||||
const { title, hint } = errorCopy(kind, error, targetGone, context)
|
||||
const reason = error instanceof Error ? error.message : ''
|
||||
return (
|
||||
<div
|
||||
@@ -92,13 +137,16 @@ export function CompareError({ kind, error, onRetry, target, base }: {
|
||||
<div className="min-w-0 flex-1 [overflow-wrap:anywhere]">
|
||||
<div className="font-medium text-fg" data-compare-error-title="">{title}</div>
|
||||
{reason && reason !== title && <div className="mt-0.5 text-dim">{reason}</div>}
|
||||
<div className="mt-0.5 text-faint">{hint}</div>
|
||||
{hint && <div className="mt-0.5 text-faint" data-compare-error-hint="">{hint}</div>}
|
||||
{kind === '409' && (target || base) && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||
{base && <Link className="btn btn-sm" to={`/runs/${base}`}>{T.viewBase}</Link>}
|
||||
{target && <Link className="btn btn-sm" to={`/runs/${target}`}>{T.viewTarget}</Link>}
|
||||
</div>
|
||||
)}
|
||||
{kind === '404' && targetGone && (
|
||||
<div className="mt-1.5"><Link className="btn btn-sm" to="/runs">{T.toRuns}</Link></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 两条 SQL 逐词对比:并排显示时标出各自独有的部分(结构变了的那条查询,差在哪一眼能看到)。
|
||||
*
|
||||
* 按词(名字、关键字、数、字符串、运算符)而不是按字符比:按字符比会把 created_at 和 paid_at 里碰巧相同的「_at」
|
||||
* 按词(名字、关键字、数、字符串、运算符、注释)而不是按字符比:按字符比会把 created_at 和 paid_at 里碰巧相同的「_at」
|
||||
* 当成没变,读起来支离破碎;按行比又太粗,模型写的 SQL 常常一整条写在一行里。关键字和不加引号的名字不分大小写
|
||||
* (服务端的规范化也不算它们不同),字符串、加引号的名字照原样比。空白不参与比较,只是两个变了的词之间的空白也
|
||||
* 跟着标,免得一段新加的条件被切成好几截。
|
||||
@@ -26,8 +26,10 @@ export interface SqlDiff {
|
||||
/** 逐词比较时动态规划表的格数上限(Uint32Array,4 MB) */
|
||||
const CELL_LIMIT = 1_000_000
|
||||
|
||||
// 空白、字符串('' 转义)、加引号的名字("…"、`…`、[…])、名字和关键字、数、两个字符的运算符、其余单个字符
|
||||
const TOKEN = /\s+|'(?:[^']|'')*'?|"(?:[^"]|"")*"?|`[^`]*`?|\[[^\]\s]*\]?|[\p{L}_][\p{L}\p{N}_$]*|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|!=|\|\||::|[\s\S]/gu
|
||||
// 空白、注释(-- 到行尾、/* … */,没收尾的到末尾)、字符串('' 转义)、加引号的名字("…"、`…`、[…])、名字和关键字、
|
||||
// 数、两个字符的运算符、其余单个字符。注释整段算一个词,而且排在字符串前面:模型写的英文注释里常有 it's、user's,
|
||||
// 把里面的撇号当成字符串的开头,会一直吞到下一个引号,后面没改的语句全被标成不同
|
||||
const TOKEN = /\s+|--[^\n]*|\/\*[\s\S]*?(?:\*\/|$)|'(?:[^']|'')*'?|"(?:[^"]|"")*"?|`[^`]*`?|\[[^\]\s]*\]?|[\p{L}_][\p{L}\p{N}_$]*|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|!=|\|\||::|[\s\S]/gu
|
||||
|
||||
interface Token {
|
||||
text: string
|
||||
|
||||
@@ -96,7 +96,7 @@ const ESTIMATE = {
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 1, compare: 30, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
|
||||
schedules: 1, compare: 45, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
|
||||
}
|
||||
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
|
||||
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
|
||||
|
||||
+298
-38
@@ -5,6 +5,9 @@
|
||||
// 渲染的写法对不上);不能拆的一声不吭;展开后归因只给数没有占比时不说为什么;SQL 结构变了只给一条、差异不标;
|
||||
// 换了基准、互换了却不重新请求,或者慢的旧答复盖掉新的;409(未封存、封存核对不一致)、422(不是同一工作流)、
|
||||
// 404(找不到基准)只弹一句通用报错;窄屏(390px)被表格撑出横向滚动;浅色下分类标签、差异高亮看不清。
|
||||
// 还有几处照服务端(engine/compare.py、api/compare.py)的实际输出才看得出来的:无法比较的指标原因只在 reasons 里;
|
||||
// 同一节点两条查询的 (node_id, ordinal) 相同;一边的 SQL 是 null;基准运行读不到;本次的记录读不到;不属于已保存
|
||||
// 工作流的运行;查找基准期间离开运行详情。下一步的提示要和页面上真能做的事对得上。
|
||||
//
|
||||
// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、
|
||||
// compare/base、compare 都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。
|
||||
@@ -12,7 +15,7 @@
|
||||
//
|
||||
// node scripts/check-compare.mjs
|
||||
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-compare.mjs
|
||||
// COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。
|
||||
// COMPARE_SHOTS=<目录> 时亮暗、宽窄各截几张,供人眼复核。COMPARE_ONLY=查询,窄屏 只跑标题里带这几截的段。
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||||
|
||||
@@ -21,12 +24,17 @@ const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Conte
|
||||
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()
|
||||
@@ -79,6 +87,13 @@ const ALL_ROWS = REGIONS.map((name, i) => ({
|
||||
}))
|
||||
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,
|
||||
@@ -104,8 +119,7 @@ const METRICS = [
|
||||
{ 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: ['所依据的查询「退货明细」的筛选条件有改动'],
|
||||
decomposition_note: '所依据的查询「退货明细」SQL 结构有改动,口径不同,不做拆分' }),
|
||||
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: ['「拉取销量」只有日期参数不同'],
|
||||
@@ -113,12 +127,12 @@ const METRICS = [
|
||||
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: ['本次新增的指标'] }),
|
||||
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: ['本次没有这个指标'] }),
|
||||
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: ['基准运行的口径卡没有记录输入'], decomposition_note: '基准运行的口径卡没有记录输入,无法拆分' }),
|
||||
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' })),
|
||||
@@ -126,23 +140,24 @@ const METRICS = [
|
||||
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: 0, structure: 'params_only', base_sql: PULL_BASE, target_sql: PULL_TARGET,
|
||||
{ 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: 0, structure: 'changed',
|
||||
{ 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: 0, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM stores',
|
||||
{ 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: 1, structure: 'same', base_sql: 'SELECT COUNT(*) AS n FROM visits',
|
||||
{ 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: 0, structure: 'added', base_sql: null,
|
||||
{ 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: 0, structure: 'removed', base_sql: 'SELECT COUNT(*) AS n FROM members',
|
||||
{ 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: '基准运行的查询快照无法读取' },
|
||||
]
|
||||
@@ -165,6 +180,7 @@ const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data
|
||||
params_only: '只参数变', added: '新增', removed: '移除', incomparable: '无法比较' }
|
||||
const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged']
|
||||
const REASON = '默认基准:同一定时任务的上一次运行'
|
||||
const FINDING = '正在查找上一次运行'
|
||||
|
||||
// ---------------------------------------------------------------- 假接口
|
||||
|
||||
@@ -180,9 +196,15 @@ const GRAPH = { nodes: [
|
||||
{ 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) 可以换掉对应接口的答复,返回 {status, json, wait}
|
||||
* (wait 是一个 Promise:等它落定再答,用来压住某一次请求)
|
||||
* 默认的假接口。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) => {
|
||||
@@ -190,6 +212,8 @@ function defaults(opts) {
|
||||
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 }
|
||||
@@ -207,6 +231,11 @@ function defaults(opts) {
|
||||
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() }
|
||||
@@ -222,7 +251,8 @@ const browser = await chromium.launch({ executablePath: CHROME })
|
||||
|
||||
/**
|
||||
* 开一页:/api 的请求先问 defaults(再由 opts 换掉其中几个),答不了的 GET 放行到服务端,非 GET 一律拦掉。
|
||||
* 记下每次请求(「GET /api/runs/…?base=…」)和页面报错;原生对话框都算失败
|
||||
* 记下每次请求(「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' })
|
||||
@@ -239,8 +269,10 @@ async function open(path, { width = 1280, height = 900, theme = 'dark', waitUnti
|
||||
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) => {
|
||||
@@ -267,14 +299,19 @@ async function open(path, { width = 1280, height = 900, theme = 'dark', waitUnti
|
||||
void getComputedStyle(root).backgroundColor
|
||||
requestAnimationFrame(() => root.classList.remove('theme-switching'))
|
||||
}, theme)
|
||||
return { ctx, page, log, blocked, errors }
|
||||
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) => {
|
||||
@@ -314,10 +351,62 @@ await section('从运行详情进入', async () => {
|
||||
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(';'))
|
||||
})
|
||||
|
||||
await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => {
|
||||
let release
|
||||
const gate = new Promise((r) => { release = r })
|
||||
const { page, log, errors } = await open(`/runs/${T}`,
|
||||
{ base: () => ({ json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) }, wait: gate }) })
|
||||
// compare/base 这一请求了结(答完,或者被取消)
|
||||
const ended = new Promise((resolve) => {
|
||||
const done = (req) => { if (new URL(req.url()).pathname.endsWith('/compare/base')) resolve() }
|
||||
page.on('requestfinished', done)
|
||||
page.on('requestfailed', done)
|
||||
})
|
||||
await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
|
||||
await page.getByRole('button', { name: '更多操作' }).click()
|
||||
await page.locator('[data-menu=compare]').click()
|
||||
// 菜单点完就收起了:查找期间要有一条看得见的提示,接口慢的时候不像点了没反应
|
||||
check('点了之后有「正在查找上一次运行」的提示', 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)
|
||||
// 改主意去了别的页面:答复晚到也不能把人拽回对比页
|
||||
await page.evaluate(() => { history.pushState({}, '', '/data'); dispatchEvent(new PopStateEvent('popstate')) })
|
||||
await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 })
|
||||
release()
|
||||
// 等请求了结,再给跳转留足时间:第一次进 /data 要现编那一页,主线程忙的时候晚到的跳转会落在固定的等待之后
|
||||
await Promise.race([ended, new Promise((r) => setTimeout(r, 4000))])
|
||||
const jumped = await until(async () => new URL(page.url()).pathname === '/runs/compare', 2500)
|
||||
const at = new URL(page.url())
|
||||
check('离开运行详情之后答复才回来:留在原地,不跳到对比页', !jumped && at.pathname.startsWith('/data'), at.pathname + at.search)
|
||||
check('离开之后「正在查找」的提示也收起', await page.locator('[data-toast]', { hasText: FINDING }).count() === 0)
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
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 }
|
||||
@@ -377,10 +466,16 @@ await section('指标表', async () => {
|
||||
(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 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()) === '无变化')
|
||||
@@ -445,6 +540,19 @@ await section('展开:输入对比与维度归因', async () => {
|
||||
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(';'))
|
||||
})
|
||||
|
||||
@@ -483,7 +591,7 @@ await section('查询', async () => {
|
||||
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"]')
|
||||
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"]')
|
||||
@@ -500,29 +608,85 @@ await section('查询', async () => {
|
||||
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:0"] [data-compare-query-problem]').innerText())
|
||||
check('快照读不出来:照服务端的说明写在那一条上', (await page.locator('[data-compare-query="member:1"] [data-compare-query-problem]').innerText())
|
||||
=== '基准运行的查询快照无法读取')
|
||||
const pull = page.locator('[data-compare-query="pull:0"]')
|
||||
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: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:1"] [data-compare-nth]').innerText()).includes('第 2 条')
|
||||
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: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:0"] [data-compare-sql="base"]').count() === 1)
|
||||
&& 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)
|
||||
@@ -596,24 +760,44 @@ await section('过时的答复不盖掉新的', async () => {
|
||||
/** 「其他错误」那一节:第一次对比回 500,之后照常 */
|
||||
let flakyCalls = 0
|
||||
await section('出错与找不到基准', async () => {
|
||||
const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: '基准运行的封存核对不一致,无法对比' } }) })
|
||||
// 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"]')
|
||||
check('409:写明未封存或封存核对不一致,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()).includes('封存')
|
||||
&& (await e409.innerText()).includes('基准运行的封存核对不一致,无法对比'), (await e409.innerText().catch(() => '')).replace(/\s+/g, ' '))
|
||||
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 other = await open(comparePath(T, B), { compare: () => ({ status: 422, json: { detail: '两次运行不属于同一个工作流' } }) })
|
||||
// 本次自己没封存:换基准也没用,服务端的原因里已经写了要等运行结束,不再叫人去换基准
|
||||
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 other = await open(comparePath(T, B), { compare: () => ({ status: 422,
|
||||
json: { detail: '两次运行不属于同一个工作流(「区域销售周报」与「门店日报」),不能对比', code: 'compare_mismatch' } }) })
|
||||
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, ' '))
|
||||
&& (await e422.innerText()).includes('同一工作流的另一次运行'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' '))
|
||||
|
||||
const gone = await open(comparePath(T, B), { compare: () => ({ status: 404, json: { detail: '运行记录不存在' } }) })
|
||||
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('运行记录不存在'))
|
||||
|
||||
@@ -631,14 +815,27 @@ await section('出错与找不到基准', async () => {
|
||||
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: () => ({ status: 404, json: { detail: '运行记录不存在', code: 'run_not_found' } }) })
|
||||
const missing = await open(comparePath(T), { base: () => RUN_GONE, run: (id) => (id === T ? RECORD_GONE : null) })
|
||||
await settled(missing.page, 'error')
|
||||
check('找基准时本次就不存在:写运行记录不存在', await missing.page.locator('[data-compare-error="404"]').count() === 1
|
||||
&& (await missing.page.locator('[data-compare-error-title]').innerText()) === '运行记录不存在')
|
||||
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, ' '))
|
||||
|
||||
// 只带本次、找得到:自己补上基准,写明怎么选的
|
||||
const auto = await open(comparePath(T))
|
||||
@@ -664,6 +861,67 @@ await section('出错与找不到基准', async () => {
|
||||
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(';'))
|
||||
})
|
||||
|
||||
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: [] } } }) })
|
||||
@@ -703,7 +961,7 @@ await section('窄屏', async () => {
|
||||
})
|
||||
check(`390px(${theme}):指标表在自己的框里横向滚动,键盘可聚焦`, !!scroller && scroller.sw > scroller.cw && scroller.focusable,
|
||||
JSON.stringify(scroller))
|
||||
const refund = page.locator('[data-compare-query="refund:0"]')
|
||||
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 }))
|
||||
@@ -778,5 +1036,7 @@ await section('深浅色可读', async () => {
|
||||
})
|
||||
|
||||
await browser.close()
|
||||
console.log(failed ? `\n✗ 运行对比:${failed} 项未通过` : '\n✓ 运行对比:全部通过')
|
||||
// 段过滤一段都没对上:报成没通过,不让一个写错的过滤词顶着「全部通过」
|
||||
if (!ran) failed++
|
||||
console.log(failed ? `\n✗ 运行对比:${ran ? `${failed} 项未通过` : '一项都没有检查'}` : '\n✓ 运行对比:全部通过')
|
||||
process.exit(failed ? 1 : 0)
|
||||
|
||||
Reference in New Issue
Block a user