触发历史不再给基准已过时的自动对比;对比页找不到默认基准时标题不再和「可在上方选择」打架,换基准的下拉和基准卡写明失败、取消的运行

- 中间失败的那次事后被接着运行成功之后,第 3 周存着的那份对比(比的是第 1 周,previous 为假)和运行详情「与上一次
  对比」的默认基准(现在是第 2 周)又对不上,两处却都写「上一次成功的运行」。auto_compare.outdated 对基准当时就不是
  紧挨着的上一次的那几份按 pick_base 现挑一遍,基准变了的算过时;api/schedules._fire_outs 对过时的给 null(不给链接、
  不给旧计数),两个同名入口比的始终是同一对运行。基准没变的照旧给:中间那次还没结束、后来失败、被删掉都一样。紧挨着
  的上一次不会过时(两次之间插不进新的运行),不核
- test_base_follows_launch_order_not_finish_time 补上:接着运行完之后第 3 周的触发记录不再带对比、任务列表的 last_fire
  同样不带、运行详情的默认基准是第 2 周;另加一条基准仍是挑出来的那一次时照旧给出
- 对比页找不到默认基准:标题由「未找到可作为基准的运行」改为「没有默认基准」——失败、取消但封存了的运行不当默认基准,
  却照样能在上方手动选;服务端的原因改成「没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功
  结束并已封存的运行」,运行详情弹出的提示单独读也说得清
- 换基准的下拉给失败、已取消的运行写上状态,基准卡也标出来;下拉说明补一句默认基准只取成功结束的运行
- 运行详情「更多」的菜单项点之前不知道基准是哪一次,基准不是紧挨着的上一次时仍叫「与上一次对比」,和触发历史的叫法
  不同:注释照实写明,留作后续
This commit is contained in:
ThearchyHelios
2026-10-06 13:33:40 +08:00
parent 238fe94fee
commit 1bee9b5100
8 changed files with 176 additions and 35 deletions
+7 -5
View File
@@ -2,9 +2,10 @@
GET /api/runs/{run_id}/compare/base:默认拿哪一次来比,{base_run_id, reason, base}。规则在 pick_base,定时任务结束后
的自动对比(scheduler/auto_compare.py)用的是同一个函数——触发历史里的「与上一次对比」和运行详情里的同名入口必须拿到
同一个基准,计数才对得上。这次运行是定时任务发起的,取同一定时任务、同一工作流、比它先发起的运行里成功结束并封存的
最近一次(reason: same_schedule);不是、或者同一任务之前没有成功的,取同一工作流、同一运行类别里同样条件的最近一次
(reason: same_workflow)。找不到 404。
同一个基准,计数才对得上;触发历史里存着的那一份,事后基准不再是这里挑出来的那一次(中间失败的那次被接着运行成功
了)就不再给出(auto_compare.outdated)。这次运行是定时任务发起的,取同一定时任务、同一工作流、比它先发起的运行里
成功结束并封存的最近一次(reason: same_schedule);不是、或者同一任务之前没有成功的,取同一工作流、同一运行类别里
同样条件的最近一次(reason: same_workflow)。找不到 404。
GET /api/runs/{run_id}/compare?base={base_id}:{target, base, summary, template, metrics, queries, notes},每个字段
的形状见 engine/compare.py。两次运行都必须已结束、封存且封存核对一致,封存判定照搬证据接口(api/evidence._sealed):
@@ -52,8 +53,9 @@ UNSEALED_TEXT = "{who}尚未结束并封存:对比只用封存的运行记录
#: 已经结束、只是没有封存清单(封存上线前结束的、结束时没封上的):再等也等不来封存,不能说「运行结束后才能对比」
NO_MANIFEST_TEXT = "{who}没有封存清单,不能用于对比"
SEAL_BROKEN_TEXT = "{who}的封存核对不一致:封存后有事件被修改、删除或插入,不能用于对比。可在记录页点「核对」查看核对结果"
NO_BASE_TEXT = ("没有找到可以对比的上一次成功的运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、"
"比这次先发起、成功结束并已封存的运行")
#: 找不到默认基准。运行详情的「与上一次对比」把它原样弹给人看,对比页把它跟在标题「没有默认基准」后面:自己得说清
#: 找的是什么、没找到。定时任务发起的先找同一定时任务、再找同一工作流,两档都落空才到这里,条件只写后一档的
NO_BASE_TEXT = "没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功结束并已封存的运行"
TARGET = "这次运行"
BASE = "作为基准的运行"
+6 -3
View File
@@ -285,17 +285,20 @@ async def _fire_outs(session: AsyncSession, fires: list[ScheduleFire]) -> list[d
对比和比的时候两边的封存绑定(auto_compare):哪一边现在不在终态(被继续执行)、被删掉、清单哈希和比的时候不一样
(重新封存过)都给 null——对比页按现在的封存现算,记下的计数和点进去看到的会对不上,或者干脆比不了。工件读不出来、
被改过的同样给 null。"""
被改过的同样给 null。基准已经不是现在挑出来的那一次(auto_compare.outdated:中间隔着的运行事后接着运行成功了)也
给 null:运行详情里同名的入口比的是另一次。"""
compared = {f.id: f.compare_artifact for f in fires if f.compare_artifact and f.run_id}
compares = await auto_compare.briefs(list(compared.values()))
stale = await auto_compare.outdated(session, compares)
run_ids = {f.run_id for f in fires if f.run_id} | {brief["base_run_id"] for brief in compares.values()}
# 运行现在的状态和清单哈希:run_status 照写,对比按它核对两边的封存
current = {run_id: (status, seal) for run_id, status, seal in (await session.execute(
select(Run.id, Run.status, Run.manifest_hash).where(Run.id.in_(run_ids)))).all()} if run_ids else {}
def compare_of(f: ScheduleFire) -> dict[str, Any] | None:
brief = compares.get(compared.get(f.id) or "")
if brief is None or brief["target_run_id"] != f.run_id:
artifact = compared.get(f.id) or ""
brief = compares.get(artifact)
if brief is None or brief["target_run_id"] != f.run_id or artifact in stale:
return None
for run_id, seal in ((brief["target_run_id"], brief["target_manifest"]),
(brief["base_run_id"], brief["base_manifest"])):
+26 -1
View File
@@ -10,7 +10,8 @@
运行详情「与上一次对比」的默认基准用的是同一个函数:触发历史和运行详情里两个同名的入口必须比同一对运行。失败的那次
不当基准:拿它当基准,每个指标都成了「新增」。按发起先后挑,不按结束时间:老的失败运行事后被接着跑完,结束得比
后一次晚,它的上一次仍是它前面那次。紧挨着的上一次不是成功的(失败、取消、还没结束)时 previous 为 False,通知和
触发历史写明比的是哪一次。这个任务的第一次不比(不像运行详情那样退回同一工作流的别的运行:那不是「上一次」)
触发历史写明比的是哪一次;中间那次事后接着运行成功了,上一次成功的就成了它,触发历史不再给当时那一份(outdated)。
这个任务的第一次不比(不像运行详情那样退回同一工作流的别的运行:那不是「上一次」)
- 和封存绑定:运行的封存要盖住它最后一条结束事件才算封存完(sealed_now)。runner 先写终态、再另起会话封存,失败后
接着跑完的运行在两步之间还带着失败时的清单哈希,拿它去比只比得到旧封存范围里的事件。结果里记下两边的清单哈希:
哪一边后来变了,触发历史就不再给这份对比(api/schedules._fire_outs);对比途中变了,结果不记
@@ -249,6 +250,30 @@ def brief_of(artifact: str) -> dict[str, Any] | None:
return _copy(brief)
async def outdated(session: Any, known: dict[str, dict[str, Any]]) -> set[str]:
"""known(对比工件 id → 摘要)里基准已经不是现在挑出来的那一次的:返回这些工件的 id。
只有基准当时就不是紧挨着的上一次(previous 为 False)的会这样:中间隔着的那几次(失败、还没结束的)事后接着运行
成功、封存了,这次之前同一定时任务上一次成功的就成了它。运行详情「与上一次对比」的默认基准(pick_base)比的是它,
触发历史要是还给当时那一份,两个同名入口比的就不是同一对运行,计数也对不上。紧挨着的上一次不会这样:两次之间插不进
新的运行(发起时间只会往后排),两边自己被继续执行、重新封存另有核对(api/schedules._fire_outs)。这类对比本来就少,
逐个现挑一遍不费什么"""
from app.api import compare as compare_api
wanted = {artifact: brief for artifact, brief in known.items() if not brief["previous"]}
if not wanted:
return set()
targets = {run.id: run for run in (await session.execute(
select(Run).where(Run.id.in_({brief["target_run_id"] for brief in wanted.values()})))).scalars()}
stale = set()
for artifact, brief in wanted.items():
target = targets.get(brief["target_run_id"])
found = await compare_api.pick_base(session, target, schedule_only=True) if target is not None else None
if found is None or found.run.id != brief["base_run_id"]:
stale.add(artifact)
return stale
async def briefs(artifacts: list[str]) -> dict[str, dict[str, Any]]:
"""几份对比工件的摘要:工件 id → 摘要,读不出来的不在里面。读盘在线程里做。"""
wanted = list(dict.fromkeys(a for a in artifacts if a))
+52 -4
View File
@@ -9,7 +9,8 @@
记录的 compare_artifact。这个任务的第一次(只有同一工作流别的运行可比)、还没封存时不比;对比出错(基准的封存核对
不一致、意外的异常)只记日志,通知照常推送、不带对比
- 接口:Fire 多一个 compare: {base_run_id, summary, previous} 或 null,summary 同对比接口的计数,previous 说基准是不是
紧挨着的上一次;基准被删、被继续执行(不在终态)时为 null(对比页只认封存的运行)
紧挨着的上一次;基准被删、被继续执行(不在终态)时为 null(对比页只认封存的运行);基准不再是现在挑出来的那一次
(中间失败的那次事后被接着运行成功)时也为 null(运行详情里同名的入口比的已是另一次)
- 通知:结束的通知多一行「与上一次相比:只数据变 2 个、口径与数据都变 0 个、SQL 结构变 0 个」,配了对外访问地址时附
对比页链接(/runs/compare?target=…&base=…);只给个数,报告、指标、查询里的具体数字一个不出现
@@ -325,9 +326,21 @@ async def test_after_a_failed_run_the_base_is_the_last_successful_one(recorder,
assert (base["base_run_id"], base["reason"]) == (first, "same_schedule")
async def fires_of(client, sid: str) -> dict[str, dict]:
return {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
async def default_base_of(client, run_id: str) -> tuple[str, str]:
body = (await client.get(f"/api/runs/{run_id}/compare/base")).json()
return body["base_run_id"], body["reason"]
async def test_base_follows_launch_order_not_finish_time(recorder, client):
"""第 2 周失败、第 3 周照常成功之后,有人把第 2 周接着跑完:它结束得比第 3 周晚,但它的上一次仍是第 1 周。以前
按结束时间挑,拿后一次触发的第 3 周当基准,「与上一次相比」的变化方向是反的。第 3 周已经比过的那一份不受影响"""
按结束时间挑,拿后一次触发的第 3 周当基准,「与上一次相比」的变化方向是反的。
第 3 周存下的那一份不改,但触发历史不再给它:第 3 周之前同一定时任务上一次成功的已经是第 2 周,运行详情「与上一次
对比」比的是第 2 周。还给第 1 周那一份,两个同名入口就比的不是同一对运行,计数也对不上"""
workflow = await new_workflow()
sid = await new_schedule(workflow, url=None)
first = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
@@ -339,6 +352,9 @@ async def test_base_follows_launch_order_not_finish_time(recorder, client):
await webhook.notify_due()
third_artifact = (await fire_row(third_fire)).compare_artifact
assert load(third_artifact)["result"]["base"]["id"] == first
assert (await fires_of(client, sid))[third_fire]["compare"] == {"base_run_id": first, "summary": SUMMARY,
"previous": False}
assert await default_base_of(client, third) == (first, "same_schedule")
await reseal(second, week("r2", NEXT_ROWS), status="succeeded", finished=T0 + 2 * WEEK + timedelta(days=1))
await webhook.notify_due()
@@ -346,8 +362,40 @@ async def test_base_follows_launch_order_not_finish_time(recorder, client):
assert stored["result"]["base"]["id"] == first and stored["previous"] is True
assert (await fire_row(third_fire)).compare_artifact == third_artifact
# 运行详情里的默认基准同样按发起先后:接着跑完的第 2 周,上一次仍是第 1 周
base = (await client.get(f"/api/runs/{second}/compare/base")).json()
assert (base["base_run_id"], base["reason"]) == (first, "same_schedule")
assert await default_base_of(client, second) == (first, "same_schedule")
items = await fires_of(client, sid)
assert (items[second_fire]["compare"]["base_run_id"], items[second_fire]["compare"]["previous"]) == (first, True)
# 第 3 周:运行详情比的是第 2 周了,触发历史不再给比第 1 周的那一份(不给链接、不给旧计数)
assert await default_base_of(client, third) == (second, "same_schedule")
assert items[third_fire]["compare"] is None
[listed] = [s for s in (await client.get("/api/schedules")).json()["items"] if s["id"] == sid]
assert listed["last_fire"]["id"] == third_fire and listed["last_fire"]["compare"] is None
async def test_a_stored_compare_stays_while_its_base_is_still_the_pick(recorder, client):
"""基准当时就不是紧挨着的上一次、现在仍是挑出来的那一次:照旧给出。中间那次还停着没结束、后来失败了、被删掉了,
第 3 周之前同一定时任务上一次成功的都还是第 1 周,运行详情比的也是它。删掉之后它其实成了紧挨着的上一次,previous
仍照当时记下的给:「与上一次成功的运行对比」照样属实,通知里当时也是这么说的"""
workflow = await new_workflow()
sid = await new_schedule(workflow, url=None)
first = await persist(week("r1"), workflow, finished=T0, schedule_id=sid)
await fire_for(sid, first, T0 - timedelta(minutes=2), notified="succeeded")
second = await persist(failed_week("r2"), workflow, finished=T0 + WEEK, schedule_id=sid, status="running",
seal=False)
await fire_for(sid, second, T0 + WEEK - timedelta(minutes=2), notified="running")
third = await persist(week("r3", NEXT_ROWS), workflow, finished=T0 + 2 * WEEK, schedule_id=sid)
third_fire = await fire_for(sid, third, T0 + 2 * WEEK - timedelta(minutes=2))
await webhook.notify_due()
shown = {"base_run_id": first, "summary": SUMMARY, "previous": False}
assert (await fires_of(client, sid))[third_fire]["compare"] == shown
assert await default_base_of(client, third) == (first, "same_schedule")
await set_run(second, status="failed")
await seal(second)
assert (await fires_of(client, sid))[third_fire]["compare"] == shown
assert await default_base_of(client, third) == (first, "same_schedule")
assert (await client.delete(f"/api/runs/{second}", params={"force": "true"})).status_code == 204
assert (await fires_of(client, sid))[third_fire]["compare"] == shown
assert await default_base_of(client, third) == (first, "same_schedule")
async def test_without_an_earlier_success_there_is_no_base(recorder, client):
+17 -8
View File
@@ -3085,13 +3085,14 @@ export const SCHEDULE_TEXT = {
// ---- 触发历史里的对比:运行成功结束后,服务端自动和同一定时任务上一次成功的运行比一次
/**
* 和运行详情「更多」菜单里的入口是同一件事(服务端用同一个函数挑基准,两处比的是同一对运行),用同一个叫法。
* RUN_COMPARE_TEXT 定义在本对象之后,模块初始化时还取不到,写成 getter 用到时再取
* 和运行详情「更多」菜单里的入口是同一件事(服务端用同一个函数挑基准,两处比的是同一对运行),基准就是紧挨着的上一次
* 时用同一个叫法。RUN_COMPARE_TEXT 定义在本对象之后,模块初始化时还取不到,写成 getter 用到时再取
*/
get compareLink() { return RUN_COMPARE_TEXT.menu },
/**
* 基准不是紧挨着的上一次(上一次失败了、取消了,比的是再往前成功的那一次)时链接上看得见的叫法:触屏没有悬停,
* 键盘聚焦时也不显示 title,只在悬停里说明会让人以为比的就是上一次
* 键盘聚焦时也不显示 title,只在悬停里说明会让人以为比的就是上一次。运行详情「更多」里的菜单项点之前还不知道基准
* 是哪一次,这时仍叫「与上一次对比」,两处叫法不同;进了对比页都写「默认基准:同一定时任务上一次成功的运行」
*/
compareLinkLastOk: '与上一次成功的运行对比',
/**
@@ -3189,7 +3190,8 @@ export const RUN_COMPARE_TEXT = {
same_workflow: '默认基准:同一工作流、同一运行类别上一次成功的运行',
} as Record<string, string>,
pickBase: '更换基准',
pickHint: '同一工作流最近的运行,未封存的不能作为基准',
/** 默认基准只取成功结束的运行(pick_base),手动选不限:失败、取消的也能选,下拉和基准卡上写明它的状态 */
pickHint: '同一工作流最近的运行,未封存的不能作为基准;默认基准只取成功结束的运行',
pickLoading: '正在读取同一工作流的运行…',
pickFailed: '无法读取同一工作流的运行',
pickNone: '同一工作流没有其他运行',
@@ -3197,9 +3199,12 @@ export const RUN_COMPARE_TEXT = {
pickTargetGone: '本次运行的记录不存在,无法列出同一工作流的运行',
pickUnsaved: '工作流未保存或已删除,没有可对比的运行',
pickPlaceholder: '选择一次运行作为基准',
/** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */
pickOption: (time: string, cls: string, id: string, sealed: boolean) =>
`${time} · ${cls} · ${id}${sealed ? '' : '(未封存)'}`,
/**
* 下拉里的一次运行:时间 · 类别和版本 · 短 id,没有成功结束的写上状态(失败、已取消),没封存的另标。默认基准跳过的
* 正是这几种,手动挑时也得看得出哪次没成功
*/
pickOption: (time: string, cls: string, id: string, sealed: boolean, status?: string | null) =>
`${time} · ${cls} · ${id}${status ? ` · ${status}` : ''}${sealed ? '' : '(未封存)'}`,
/** 当前的基准读不到时下拉里的那一项(短 id):如实显示选的是哪一次 */
pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`,
// 页面状态
@@ -3222,7 +3227,11 @@ export const RUN_COMPARE_TEXT = {
errMissingHint: '链接可能已失效,请从记录页重新打开',
/** 本次的记录读得到、对比却回 404:缺的是基准 */
errBaseMissingHint: '可在上方选择另一次运行作为基准',
errNoBase: '未找到可作为基准的运行',
/**
* 找不到默认基准:标题只说没有默认基准,原因(服务端的 NO_BASE_TEXT:没有找到上一次成功的运行、要什么条件)跟在后面。
* 不能写成「没有可作为基准的运行」:失败、取消但封存了的运行不当默认基准,却照样能在上方手动选
*/
errNoBase: '没有默认基准',
errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准',
/** 下拉里一次能选的都没有(只有本次、或者都没封存) */
errNoBaseNone: '同一工作流还没有其他已封存的运行',
+16 -4
View File
@@ -1,8 +1,9 @@
import { useId, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftRight, CalendarClock, RotateCw } from 'lucide-react'
import { Skeleton, useTicker } from '../../../components/ui'
import { Skeleton, StatusPill, useTicker } from '../../../components/ui'
import { formatDateTime, formatTime, shortId } from '../../../lib/format'
import { statusLabel } from '../../../lib/status'
import { RUN_COMPARE_TEXT as T, runClassLabel, runName } from '../../../lib/terms'
import type { RunWithSchedule } from '../../../types'
import { ClassChip as RunClassChip } from '../parts'
@@ -11,7 +12,8 @@ import { isGone } from './model'
/**
* 顶部的两次运行:左边基准、右边本次(窄屏上下排),中间「互换」。每张卡写名字、时间、类别和版本,点名字进各自的
* 运行详情;没封存的标出来(对比会因此被拒)。基准卡底下是换基准的下拉:同一工作流最近的运行,没封存的不能选。
* 运行详情;没封存的标出来(对比会因此被拒),失败、取消的标出状态。基准卡底下是换基准的下拉:同一工作流最近的运行,
* 没封存的不能选。
*/
export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved, reason, onPick, onSwap }: {
target: string
@@ -49,6 +51,12 @@ export function RunPair({ target, base, targetRun, baseRun, candidates, unsaved,
)
}
/**
* 封存了、却没有成功结束的(失败、已取消):能手动选作基准,默认基准却跳过它(拿它当基准,口径卡没执行的指标都成了
* 「新增」「移除」)。下拉和卡上写明状态,人读懂「上一次成功的运行」之后自己挑得出成功的那一次
*/
const unsuccessful = (status: string | null | undefined) => status === 'failed' || status === 'cancelled'
function RunCard({ role, id, run, children }: {
role: 'base' | 'target'; id: string | null; run: Loaded<RunWithSchedule>; children?: ReactNode
}) {
@@ -63,6 +71,9 @@ function RunCard({ role, id, run, children }: {
{r?.schedule_id && (
<span className="chip" title={T.scheduled}><CalendarClock size={10} aria-hidden /> {T.scheduled}</span>
)}
{r && unsuccessful(r.status) && (
<span className="inline-flex" data-compare-run-status={r.status}><StatusPill status={r.status} /></span>
)}
{r && !r.manifest_hash && (
<span className="chip" title={T.unsealedHint} data-compare-unsealed=""
style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 40%, transparent)' }}>
@@ -113,7 +124,8 @@ function RetryButton({ onClick }: { onClick: () => void }) {
}
/**
* 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因。
* 换基准。列的是运行列表接口按工作流取回的最近几次(新的在前),去掉本次;没封存的列出来但不能选,写明原因;失败、
* 取消的能选,写明状态(默认基准不取它们)。
*
* 当前的基准不在这几次里(更早、属于别的工作流、读不到)时也单列一项:受控的下拉值对不上任何选项时浏览器会选中
* 第一项,看着像另一次运行被选中,而原生下拉再选当前显示的那一项不触发 change,最自然的补救点了没反应。读不到的
@@ -146,7 +158,7 @@ function BasePicker({ target, base, baseRun, targetRun, candidates, unsaved, onP
const list = (candidates.data ?? []).filter((r) => r.id !== target)
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)
runClassLabel(r.run_class, r.version), shortId(r.id), !!r.manifest_hash, unsuccessful(r.status) ? statusLabel(r.status) : null)
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))
+50 -9
View File
@@ -11,7 +11,8 @@
// 都不能出现对比页)。下一步的提示要和页面上真能做的事对得上:下拉里没有能换的运行就不叫人去上方换,本次的记录
// 不存在时不给注定失败的「重试」。默认基准的说法照服务端的挑法(api/compare.py 的 pick_base)写「上一次成功的运行」
// ——上一次失败了,基准是再往前成功的那一次;定时任务触发历史里的「与上一次对比」和运行详情里的同名入口打开同一对
// 运行、写同一句来历,基准不是紧挨着的上一次时触发历史上看得见的字写明「上一次成功的运行」。
// 运行、写同一句来历,基准不是紧挨着的上一次时触发历史上看得见的字写明「上一次成功的运行」;找不到默认基准时标题不说
// 「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。
//
// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、
// compare/base、compare、定时任务列表、发起它们的定时任务和触发历史都在浏览器层答掉,别的 GET 放行(外壳要的工作流、
@@ -190,6 +191,8 @@ const REASON_WORKFLOW = '默认基准:同一工作流、同一运行类别上
const FINDING = '正在查找上一次成功的运行'
/** 409、422、缺基准的 404 时下拉里没有能换的运行:不叫人去上方换,照实说 */
const NO_OTHER = '同一工作流没有其他可换作基准的已封存运行'
/** 服务端(api/compare.py 的 NO_BASE_TEXT)找不到默认基准时的原话 */
const NO_BASE_SERVER = '没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功结束并已封存的运行'
/**
* 发起本次和基准的定时任务(RUNS 里的 schedule_id)和它的触发历史:本次那一行带服务端自动对比的计数,基准是 B、不是
@@ -249,7 +252,7 @@ function defaults(opts) {
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: '没有找到可作为基准的运行' } }
: { status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } }
}
m = p.match(/^\/api\/runs\/([^/]+)\/compare$/)
if (m) {
@@ -531,7 +534,8 @@ await section('默认基准的说法:上一次成功的运行', async () => {
})
await section('运行详情与触发历史的「与上一次对比」:同一个基准、同一句来历', async () => {
// 两处的基准由服务端用同一个函数挑(api/compare.py 的 pick_base):上一次失败了,两处都是再往前成功的 B
// 两处的基准由服务端用同一个函数挑(api/compare.py 的 pick_base):上一次失败了,两处都是再往前成功的 B。两处真的
// 一致由 backend/tests/test_scheduler_compare.py 守,这里守前端两个入口都原样用服务端给的基准
const menu = await open(`/runs/${T}`)
await viaMenu(menu.page)
const hist = await open(`/schedules/${SCHEDULE_ID}?tab=history`)
@@ -871,6 +875,41 @@ await section('换基准、互换', async () => {
`${listsBefore} → ${lists()}`)
})
await section('换基准:失败、取消的运行写明状态,默认基准只取成功结束的', async () => {
// 默认基准跳过失败、取消的运行(服务端 pick_base),手动选不限:下拉和基准卡上得看得出哪次没成功,人读懂「上一次成功的
// 运行」之后才挑得出成功的那一次。两次都封存过,都能选
const F = 'c0ffee0failed0000000000000000005'
const C = 'c0ffee0cancel0000000000000000006'
RUNS[F] = runOf(F, { status: 'failed', version: 3, created_at: day('10-01'), started_at: day('10-01'), finished_at: '2026-10-01T01:01:00Z' })
RUNS[C] = runOf(C, { status: 'cancelled', version: 3, created_at: day('10-02'), started_at: day('10-02'), finished_at: '2026-10-02T01:01:00Z' })
try {
const { page, errors } = await open(comparePath(T, F))
await settled(page)
const picker = page.locator('select[data-compare-base-picker]')
await until(async () => (await picker.locator('option').count()) > 2)
const options = Object.fromEntries(await picker.locator('option').evaluateAll((els) =>
els.map((o) => [o.value, { text: o.textContent ?? '', disabled: o.disabled }])))
check('下拉:失败的那次写「失败」,照样能选', !!options[F]?.text.includes('· 失败') && !options[F].disabled, JSON.stringify(options[F]))
check('下拉:取消的那次写「已取消」,照样能选', !!options[C]?.text.includes('· 已取消') && !options[C].disabled, JSON.stringify(options[C]))
check('下拉:成功结束的不另写状态', !!options[B] && !/失败|已取消|已完成/.test(options[B].text), options[B]?.text)
const card = page.locator('[data-compare-run="base"]')
check('下拉下面的说明写明默认基准只取成功结束的运行', (await card.innerText()).includes('默认基准只取成功结束的运行'),
(await card.innerText()).replace(/\s+/g, ' '))
const status = card.locator('[data-compare-run-status]')
check('基准卡:失败的那次标出「失败」', await status.count() === 1 && (await status.innerText()).trim() === '失败',
await status.innerText().catch(() => '没有'))
check('本次卡:成功结束的不标状态', await page.locator('[data-compare-run="target"] [data-compare-run-status]').count() === 0)
await picker.selectOption(B)
await until(async () => new URL(page.url()).searchParams.get('base') === B)
await settled(page)
check('换成成功结束的那次:基准卡不再标状态', await until(async () => await card.locator('[data-compare-run-status]').count() === 0))
check('页面没有报错', errors.length === 0, errors.join(';'))
} finally {
delete RUNS[F]
delete RUNS[C]
}
})
await section('过时的答复不盖掉新的', async () => {
let release
const gate = new Promise((r) => { release = r })
@@ -958,15 +997,18 @@ await section('出错与找不到基准', async () => {
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: '同一工作流没有其他已封存的正式运行' } }) })
// 只带本次:页面自己找基准,找不到时写原因,下拉照样能选。标题只说没有默认基准:失败、取消但封存了的运行不当默认
// 基准,却照样能手动选,写成「没有可作为基准的运行」就和下面「可在上方选择」打架
const lone = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } }) })
await settled(lone.page, 'error')
const noBase = lone.page.locator('[data-compare-error="no-base"]')
check('找不到基准:写明原因', (await noBase.locator('[data-compare-error-title]').innerText()).includes('未找到可作为基准的运行')
&& (await noBase.innerText()).includes('同一工作流没有其他已封存的正式运行'))
check('找不到基准:标题写「没有默认基准」,后面跟服务端的原因', (await noBase.locator('[data-compare-error-title]').innerText()).trim() === '没有默认基准'
&& (await noBase.innerText()).includes(NO_BASE_SERVER), (await noBase.innerText()).replace(/\s+/g, ' '))
check('找不到基准:仍能手动选', await lone.page.locator('select[data-compare-base-picker]').isEnabled())
check('找不到基准、下拉里有能选的:说可在上方选', await until(async () => (await noBase.innerText()).includes('可在上方选择')),
(await noBase.innerText()).replace(/\s+/g, ' '))
check('找不到基准:不说「没有可作为基准的运行」这类和「可在上方选择」矛盾的话', !/可作为基准的运行|没有可以对比/.test(await noBase.innerText()),
(await noBase.innerText()).replace(/\s+/g, ' '))
// 同一工作流只有一次没封存的:下拉里没有能选的,不能再叫人去上方选
const bare = await open(comparePath(T), { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行', code: 'compare_no_base' } }),
list: () => ({ json: [RUNS[T], RUNS[U]] }) })
@@ -1071,8 +1113,7 @@ await section('不属于已保存工作流的运行:对比页的说明前后
try {
// 只带本次:服务端找不到基准(这种运行没有可比的)
const lone = await open(comparePath(T), { base: () => ({ status: 404, json: {
detail: '没有找到可以对比的上一次成功的运行:需要同一工作流、同一运行类别(定时任务发起的优先找同一定时任务)、比这次先发起、成功结束并已封存的运行',
code: 'compare_no_base' } }) })
detail: NO_BASE_SERVER, code: 'compare_no_base' } }) })
await settled(lone.page, 'error')
await until(async () => (await lone.page.locator('[data-compare-run="base"]').innerText()).includes('未保存或已删除'), 3000)
const card = (await lone.page.locator('[data-compare-run="base"]').innerText()).replace(/\s+/g, ' ')
+2 -1
View File
@@ -1509,7 +1509,8 @@ await section('详情:触发历史里的「与上一次对比」', async () =>
await section('详情:触发历史和运行详情的「与上一次对比」拿同一个基准', async () => {
// f10 的上一次(f11)失败了,服务端两处都挑再往前成功的 f12:自动对比和运行详情的默认基准用的是同一个函数
// (api/compare.py 的 pick_base)。这里守前端:两个入口都原样用服务端给的基准,打开同一对运行、写同一句来历
// (api/compare.py 的 pick_base),基准事后变了的旧对比触发历史不再给,这些由 backend/tests/test_scheduler_compare.py
// 守。这里守前端:两个入口都原样用服务端给的基准,打开同一对运行、写同一句来历
const runOf = (id, at) => ({ ...RUNS[0], id, started_by: '定时任务:门店周报 · 每周', created_at: iso(at), started_at: iso(at),
finished_at: iso(at + 4200), manifest_hash: 'ab'.repeat(32), manifest_seq: 2 })
const f10 = runOf('run-f10', nextMonday9(-11))