diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 36adf24c..d5ff857c 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3115,6 +3115,8 @@ export const RUN_COMPARE_TEXT = { pickLoading: '正在读取同一工作流的运行…', pickFailed: '无法读取同一工作流的运行', pickNone: '同一工作流没有其他运行', + /** 本次的运行记录不存在:不知道是哪个工作流,列不出候选,重试也是一样 */ + pickTargetGone: '本次运行的记录不存在,无法列出同一工作流的运行', pickUnsaved: '工作流未保存或已删除,没有可对比的运行', pickPlaceholder: '选择一次运行作为基准', /** 下拉里的一次运行:时间 · 类别和版本 · 短 id,没封存的另标 */ @@ -3146,6 +3148,8 @@ export const RUN_COMPARE_TEXT = { errNoBaseHint: '可在上方选择同一工作流的其他运行作为基准', /** 下拉里一次能选的都没有(只有本次、或者都没封存) */ errNoBaseNone: '同一工作流还没有其他已封存的运行', + /** 409、422、404 时下拉里除了本次和当前的基准,没有别的已封存的运行:换基准这条路走不通,照实说 */ + errNoOtherBase: '同一工作流没有其他可换作基准的已封存运行', viewBase: '查看基准运行', viewTarget: '查看本次运行', // 摘要 diff --git a/frontend/src/pages/runs/ComparePage.tsx b/frontend/src/pages/runs/ComparePage.tsx index 2e28c161..f56f49d7 100644 --- a/frontend/src/pages/runs/ComparePage.tsx +++ b/frontend/src/pages/runs/ComparePage.tsx @@ -68,7 +68,9 @@ export function ComparePage() { 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, + // 能换的:已封存、不是本次、也不是当前的基准(409 时出问题的可能正是它) + pickable: candidates.state === 'ok' + ? (candidates.data ?? []).some((r) => r.id !== target && r.id !== base && !!r.manifest_hash) : null, } return ( diff --git a/frontend/src/pages/runs/compare/RunPair.tsx b/frontend/src/pages/runs/compare/RunPair.tsx index b32719d8..d04e27a0 100644 --- a/frontend/src/pages/runs/compare/RunPair.tsx +++ b/frontend/src/pages/runs/compare/RunPair.tsx @@ -120,7 +120,7 @@ function RetryButton({ onClick }: { onClick: () => void }) { * 写短 id 加原因,下拉如实反映当前选的是哪一次,其余每一项都能触发 change。 * * 列不出候选时说清楚、给重试,不停在「正在读取」:同一工作流的运行没取到;或者本次的记录没读到,不知道是哪个工作流 - * (候选的键是空的,不会自己去取) + * (候选的键是空的,不会自己去取)。本次的记录根本不存在(404)时重试也是一样,只说不存在、不给重试 */ function BasePicker({ target, base, baseRun, targetRun, candidates, unsaved, onPick }: { target: string; base: string | null; baseRun: Loaded; targetRun: Loaded @@ -129,6 +129,9 @@ function BasePicker({ target, base, baseRun, targetRun, candidates, unsaved, onP const id = useId() const now = useTicker(60_000) if (unsaved) return

{T.pickUnsaved}

+ if (candidates.state === 'idle' && targetRun.state === 'error' && isGone(targetRun.error)) { + return

{T.pickTargetGone}

+ } const failed = candidates.state === 'error' ? candidates.reload : candidates.state === 'idle' && targetRun.state === 'error' ? targetRun.reload : null if (failed) { diff --git a/frontend/src/pages/runs/compare/entry.tsx b/frontend/src/pages/runs/compare/entry.tsx index 22b64843..cee05d99 100644 --- a/frontend/src/pages/runs/compare/entry.tsx +++ b/frontend/src/pages/runs/compare/entry.tsx @@ -23,7 +23,8 @@ const NOTE_DELAY_MS = 300 * 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。 * * 菜单点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、 - * 收起提示,晚到的答复不再把人带去对比页。 + * 收起提示,晚到的答复不再把人带去对比页。卸载取消不了全部:地址已经换了、新页面还没画出来(路由按过渡渲染)的那一下 + * 答复回来,组件还在,请求也没取消。所以答复回来时再看一眼地址:人已经不在点菜单时的那个运行详情上,就不跳、也不提示。 * * 写成 hook(返回造菜单项的函数):运行详情里有提前返回,菜单项本身只能在拿到运行之后造,查找中的状态得在那之前就挂上 */ @@ -49,6 +50,9 @@ export function useCompareEntry(): (run: Run, live: boolean) => MenuItem { 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}` }) @@ -60,11 +64,11 @@ export function useCompareEntry(): (run: Run, live: boolean) => MenuItem { pending.current = { ctrl, stop } setBusy(true) api.compare.base(run.id, { signal: ctrl.signal }).then((found) => { - if (ctrl.signal.aborted) return + 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 (ctrl.signal.aborted) return + 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 { diff --git a/frontend/src/pages/runs/compare/parts.tsx b/frontend/src/pages/runs/compare/parts.tsx index 944ef95a..67d4c103 100644 --- a/frontend/src/pages/runs/compare/parts.tsx +++ b/frontend/src/pages/runs/compare/parts.tsx @@ -67,10 +67,18 @@ export interface CompareErrorContext { /** 本次、基准的运行记录是不是 404 */ targetGone: boolean baseGone: boolean - /** 换基准的下拉里有没有能选的运行(已封存、不是本次);还不知道(在读、读失败)时为 null */ + /** 换基准的下拉里有没有能换的运行(已封存、不是本次、也不是当前的基准);还不知道(在读、读失败)时为 null */ pickable: boolean | null } +/** + * 「可在上方选择」这类下一步只在下拉里真有能换的运行时说:一次都没有时照实说没有(找默认基准时说「还没有」,换基准时说 + * 「没有其他可换的」),还不知道(在读、读失败)时先不说 + */ +function pickHint(ctx: CompareErrorContext, hint: string, none: string): string | null { + return ctx.pickable == null ? null : ctx.pickable ? hint : none +} + /** * 标题和下一步。标题说是哪一种;服务端的原因原样跟在标题后面(CompareError 里),下一步只写界面上真有的入口、 * 服务端已经写了的不再重复: @@ -80,6 +88,7 @@ export interface CompareErrorContext { * - 404:缺的是本次(targetGone)才是链接失效;缺的是基准(基准的记录 404,或者本次的记录读得到)就在上方另选; * 还不知道是哪一边时先不说; * - 找不到默认基准:本次不属于已保存的工作流同 422;下拉里有能选的才说可以在上方选,一次都没有就照实说 + * 叫人去上方换基准的几句(409、422、缺基准的 404、找不到默认基准)都先看下拉里有没有能换的(pickHint) */ function errorCopy(kind: Exclude, error: unknown, targetGone: boolean, ctx: CompareErrorContext): { title: string; hint: string | null @@ -88,18 +97,20 @@ function errorCopy(kind: Exclude, error: unknown, tar 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 } + return { title, hint: code === UNSEALED && ctx.targetUnsealed ? null : pickHint(ctx, T.errSealedHint, T.errNoOtherBase) } } case '422': - return ctx.targetUnsaved ? { title: T.errUnsaved, hint: null } : { title: T.errMismatch, hint: T.errMismatchHint } + return ctx.targetUnsaved ? { title: T.errUnsaved, hint: null } + : { title: T.errMismatch, hint: pickHint(ctx, T.errMismatchHint, T.errNoOtherBase) } case '404': return { title: T.errMissing, - hint: targetGone ? T.errMissingHint : ctx.baseGone || ctx.targetKnown ? T.errBaseMissingHint : null, + hint: targetGone ? T.errMissingHint + : ctx.baseGone || ctx.targetKnown ? pickHint(ctx, T.errBaseMissingHint, T.errNoOtherBase) : 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 } + return { title: T.errNoBase, hint: pickHint(ctx, T.errNoBaseHint, T.errNoBaseNone) } } } diff --git a/scripts/check-compare.mjs b/scripts/check-compare.mjs index 3abf62d8..f980e46d 100644 --- a/scripts/check-compare.mjs +++ b/scripts/check-compare.mjs @@ -7,10 +7,12 @@ // 404(找不到基准)只弹一句通用报错;窄屏(390px)被表格撑出横向滚动;浅色下分类标签、差异高亮看不清。 // 还有几处照服务端(engine/compare.py、api/compare.py)的实际输出才看得出来的:无法比较的指标原因只在 reasons 里; // 同一节点两条查询的 (node_id, ordinal) 相同;一边的 SQL 是 null;基准运行读不到;本次的记录读不到;不属于已保存 -// 工作流的运行;查找基准期间离开运行详情。下一步的提示要和页面上真能做的事对得上。 +// 工作流的运行;查找基准期间离开运行详情(新页面画好之后、或者地址刚换新页面还没画出来时答复才回来,走过的地址里 +// 都不能出现对比页)。下一步的提示要和页面上真能做的事对得上:下拉里没有能换的运行就不叫人去上方换,本次的记录 +// 不存在时不给注定失败的「重试」。 // // 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、 -// compare/base、compare 都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。 +// compare/base、compare、定时任务列表都在浏览器层答掉,别的 GET 放行(外壳要的工作流、待审批),非 GET 一律拦掉。 // 运行对比的两个接口只读,这里的假答复严格照接口约定的字段名和形状。 // // node scripts/check-compare.mjs @@ -181,6 +183,8 @@ const LABEL = { unchanged: '未变', data_only: '只数据变', caliber_and_data const ORDER = ['data_only', 'caliber_and_data', 'sql_changed', 'params_only', 'added', 'removed', 'incomparable', 'unchanged'] const REASON = '默认基准:同一定时任务的上一次运行' const FINDING = '正在查找上一次运行' +/** 409、422、缺基准的 404 时下拉里没有能换的运行:不叫人去上方换,照实说 */ +const NO_OTHER = '同一工作流没有其他可换作基准的已封存运行' // ---------------------------------------------------------------- 假接口 @@ -243,6 +247,8 @@ function defaults(opts) { if (m[2] === '/artifacts') return { json: [] } } if (p === '/api/approvals' && url.searchParams.get('run_id')) return { json: [] } + // 查找基准期间离开运行详情去的那一页(定时任务列表):给空的列表,页面落定不靠服务端里有什么 + if (p === '/api/schedules') return { json: { items: [] } } return null } } @@ -356,37 +362,85 @@ await section('从运行详情进入', async () => { check('页面没有报错', errors.length === 0, errors.join(';')) }) -await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => { +/** 查找基准期间离开去的那一页:定时任务列表。进去之后不再自己换地址(/data 会再换成 /data/databases),晚到的跳转盖不住 */ +const AWAY = '/schedules' +/** 那一页落定:列表读回来了(假接口给的空列表) */ +const awaySettled = (page) => page.locator('[data-schedules-page="list"] [data-schedules-empty]').waitFor({ timeout: 8000 }) +const NO_BASE = '同一工作流没有其他已封存的正式运行' + +/** + * 在运行详情里点「与上一次对比」,compare/base 的答复(answer,默认找到 B)先压住不放。交回页面、放行的函数、这一请求 + * 了结的 Promise(答完或者被取消,交回那个请求)、点了之后有没有挂出「正在查找」的提示,和点了之后走过的路径 + * (framenavigated 连 pushState、replaceState 都算):晚到的答复哪怕只让页面在对比页一闪而过、又被别的跳转盖掉, + * 走过的路径里也留着 + */ +async function findingBase(answer = { json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) } }) { let release const gate = new Promise((r) => { release = r }) - const { page, log, errors } = await open(`/runs/${T}`, - { base: () => ({ json: { base_run_id: B, reason: 'same_schedule', base: refOf(B) }, wait: gate }) }) - // compare/base 这一请求了结(答完,或者被取消) + const opened_ = await open(`/runs/${T}`, { base: () => ({ ...answer, wait: gate }) }) + const { page, log } = opened_ const ended = new Promise((resolve) => { - const done = (req) => { if (new URL(req.url()).pathname.endsWith('/compare/base')) resolve() } + const done = (req) => { if (new URL(req.url()).pathname.endsWith('/compare/base')) resolve(req) } page.on('requestfinished', done) page.on('requestfailed', done) }) await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 }) + const visited = [] + page.on('framenavigated', (f) => { if (f === page.mainFrame()) visited.push(new URL(f.url()).pathname) }) await page.getByRole('button', { name: '更多操作' }).click() await page.locator('[data-menu=compare]').click() // 菜单点完就收起了:查找期间要有一条看得见的提示,接口慢的时候不像点了没反应 - check('点了之后有「正在查找上一次运行」的提示', await until(async () => - await page.locator('[data-toast]', { hasText: FINDING }).count() === 1, 3000)) + 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 page.evaluate(() => { history.pushState({}, '', '/data'); dispatchEvent(new PopStateEvent('popstate')) }) - await page.waitForURL((u) => u.pathname.startsWith('/data'), { timeout: 5000 }) + return { ...opened_, release, ended, noted, visited } +} + +await section('查找基准期间:看得见在做事,离开之后不再跳走', async () => { + const { page, release, ended, noted, visited, errors } = await findingBase() + check('点了之后有「正在查找上一次运行」的提示', noted) + // 改主意去了别的页面。等那一页画好、运行详情卸下之后再放行:答复晚到也不能把人拽回对比页 + await page.evaluate((to) => { history.pushState({}, '', to); dispatchEvent(new PopStateEvent('popstate')) }, AWAY) + await awaySettled(page) + const unmounted = await until(async () => await page.locator('[data-run-detail]').count() === 0, 3000) release() - // 等请求了结,再给跳转留足时间:第一次进 /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 jumped = await until(async () => visited.includes('/runs/compare'), 2500) const at = new URL(page.url()) - check('离开运行详情之后答复才回来:留在原地,不跳到对比页', !jumped && at.pathname.startsWith('/data'), at.pathname + at.search) + check('新页面画好之后答复才回来:一直留在新页面,走过的地址里从没有对比页', unmounted && !jumped && at.pathname === AWAY, + `${visited.join(' → ')};现在 ${at.pathname}${at.search}`) check('离开之后「正在查找」的提示也收起', await page.locator('[data-toast]', { hasText: FINDING }).count() === 0) check('页面没有报错', errors.length === 0, errors.join(';')) }) +await section('查找基准期间离开、立刻放行:地址已换,新页面还没画出来', async () => { + // React Router 按过渡(startTransition)渲染换页:地址先换,新页面画好、运行详情卸下(这才取消请求)在后。这里只换 + // 地址、先不通知路由,把这段空当撑住,答复就在这时放行——检查不靠时机碰运气。找到了基准的不跳,找不到的不弹提示 + for (const [what, answer] of [['找到了基准', undefined], ['找不到基准', { status: 404, json: { detail: NO_BASE } }]]) { + const { page, release, ended, noted, visited, errors } = await findingBase(answer) + await page.evaluate((to) => { history.pushState({}, '', to) }, AWAY) + release() + const req = await Promise.race([ended, new Promise((r) => setTimeout(() => r(null), 4000))]) + // 答复处理完的标志:菜单收尾时收起「正在查找」的提示 + const handled = noted && await until(async () => await page.locator('[data-toast]', { hasText: FINDING }).count() === 0, 3000) + const held = !!req && !req.failure() && handled && await page.locator(`[data-run-detail="${T}"]`).count() === 1 + const jumped = await until(async () => visited.includes('/runs/compare'), 1000) + const told = await page.locator('[data-toast]', { hasText: NO_BASE }).count() + // 空当结束:通知路由,新页面画出来 + await page.evaluate(() => { dispatchEvent(new PopStateEvent('popstate')) }) + await awaySettled(page).catch(() => {}) + const at = new URL(page.url()) + check(`${what}:答复回来时运行详情还在、请求没被取消(撑住了空当)`, held, + `请求${req ? (req.failure() ? '被取消' : '已答完') : '没有了结'},提示${handled ? '已收起' : '没收起'}`) + check(`${what}:不跳到对比页,走过的地址里从没有对比页,留在新页面`, !jumped && at.pathname === AWAY, + `${visited.join(' → ')};现在 ${at.pathname}${at.search}`) + check(`${what}:不弹找不到基准的提示`, told === 0) + check(`${what}:页面没有报错`, errors.length === 0, errors.join(';')) + for (const c of opened) await c.close().catch(() => {}) + opened.clear() + } +}) + await section('不属于已保存工作流的运行:入口不可点并说明原因', async () => { const saved = RUNS[T] // 两种都没有 workflow_id:画布上没保存就运行的,和工作流删掉之后被置空的(名字还是原来的) @@ -765,6 +819,8 @@ await section('出错与找不到基准', async () => { const sealed = await open(comparePath(T, B), { compare: () => ({ status: 409, json: { detail: broken, code: 'compare_seal_broken' } }) }) await settled(sealed.page, 'error') const e409 = sealed.page.locator('[data-compare-error="409"]') + // 叫人去上方换基准的那句下一步,要等下拉读到同一工作流的运行、知道有没有能换的才写 + await until(() => has(sealed.page, '[data-compare-error="409"] [data-compare-error-hint]'), 3000) const t409 = (await e409.innerText().catch(() => '')).replace(/\s+/g, ' ') check('409 封存核对不一致:标题照实写,带上服务端的原因', (await e409.locator('[data-compare-error-title]').innerText()) === '无法对比:封存核对不一致' && t409.includes(broken), t409) @@ -790,13 +846,27 @@ await section('出错与找不到基准', async () => { RUNS[T] = savedT } - const other = await open(comparePath(T, B), { compare: () => ({ status: 422, - json: { detail: '两次运行不属于同一个工作流(「区域销售周报」与「门店日报」),不能对比', code: 'compare_mismatch' } }) }) + const mismatch = '两次运行不属于同一个工作流(「区域销售周报」与「门店日报」),不能对比' + const other = await open(comparePath(T, B), { compare: () => ({ status: 422, json: { detail: mismatch, code: 'compare_mismatch' } }) }) await settled(other.page, 'error') const e422 = other.page.locator('[data-compare-error="422"]') + await until(() => has(other.page, '[data-compare-error="422"] [data-compare-error-hint]'), 3000) check('422:写明无法对比,带上原因和下一步', (await e422.innerText()).includes('两次运行不属于同一个工作流') && (await e422.innerText()).includes('同一工作流的另一次运行'), (await e422.innerText().catch(() => '')).replace(/\s+/g, ' ')) + // 下拉里除了本次和当前的基准,没有别的已封存的运行:换基准这条路走不通,409、422 都不再叫人去上方换,照实说没有。 + // 409 的基准和本次同属一个工作流(下拉里有它,可它正是出问题的那一次),另一次没封存;422 的基准属于别的工作流 + for (const [code, answer, list] of [ + ['409', { status: 409, json: { detail: broken, code: 'compare_seal_broken' } }, [RUNS[T], RUNS[B], RUNS[U]]], + ['422', { status: 422, json: { detail: mismatch, code: 'compare_mismatch' } }, [RUNS[T], RUNS[U]]], + ]) { + const only = await open(comparePath(T, B), { compare: () => answer, list: () => ({ json: list }) }) + await settled(only.page, 'error') + const said = await until(async () => (await maybeText(only.page.locator(`[data-compare-error="${code}"] [data-compare-error-hint]`))).trim(), 3000) + const box = (await only.page.locator(`[data-compare-error="${code}"]`).innerText()).replace(/\s+/g, ' ') + check(`${code}、下拉里没有能换的:不叫人去上方换基准,写明没有其他可换作基准的已封存运行`, said === NO_OTHER && !box.includes('上方'), box) + } + const gone = await open(comparePath(T, B), { compare: () => RUN_GONE }) await settled(gone.page, 'error') check('404:写明运行记录不存在', (await gone.page.locator('[data-compare-error="404"]').innerText()).includes('运行记录不存在')) @@ -836,6 +906,17 @@ await section('出错与找不到基准', async () => { && (await missing.page.locator('[data-compare-error-title]').innerText()) === '运行记录不存在' && (await gone404.innerText()).includes('链接可能已失效') && await gone404.locator('a[href="/runs"]').count() === 1, (await gone404.innerText().catch(() => '')).replace(/\s+/g, ' ')) + // 本次的记录不存在:不知道是哪个工作流、列不出候选,重试几次都一样。换基准的位置照实说,不给「重试」;地址里带了 + // 基准的(对比回 404)也一样,整页都没有注定失败的「重试」 + const goneWithBase = await open(comparePath(T, B), { compare: () => RUN_GONE, run: (id) => (id === T ? RECORD_GONE : null) }) + await settled(goneWithBase.page, 'error') + for (const [what, p] of [['只带本次', missing.page], ['带着基准', goneWithBase.page]]) { + const area = p.locator('[data-compare-run="base"]') + const noted = await until(() => has(p, '[data-compare-run="base"] [data-compare-pick-note="target-gone"]'), 3000) + check(`本次不存在(${what}):换基准的位置写明列不出同一工作流的运行,整页不给「重试」`, + noted && (await area.innerText()).includes('本次运行的记录不存在') && await p.getByRole('button', { name: '重试' }).count() === 0, + (await area.innerText()).replace(/\s+/g, ' ')) + } // 只带本次、找得到:自己补上基准,写明怎么选的 const auto = await open(comparePath(T)) @@ -877,6 +958,14 @@ await section('基准运行读不到(删了、链接过期)', async () => { await settled(page) check('另选一次之后照常对比', new URL(page.url()).searchParams.get('base') === B && compareHits(log, T, B) === 1) check('页面没有报错', errors.length === 0, errors.join(';')) + + // 同一工作流只剩本次和一次没封存的:下拉里没有能换的,缺基准时同样不叫人去上方另选 + const bare = await open(comparePath(T, GONE), { compare: () => ({ status: 404, + json: { detail: '作为对比基准的运行记录不存在', code: 'run_not_found' } }), list: () => ({ json: [RUNS[T], RUNS[U]] }) }) + await settled(bare.page, 'error') + const said = await until(async () => (await maybeText(bare.page.locator('[data-compare-error="404"] [data-compare-error-hint]'))).trim(), 3000) + const bareBox = (await bare.page.locator('[data-compare-error="404"]').innerText()).replace(/\s+/g, ' ') + check('缺的是基准、下拉里没有能换的:不叫人去上方另选,写明没有其他可换作基准的已封存运行', said === NO_OTHER && !bareBox.includes('上方'), bareBox) }) await section('本次的运行记录读不到:换基准不一直停在「正在读取」', async () => {