From 6514db61183cfd192368321e0c06e9ad8ccf2b9f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 16:52:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E8=AF=A6=E6=83=85=E3=80=8C?= =?UTF-8?q?=E6=9B=B4=E5=A4=9A=E3=80=8D=E9=87=8C=E7=9A=84=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E6=8C=89=E9=BB=98=E8=AE=A4=E5=9F=BA=E5=87=86?= =?UTF-8?q?=E6=98=AF=E4=B8=8D=E6=98=AF=E7=B4=A7=E6=8C=A8=E7=9D=80=E7=9A=84?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E6=AC=A1=E5=AE=9A=E5=8F=AB=E6=B3=95=EF=BC=9A?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E6=AC=A1=E5=A4=B1=E8=B4=A5=E3=80=81=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E3=80=81=E8=BF=98=E6=B2=A1=E7=BB=93=E6=9D=9F=E6=97=B6?= =?UTF-8?q?=E5=86=99=E3=80=8C=E4=B8=8E=E4=B8=8A=E4=B8=80=E6=AC=A1=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E7=9A=84=E8=BF=90=E8=A1=8C=E5=AF=B9=E6=AF=94=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E5=92=8C=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E5=8E=86=E5=8F=B2=E9=87=8C=E7=9A=84=E5=90=8C=E5=90=8D?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E4=B8=80=E5=AD=97=E4=B8=8D=E5=B7=AE=E3=80=81?= =?UTF-8?q?=E7=82=B9=E8=BF=9B=E5=8E=BB=E6=98=AF=E5=90=8C=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=9F=BA=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拿到能对比的运行(已封存、属于已保存的工作流、不在运行中)就预先问一次 compare/base,按答复里的 previous 定叫法;按运行 id 加清单哈希记,失败后接着运行完重新封存了会再问。问到之前这一项只写「正在查找上一次成功的运行…」、不给点:菜单开着时不会先写一个叫法、问到了再换成另一个,也点不到说错了的入口。预先问出错、找不到默认基准都不提示,叫法退回「与上一次对比」,点了照常再问一次、照实说。点了总是再问一次、按那一次的答复跳:详情页可能开了很久,预先问到的基准不一定还是现在的。 类型 RunCompareBase 加 previous(老服务端不给,按 true 算,同 ScheduleFireCompare);两种叫法放在 RUN_COMPARE_TEXT(menu、menuLastOk),SCHEDULE_TEXT 的 compareLinkLastOk 和 compareLink 一样改成取它的 getter,过时的说明一并改掉。check-compare:夹具里本次的默认基准带 previous: false(和触发历史那一行同一个假定);上一次失败了、上一次就是成功的两种,运行详情和触发历史两个入口叫法一致、打开同一对运行;预先问的那一次压住时菜单项只写正在查找、问到后直接是最终叫法(记下菜单开着时出现过的每一种状态核对);预先问出错、找不到基准时不提示、点了照常再问;点了之后对比页不再自己找一遍 --- frontend/src/api/compare.ts | 4 +- frontend/src/lib/terms.ts | 11 +- frontend/src/pages/runs/RunDetailView.tsx | 7 +- frontend/src/pages/runs/compare/entry.tsx | 127 +++++++----- frontend/src/types.ts | 5 + scripts/check-compare.mjs | 231 +++++++++++++++++----- 6 files changed, 279 insertions(+), 106 deletions(-) diff --git a/frontend/src/api/compare.ts b/frontend/src/api/compare.ts index 9d29b2e1..cbcb6b5b 100644 --- a/frontend/src/api/compare.ts +++ b/frontend/src/api/compare.ts @@ -12,8 +12,8 @@ const run = (id: string) => `/runs/${encodeURIComponent(id)}` export const compare = { /** - * 默认的基准:同一定时任务的上一次运行,没有就同一工作流、同一运行类别的上一次。找不到时回 404,ApiError 的 - * message 是给人看的原因 + * 默认的基准:同一定时任务上一次成功并封存的运行,没有就同一工作流、同一运行类别的上一次。previous 说它是不是紧挨着 + * 的上一次(运行详情的菜单项按它定叫法)。找不到时回 404,ApiError 的 message 是给人看的原因 */ base: (runId: string, opts?: RequestOptions) => get(`${run(runId)}/compare/base`, opts), /** diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 5c0e6215..ee3a254b 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3119,10 +3119,10 @@ export const SCHEDULE_TEXT = { get compareLink() { return RUN_COMPARE_TEXT.menu }, /** * 基准不是紧挨着的上一次(上一次失败了、取消了,比的是再往前成功的那一次)时链接上看得见的叫法:触屏没有悬停, - * 键盘聚焦时也不显示 title,只在悬停里说明会让人以为比的就是上一次。运行详情「更多」里的菜单项点之前还不知道基准 - * 是哪一次,这时仍叫「与上一次对比」,两处叫法不同;进了对比页都写「默认基准:同一定时任务上一次成功的运行」 + * 键盘聚焦时也不显示 title,只在悬停里说明会让人以为比的就是上一次。运行详情「更多」里的菜单项按服务端给的 previous + * 用同一个叫法(同为 getter);进了对比页都写「默认基准:同一定时任务上一次成功的运行」 */ - compareLinkLastOk: '与上一次成功的运行对比', + get compareLinkLastOk() { return RUN_COMPARE_TEXT.menuLastOk }, /** * 悬停写各类指标的个数,和 Webhook 通知里那一行同一个写法(服务端 scheduler/auto_compare.py 的 summary_text): * 变化三分法的三类总写上,0 个也写;其余各类有才写,次序同对比页;两边都没有指标时不写一串 0 个。只有个数, @@ -3189,8 +3189,11 @@ export const SCHEDULE_TEXT = { export const RUN_COMPARE_TEXT = { title: '运行对比', hint: '把两次运行的指标变化拆成数据、口径、模板与 SQL 三类', - // 入口:运行详情的「更多」菜单 + // 入口:运行详情的「更多」菜单。叫法看服务端给的 previous(基准是不是紧挨着的上一次),和定时任务触发历史里的同名 + // 链接一个说法(SCHEDULE_TEXT.compareLink、compareLinkLastOk 取的就是这两条) menu: '与上一次对比', + /** 上一次失败、取消、还没结束:基准是再往前成功的那一次,不能说成「上一次」 */ + menuLastOk: '与上一次成功的运行对比', menuSealedOnly: '运行结束并封存后才能对比', /** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */ menuNoWorkflow: '工作流未保存或已删除,无法对比', diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index 271f0ef8..c725563c 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -63,8 +63,9 @@ export function RunDetailView({ runId, onChange, onDeleted }: { const [feedback, setFeedback] = useState(null) const streamBox = useRef(null) const now = useTicker(60_000) - // 「更多」里的「与上一次对比」:查找基准时的状态在这里挂着,菜单项等拿到运行之后再造(下面有提前返回) - const compareEntry = useCompareEntry() + // 「更多」里的「与上一次对比」:拿到能对比的运行就预先问默认基准(叫法看它是不是紧挨着的上一次),预先查找和点了 + // 之后查找中的状态都在这里挂着,所以放在下面的提前返回之前。live 同下面的 live + const compareItem = useCompareEntry(d.run, d.streaming || isLiveRun(d.run?.status)) useEffect(() => { if (d.state !== 'missing') return @@ -464,7 +465,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: { key: 'copy-link', label: '复制链接', icon: , onSelect: () => void copyText(`${location.origin}/runs/${run.id}`, '链接'), }, - compareEntry(run, live), + compareItem, { key: 'delete', label: '删除记录…', icon: , danger: true, onSelect: () => void remove(), disabled: live || busy === 'delete', diff --git a/frontend/src/pages/runs/compare/entry.tsx b/frontend/src/pages/runs/compare/entry.tsx index f0e55a3e..9a31d140 100644 --- a/frontend/src/pages/runs/compare/entry.tsx +++ b/frontend/src/pages/runs/compare/entry.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { GitCompareArrows } from 'lucide-react' import { ApiError, api } from '../../../api/client' @@ -16,6 +16,12 @@ export const comparePath = (target: string, base?: string | null) => /** 查找超过这么久才挂「正在查找」的提示:答复快的不闪一下 */ const NOTE_DELAY_MS = 300 +/** + * 预先问到的默认基准。key 是问的哪一次运行的哪一份封存(运行 id + 清单哈希:失败后接着运行完会重新封存,基准可能跟着 + * 变);previous 为 null 是没问到(找不到默认基准、出错),叫法退回「与上一次对比」,点了照常再问、照实说 + */ +interface Known { key: string; previous: boolean | null } + /** * 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务上一次成功的运行,没有就同一工作流、 * 同一类别上一次成功的运行;定时任务触发历史里同名的入口由服务端用同一个函数挑,两处比的是同一对运行),找到了再跳到 @@ -23,15 +29,23 @@ const NOTE_DELAY_MS = 300 * 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据 * 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。 * - * 菜单点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、 + * 叫法和触发历史里的链接一致:基准不是紧挨着的上一次(上一次失败、取消、还没结束)时写「与上一次成功的运行对比」。 + * 点之前就得知道,所以拿到能对比的运行就预先问一次(previous)。问到之前这一项只写「正在查找」、不给点:菜单开着时不会 + * 先写一个叫法、问到了再换成另一个,也点不到一个说错了的入口;平常运行一打开就问,人点开菜单时早已问到。预先问不到 + * (找不到默认基准、出错)不提示,叫法退回「与上一次对比」,点了照常再问一次、照实说。 + * + * 点了总是再问一次、按这一次的答复跳:详情页可能开了很久,中间有运行接着跑完、被删掉,基准跟着变,预先问到的不一定还是 + * 现在的。菜单点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、 * 收起提示,晚到的答复不再把人带去对比页。卸载取消不了全部:地址已经换了、新页面还没画出来(路由按过渡渲染)的那一下 * 答复回来,组件还在,请求也没取消。所以答复回来时再看一眼地址:人已经不在点菜单时的那个运行详情上,就不跳、也不提示。 * - * 写成 hook(返回造菜单项的函数):运行详情里有提前返回,菜单项本身只能在拿到运行之后造,查找中的状态得在那之前就挂上 + * 写成 hook、在运行详情的提前返回之前调用:预先查找和点了之后查找中的状态都得在拿到运行之前就挂上。run 为空(还在读 + * 运行记录)时交回的这一项不会被画出来 */ -export function useCompareEntry(): (run: Run, live: boolean) => MenuItem { +export function useCompareEntry(run: Run | null, live: boolean): MenuItem { const navigate = useNavigate() const [busy, setBusy] = useState(false) + const [known, setKnown] = useState(null) // 在路上的那一次:stop 收起(或者不再挂出)「正在查找」的提示 const pending = useRef<{ ctrl: AbortController; stop: () => void } | null>(null) useEffect(() => () => { @@ -40,48 +54,67 @@ export function useCompareEntry(): (run: Run, live: boolean) => MenuItem { p?.ctrl.abort() p?.stop() }, []) - return useCallback((run: Run, live: boolean): MenuItem => { - const orphan = !run.workflow_id - const unsealed = live || !run.manifest_hash - return { - key: 'compare', - label: T.menu, - icon: , - disabled: orphan || unsealed || busy, - hint: orphan ? T.menuNoWorkflow : unsealed ? T.menuSealedOnly : busy ? T.menuFinding : undefined, - onSelect: () => { - const ctrl = new AbortController() - // 点菜单时所在的运行详情。只比路径:同一页里换视图、改列表筛选(改查询串)不算离开 - const from = window.location.pathname - const left = () => ctrl.signal.aborted || window.location.pathname !== from - 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) + + const orphan = !run?.workflow_id + const unsealed = live || !run?.manifest_hash + // 能对比的才预先问:不能对比的入口本来就不给点,问了也是 409、422 + const target = run && !orphan && !unsealed ? run.id : null + const key = target ? `${target}:${run?.manifest_hash}` : null + useEffect(() => { + if (!target || !key) return + const ctrl = new AbortController() + api.compare.base(target, { signal: ctrl.signal }).then( + (found) => setKnown({ key, previous: found.previous !== false }), + () => { if (!ctrl.signal.aborted) setKnown({ key, previous: null }) }, + ) + return () => ctrl.abort() + }, [target, key]) + + const resolved = known && known.key === key ? known : null + // 预先问的那一次还没回来:这一项只写正在查找,不先写一个叫法、问到了再换 + const asking = !!key && !resolved + return { + key: 'compare', + label: asking ? T.menuFinding : resolved?.previous === false ? T.menuLastOk : T.menu, + icon: , + disabled: orphan || unsealed || asking || busy, + hint: orphan ? T.menuNoWorkflow : unsealed ? T.menuSealedOnly : busy ? T.menuFinding : undefined, + onSelect: () => { + if (!run || !key) return + const ctrl = new AbortController() + // 点菜单时所在的运行详情。只比路径:同一页里换视图、改列表筛选(改查询串)不算离开 + const from = window.location.pathname + const left = () => ctrl.signal.aborted || window.location.pathname !== from + 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, { signal: ctrl.signal }).then((found) => { + if (left()) return + // 这一次的答复也定叫法:跳不走(地址没换成)时菜单上写的和刚问到的一致 + setKnown({ key, previous: found.previous !== false }) + const via: CompareVia = { base: found.base_run_id, reason: found.reason } + navigate(comparePath(run.id, found.base_run_id), { state: via }) + }, (e: unknown) => { + if (left()) return + if (e instanceof ApiError && e.kind === 'http' && e.status === 404) { + setKnown({ key, previous: null }) + toast.info(e.message, { action: { label: T.pickManually, onClick: () => navigate(comparePath(run.id)) } }) + } else { + toast.error(e) } - pending.current = { ctrl, stop } - setBusy(true) - api.compare.base(run.id, { signal: ctrl.signal }).then((found) => { - if (left()) 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 (left()) 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(() => { - stop() - if (pending.current?.ctrl !== ctrl) return - pending.current = null - setBusy(false) - }) - }, - } - }, [navigate, busy]) + }).finally(() => { + stop() + if (pending.current?.ctrl !== ctrl) return + pending.current = null + setBusy(false) + }) + }, + } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4571b97a..72a7315c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3436,6 +3436,11 @@ export interface RunCompareBase { base_run_id: string reason: RunCompareBaseReason | (string & {}) base: RunCompareRef + /** + * 基准是不是紧挨着的上一次(服务端 pick_base 算的,同触发历史里自动对比的 previous):上一次失败、取消、还没结束时 + * 为 false,运行详情「更多」里的入口据此写「与上一次成功的运行对比」。老服务端不给,按 true 算 + */ + previous?: boolean } /** diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index d898a447..d742f569 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -11,8 +11,9 @@ // 都不能出现对比页)。下一步的提示要和页面上真能做的事对得上:下拉里没有能换的运行就不叫人去上方换,本次的记录 // 不存在时不给注定失败的「重试」。默认基准的说法照服务端的挑法(api/compare.py 的 pick_base)写「上一次成功的运行」 // ——上一次失败了,基准是再往前成功的那一次;定时任务触发历史里的「与上一次对比」和运行详情里的同名入口打开同一对 -// 运行、写同一句来历,基准不是紧挨着的上一次时触发历史上看得见的字写明「上一次成功的运行」;找不到默认基准时标题不说 -// 「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。 +// 运行、写同一句来历,基准不是紧挨着的上一次时两处看得见的字都写明「与上一次成功的运行对比」(运行详情一打开就预先问 +// 默认基准,问到之前菜单项不给点,不先写一个叫法再换成另一个;预先问不到时不提示,点了照常再问);找不到默认基准时 +// 标题不说「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。 // // 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、 // compare/base、compare、定时任务列表、发起它们的定时任务和触发历史都在浏览器层答掉,别的 GET 放行(外壳要的工作流、 @@ -193,6 +194,9 @@ const FINDING = '正在查找上一次成功的运行' const NO_OTHER = '同一工作流没有其他可换作基准的已封存运行' /** 服务端(api/compare.py 的 NO_BASE_TEXT)找不到默认基准时的原话 */ const NO_BASE_SERVER = '没有找到上一次成功的运行:需要同一工作流、同一运行类别里比这次先发起、成功结束并已封存的运行' +/** 运行详情「更多」里对比入口的两种叫法,和触发历史里的链接一致:基准是紧挨着的上一次时、不是时(上一次失败了) */ +const MENU = '与上一次对比' +const MENU_LAST_OK = '与上一次成功的运行对比' /** * 发起本次和基准的定时任务(RUNS 里的 schedule_id)和它的触发历史:本次那一行带服务端自动对比的计数,基准是 B、不是 @@ -211,6 +215,11 @@ const FIRES = [{ id: FIRE_T, fire_at: day('10-05'), kind: 'scheduled', status: 'started', run_id: T, run_status: 'succeeded', reason: null, notified: [], created_at: day('10-05'), compare: { base_run_id: B, previous: false, summary: SUMMARY }, }] +/** + * compare/base 给本次的答复:基准是 B,不是紧挨着的上一次(previous: false),同触发历史那一行的假定。服务端两处用 + * 同一个函数挑(api/compare.py 的 pick_base),运行详情「更多」里的入口据此和触发历史写同一个叫法 + */ +const BASE_OF_T = { base_run_id: B, reason: 'same_schedule', base: refOf(B), previous: false } // ---------------------------------------------------------------- 假接口 @@ -234,7 +243,7 @@ const RECORD_GONE = { status: 404, json: { detail: '运行记录不存在' } } /** * 默认的假接口。opts.base(runId) / opts.compare(target, base, n) / opts.run(runId, n) / opts.list(workflowId) 可以换掉 * 对应接口的答复(返回 null 的照默认答),返回 {status, json, wait}(wait 是一个 Promise:等它落定再答,用来压住 - * 某一次请求)。不在夹具里的运行一律答 404,不放行到服务端 + * 某一次请求);opts.fires 换掉触发历史。不在夹具里的运行一律答 404,不放行到服务端 */ function defaults(opts) { return async (method, url, n) => { @@ -251,8 +260,7 @@ function defaults(opts) { let m = p.match(/^\/api\/runs\/([^/]+)\/compare\/base$/) if (m) { if (opts.base) return opts.base(m[1]) - return m[1] === T ? { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } } - : { status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } } + return m[1] === T ? { json: BASE_OF_T } : { status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } } } m = p.match(/^\/api\/runs\/([^/]+)\/compare$/) if (m) { @@ -275,7 +283,7 @@ function defaults(opts) { if (p === '/api/approvals' && url.searchParams.get('run_id')) return { json: [] } // 发起本次的定时任务和它的触发历史(只有直接打开那个定时任务的页面时才用到) if (p === `/api/schedules/${SCHEDULE_ID}`) return { json: SCHEDULE } - if (p === `/api/schedules/${SCHEDULE_ID}/fires`) return { json: { items: FIRES, next_before: null } } + if (p === `/api/schedules/${SCHEDULE_ID}/fires`) return { json: { items: opts.fires ?? FIRES, next_before: null } } // 查找基准期间离开运行详情去的那一页(定时任务列表):给空的列表,页面落定不靠服务端里有什么 if (p === '/api/schedules') return { json: { items: [] } } return null @@ -363,25 +371,39 @@ const shot = async (page, name) => { // ---------------------------------------------------------------- 检查 +/** 运行详情里问了几次默认基准 */ +const askedBase = (log, run = T) => log.filter((k) => k === `GET /api/runs/${run}/compare/base`).length + await section('从运行详情进入', async () => { const { page, log, blocked, errors } = await open(`/runs/${T}`) await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) // 走过的地址(含 pushState / replaceState):菜单要先问到基准再跳,不能先跳到只带本次的地址、再让对比页去找 const visited = [] page.on('framenavigated', (f) => { if (f === page.mainFrame()) visited.push(f.url()) }) - await page.getByRole('button', { name: '更多操作' }).click() + const more = page.getByRole('button', { name: '更多操作' }) + await more.click() const item = page.locator('[data-menu=compare]') - check('「更多」菜单里有「与上一次对比」,可点', await item.count() === 1 && (await item.innerText()).includes('与上一次对比') - && !(await item.isDisabled())) + await until(async () => await item.count() === 1 && !(await item.isDisabled()), 3000) + // 菜单开合几次:叫法已经定了,不再一开菜单就问一遍 + await page.keyboard.press('Escape') + await more.click() + await page.waitForTimeout(300) + check('打开运行详情就预先问一次默认基准(定菜单的叫法),菜单开合不再问', askedBase(log) === 1, `${askedBase(log)} 次`) + const label = (await item.innerText()).trim() + check('「更多」菜单里有对比入口,可点;基准不是紧挨着的上一次,写「与上一次成功的运行对比」', + await item.count() === 1 && label === MENU_LAST_OK && !(await item.isDisabled()), label) + const clickAt = log.length await item.click() await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) await settled(page) const url = new URL(page.url()) check('跳到 /runs/compare,带着本次和基准', url.searchParams.get('target') === T && url.searchParams.get('base') === B, url.search) - const iBase = log.indexOf(`GET /api/runs/${T}/compare/base`) - const iCmp = log.indexOf(`GET /api/runs/${T}/compare?base=${B}`) - check('先请求 compare/base,再请求 compare', iBase >= 0 && iCmp > iBase, `${iBase} → ${iCmp}`) - check('compare/base 只请求一次(页面不再自己找一遍)', log.filter((k) => k.startsWith(`GET /api/runs/${T}/compare/base`)).length === 1) + // 点了之后再问一次:详情页可能开了很久,预先问到的基准不一定还是现在的 + const after = log.slice(clickAt) + const iBase = after.indexOf(`GET /api/runs/${T}/compare/base`) + const iCmp = after.indexOf(`GET /api/runs/${T}/compare?base=${B}`) + check('点了之后先请求 compare/base,再请求 compare', iBase >= 0 && iCmp > iBase, `${iBase} → ${iCmp}`) + check('点了之后 compare/base 只请求一次(对比页不再自己找一遍)', askedBase(after) === 1, `${askedBase(after)} 次`) const compareUrls = visited.map((u) => new URL(u)).filter((u) => u.pathname === '/runs/compare') check('菜单问到基准之后才跳,一步到带基准的地址', compareUrls.length >= 1 && compareUrls.every((u) => u.searchParams.get('base') === B), compareUrls.map((u) => u.search).join(' → ')) @@ -398,18 +420,19 @@ const awaySettled = (page) => page.locator('[data-schedules-page="list"] [data-s const NO_BASE = '同一工作流没有其他已封存的正式运行' /** - * 在运行详情里点「与上一次对比」,compare/base 的答复(answer,默认找到 B)先压住不放。交回页面、放行的函数、这一请求 - * 了结的 Promise(答完或者被取消,交回那个请求)、点了之后有没有挂出「正在查找」的提示,和点了之后走过的路径 - * (framenavigated 连 pushState、replaceState 都算):晚到的答复哪怕只让页面在对比页一闪而过、又被别的跳转盖掉, - * 走过的路径里也留着 + * 在运行详情里点「与上一次对比」,点了之后那一次 compare/base 的答复(answer,默认找到 B)先压住不放;打开详情时预先 + * 问的那一次照常答(同一个答复),菜单项才可点。交回页面、放行的函数、点了之后那一请求了结的 Promise(答完或者被取消, + * 交回那个请求)、点了之后有没有挂出「正在查找」的提示,和点了之后走过的路径(framenavigated 连 pushState、 + * replaceState 都算):晚到的答复哪怕只让页面在对比页一闪而过、又被别的跳转盖掉,走过的路径里也留着 */ -async function findingBase(answer = { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } }) { +async function findingBase(answer = { json: BASE_OF_T }) { let release const gate = new Promise((r) => { release = r }) - const opened_ = await open(`/runs/${T}`, { base: () => ({ ...answer, wait: gate }) }) + let clicked = false + const opened_ = await open(`/runs/${T}`, { base: () => (clicked ? { ...answer, wait: gate } : answer) }) const { page, log } = opened_ const ended = new Promise((resolve) => { - const done = (req) => { if (new URL(req.url()).pathname.endsWith('/compare/base')) resolve(req) } + const done = (req) => { if (clicked && new URL(req.url()).pathname.endsWith('/compare/base')) resolve(req) } page.on('requestfinished', done) page.on('requestfailed', done) }) @@ -417,10 +440,15 @@ async function findingBase(answer = { json: { base_run_id: B, reason: 'same_sche const visited = [] page.on('framenavigated', (f) => { if (f === page.mainFrame()) visited.push(new URL(f.url()).pathname) }) await page.getByRole('button', { name: '更多操作' }).click() - await page.locator('[data-menu=compare]').click() + const item = page.locator('[data-menu=compare]') + // 预先问的那一次答完,菜单项才可点 + await until(async () => !(await item.isDisabled()), 3000) + const asked = askedBase(log) + clicked = true + await item.click() // 菜单点完就收起了:查找期间要有一条看得见的提示,接口慢的时候不像点了没反应 const noted = await until(async () => await page.locator('[data-toast]', { hasText: FINDING }).count() === 1, 3000) - await until(async () => log.includes(`GET /api/runs/${T}/compare/base`), 3000) + await until(async () => askedBase(log) > asked, 3000) return { ...opened_, release, ended, noted, visited } } @@ -505,19 +533,26 @@ await section('未封存的运行:入口不可点并说明原因', async () => } }) -/** 在运行详情里点「更多」→「与上一次对比」,等对比页落定 */ +/** + * 在运行详情里点「更多」→ 对比入口,等对比页落定。交回点之前菜单项上看得见的字(预先问到默认基准、可点之后的叫法), + * 和这期间菜单项上出现过的每一种「可点 + 字」(菜单开着时叫法换没换过) + */ async function viaMenu(page) { await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) await page.getByRole('button', { name: '更多操作' }).click() - await page.locator('[data-menu=compare]').click() + const item = page.locator('[data-menu=compare]') + await until(async () => !(await item.isDisabled()), 3000) + const label = (await item.innerText()).trim() + await item.click() await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) await settled(page) + return label } await section('默认基准的说法:上一次成功的运行', async () => { // 服务端只挑比本次先发起、成功结束并封存的运行:上一次失败、取消了时基准是再往前成功的那一次,「上一次运行」是错的 for (const [reason, want] of [['same_schedule', REASON], ['same_workflow', REASON_WORKFLOW]]) { - const base = () => ({ json: { base_run_id: B, reason, base: refOf(B) } }) + const base = () => ({ json: { ...BASE_OF_T, reason } }) const menu = await open(`/runs/${T}`, { base }) await viaMenu(menu.page) const fromMenu = (await maybeText(menu.page.locator('[data-compare-reason]'))).trim() @@ -533,31 +568,127 @@ await section('默认基准的说法:上一次成功的运行', async () => { } }) -await section('运行详情与触发历史的「与上一次对比」:同一个基准、同一句来历', async () => { - // 两处的基准由服务端用同一个函数挑(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`) - const link = hist.page.locator(`[data-fire-id="${FIRE_T}"] [data-fire-compare]`) - await link.waitFor({ timeout: 10000 }) - const label = (await link.locator('[data-fire-compare-label]').innerText()).trim() - check('触发历史:基准不是紧挨着的上一次,链接上看得见的字写「与上一次成功的运行对比」', label === '与上一次成功的运行对比', label) - await link.click() - await hist.page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) - await settled(hist.page) - const [a, b] = [new URL(menu.page.url()), new URL(hist.page.url())] - check('两个入口打开的是同一对运行(本次和基准都一样)', a.searchParams.get('target') === T && b.searchParams.get('target') === T - && a.searchParams.get('base') === B && b.searchParams.get('base') === B, `${a.search} | ${b.search}`) - const [ra, rb] = [(await maybeText(menu.page.locator('[data-compare-reason]'))).trim(), - (await maybeText(hist.page.locator('[data-compare-reason]'))).trim()] - check('两个入口写的默认基准一字不差:同一定时任务上一次成功的运行', ra === REASON && rb === REASON, `${ra} | ${rb}`) - const tiles = (page) => page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) => - els.map((e) => `${e.getAttribute('data-compare-class')}=${e.querySelector('[data-compare-count]')?.textContent?.trim()}`).join(' ')) - const [ta, tb] = [await tiles(menu.page), await tiles(hist.page)] - check('两边的变化摘要一致', !!ta && ta === tb, `${ta} | ${tb}`) - check('没有发出写请求', ![...menu.blocked, ...hist.blocked].length, [...menu.blocked, ...hist.blocked].join(';')) - check('页面没有报错', ![...menu.errors, ...hist.errors].length, [...menu.errors, ...hist.errors].join(';')) +await section('运行详情与触发历史的「与上一次对比」:同一个基准、同一个叫法、同一句来历', async () => { + // 两处的基准和 previous 由服务端用同一个函数挑(api/compare.py 的 pick_base):上一次失败了,两处都是再往前成功的 B、 + // 都不是紧挨着的上一次。两处真的一致由 backend/tests/test_scheduler_compare.py 守,这里守前端两个入口都原样用服务端 + // 给的基准和 previous:看得见的叫法一样,点进去比的是同一对运行 + for (const [what, previous, want] of [['上一次失败了', false, MENU_LAST_OK], ['上一次就是成功的', true, MENU]]) { + const base = () => ({ json: { ...BASE_OF_T, previous } }) + const fires = FIRES.map((f) => ({ ...f, compare: { ...f.compare, previous } })) + const menu = await open(`/runs/${T}`, { base, fires }) + const menuLabel = await viaMenu(menu.page) + const hist = await open(`/schedules/${SCHEDULE_ID}?tab=history`, { base, fires }) + const link = hist.page.locator(`[data-fire-id="${FIRE_T}"] [data-fire-compare]`) + await link.waitFor({ timeout: 10000 }) + const label = (await link.locator('[data-fire-compare-label]').innerText()).trim() + check(`${what}:运行详情「更多」里的入口和触发历史的链接叫法一致,都写「${want}」`, menuLabel === want && label === want, + `${menuLabel} | ${label}`) + await link.click() + await hist.page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) + await settled(hist.page) + const [a, b] = [new URL(menu.page.url()), new URL(hist.page.url())] + check(`${what}:两个入口打开的是同一对运行(本次和基准都一样)`, a.searchParams.get('target') === T + && b.searchParams.get('target') === T && a.searchParams.get('base') === B && b.searchParams.get('base') === B, + `${a.search} | ${b.search}`) + const [ra, rb] = [(await maybeText(menu.page.locator('[data-compare-reason]'))).trim(), + (await maybeText(hist.page.locator('[data-compare-reason]'))).trim()] + check(`${what}:两个入口写的默认基准一字不差:同一定时任务上一次成功的运行`, ra === REASON && rb === REASON, `${ra} | ${rb}`) + const tiles = (page) => page.locator('[data-compare-summary] [data-compare-class]').evaluateAll((els) => + els.map((e) => `${e.getAttribute('data-compare-class')}=${e.querySelector('[data-compare-count]')?.textContent?.trim()}`).join(' ')) + const [ta, tb] = [await tiles(menu.page), await tiles(hist.page)] + check(`${what}:两边的变化摘要一致`, !!ta && ta === tb, `${ta} | ${tb}`) + check(`${what}:没有发出写请求`, ![...menu.blocked, ...hist.blocked].length, [...menu.blocked, ...hist.blocked].join(';')) + check(`${what}:页面没有报错`, ![...menu.errors, ...hist.errors].length, [...menu.errors, ...hist.errors].join(';')) + for (const c of opened) await c.close().catch(() => {}) + opened.clear() + } +}) + +/** + * 菜单项上出现过的每一种状态(可点与否 + 看得见的字),按先后、去掉相邻重复的。挂在页面上,菜单开合、叫法换了都记下: + * 「先写一个叫法、问到了再换成另一个」要从这里看出来 + */ +async function watchItem(page) { + await page.evaluate(() => { + const seen = [] + window.__compareItem = seen + const look = () => { + const el = document.querySelector('[data-menu=compare]') + if (!el) return + const state = `${el.disabled ? '不可点' : '可点'}:${el.innerText.replace(/\s+/g, ' ').trim()}` + if (seen[seen.length - 1] !== state) seen.push(state) + } + new MutationObserver(look).observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true }) + look() + }) + return () => page.evaluate(() => window.__compareItem ?? []) +} + +await section('菜单的叫法:问到默认基准之前不给点,不先写一个叫法再换成另一个', async () => { + // 预先问的那一次压住:菜单开着时等它,问到之前这一项只写「正在查找」、不给点,问到了直接是最终的叫法 + let release + const gate = new Promise((r) => { release = r }) + const { page, log, errors } = await open(`/runs/${T}`, { base: () => ({ json: BASE_OF_T, wait: gate }), waitUntil: 'load' }) + await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + await until(async () => askedBase(log) === 1, 3000) + const states = await watchItem(page) + await page.getByRole('button', { name: '更多操作' }).click() + const item = page.locator('[data-menu=compare]') + await item.waitFor() + const waiting = (await item.innerText()).replace(/\s+/g, ' ').trim() + check('问到之前打开菜单:这一项不可点,只写正在查找上一次成功的运行,不先写一个叫法', await item.isDisabled() + && waiting.startsWith(FINDING) && !waiting.includes(MENU) && !waiting.includes(MENU_LAST_OK), waiting) + release() + await until(async () => !(await item.isDisabled()), 3000) + const label = (await item.innerText()).trim() + check('问到之后:可点,直接是照基准定的叫法(上一次失败了:「与上一次成功的运行对比」)', label === MENU_LAST_OK, label) + // 菜单开着的整个过程:除了「正在查找」,只出现过最终的叫法,而且出现时就可点 + const seen = await states() + const named = seen.filter((t) => !t.includes(FINDING)) + check('菜单开着的整个过程里叫法没有换过:除了「正在查找」只出现过最终的叫法', named.length === 1 + && named[0] === `可点:${MENU_LAST_OK}`, seen.join(' → ')) + check('预先问只问了一次', askedBase(log) === 1, `${askedBase(log)} 次`) + check('页面没有报错', errors.length === 0, errors.join(';')) +}) + +await section('预先问不到默认基准:不提示,叫法退回「与上一次对比」,点了照常再问', async () => { + // 服务端出错:打开详情时不弹报错(人还没点),点了再问一次,这次问到了就照常跳 + { + let n = 0 + const { page, log, errors } = await open(`/runs/${T}`, { base: () => (++n === 1 + ? { status: 500, json: { detail: '读取运行记录时出错' } } : { json: BASE_OF_T }) }) + await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + await page.getByRole('button', { name: '更多操作' }).click() + const item = page.locator('[data-menu=compare]') + await until(async () => !(await item.isDisabled()), 3000) + const label = (await item.innerText()).trim() + check('预先问出错:不弹报错,入口可点,叫法退回「与上一次对比」', label === MENU + && await page.locator('[data-toast]', { hasText: '读取运行记录时出错' }).count() === 0, label) + await item.click() + await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 }) + await settled(page) + check('……点了再问一次,问到了照常跳到对比页', new URL(page.url()).searchParams.get('base') === B && askedBase(log) === 2, + `${page.url()};问了 ${askedBase(log)} 次`) + check('……页面没有报错', errors.length === 0, errors.join(';')) + } + // 找不到默认基准(404):打开详情时不提示,点了才说原因、给「手动选择基准」 + { + const { page, log, errors } = await open(`/runs/${T}`, { base: () => ({ status: 404, json: { detail: NO_BASE_SERVER, code: 'compare_no_base' } }) }) + await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + await until(async () => askedBase(log) === 1, 3000) + await page.waitForTimeout(300) + const toast = page.locator('[data-toast]', { hasText: NO_BASE_SERVER }) + const quiet = await toast.count() === 0 + await page.getByRole('button', { name: '更多操作' }).click() + const item = page.locator('[data-menu=compare]') + await until(async () => !(await item.isDisabled()), 3000) + const label = (await item.innerText()).trim() + check('找不到默认基准:打开详情时不提示,入口可点,叫法是「与上一次对比」', quiet && label === MENU, label) + await item.click() + check('……点了再问一次,照实说原因、留在原页', await until(() => toast.count().then((c) => c === 1)) + && askedBase(log) === 2 && new URL(page.url()).pathname === `/runs/${T}`, `问了 ${askedBase(log)} 次`) + check('……页面没有报错', errors.length === 0, errors.join(';')) + } }) await section('两次运行与摘要', async () => {