diff --git a/backend/app/api/compare.py b/backend/app/api/compare.py index a1f36123..44bf258e 100644 --- a/backend/app/api/compare.py +++ b/backend/app/api/compare.py @@ -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 = "作为基准的运行" diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index e45c2e0a..5ca0a2d1 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -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"])): diff --git a/backend/app/scheduler/auto_compare.py b/backend/app/scheduler/auto_compare.py index 575b0cfd..8ba0f332 100644 --- a/backend/app/scheduler/auto_compare.py +++ b/backend/app/scheduler/auto_compare.py @@ -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)) diff --git a/backend/tests/test_scheduler_compare.py b/backend/tests/test_scheduler_compare.py index 5ffc5f98..d1bcb71e 100644 --- a/backend/tests/test_scheduler_compare.py +++ b/backend/tests/test_scheduler_compare.py @@ -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): diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 33bbf293..a5bd8393 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -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, 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: '同一工作流还没有其他已封存的运行', diff --git a/frontend/src/pages/runs/compare/RunPair.tsx b/frontend/src/pages/runs/compare/RunPair.tsx index d04e27a0..6d076091 100644 --- a/frontend/src/pages/runs/compare/RunPair.tsx +++ b/frontend/src/pages/runs/compare/RunPair.tsx @@ -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; children?: ReactNode }) { @@ -63,6 +71,9 @@ function RunCard({ role, id, run, children }: { {r?.schedule_id && ( {T.scheduled} )} + {r && unsuccessful(r.status) && ( + + )} {r && !r.manifest_hash && ( @@ -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

{T.pickNone}

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)) diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index f41e06ea..d898a447 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -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, ' ') diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index 634fb9dc..ad8332d3 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -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))