复核第 1 轮:对比入口预先问默认基准带 5 秒超时、问到没有默认基准时写「选择基准对比…」直接去对比页手动选、切回页面时再问一次;工件页签的悬停照实写,320px 时校验值和大小不再拆开

- 对比入口(pages/runs/compare/entry.tsx):预先问 compare/base 卡着不回时 5 秒后按没问到算,叫法退回「与上一次对比」、可点,不再一直停在不可点的「正在查找」;问到没有默认基准(多为工作流的第一次运行)时写「选择基准对比…」,点了直接去只带本次的对比页手动选(对比页自己再找一次),不再写「与上一次对比」、点了再问一遍弹提示;切去别的标签页再切回来时再问一次,问的期间保留原来的叫法、可点,问到不一样才换,问不到不改;更正预先问那段注释的前提(compare/base 不回 409、422,不先问是因为这些入口本来不给点)
- 运行详情「工件」页签头的悬停写上证据快照、核查记录和每个节点的产出三组(RUN_ARTIFACTS_TEXT.tabTitle,RunDetailView 只换这一处);证据快照的悬停不再说「结论里的数字从这里来」,点明含核查、质询时执行的查询;核查记录的悬停用行上的种类名,指明其间执行的查询见「证据快照」;两句都在 45 字内
- 工件行的校验值和大小包在一起排、一起折行,这一栏窄于 22rem 时收起校验值:320px 下大小不再单独折到种类下面,每行仍是两行
- 对比页没带运行时的说明缩成 32 字,不逐个列菜单项的叫法
- 检查:check-compare 补预先问超时、没有默认基准、切回页面再问、点了才发现没有基准后叫法跟着改、工件页签悬停几节;check-verify 改两组悬停的断言、补 320px;check-schedules 的假 compare/base 补上 previous: false(和服务端一致),断言运行详情菜单项与触发历史链接的叫法一字不差
This commit is contained in:
ThearchyHelios
2026-10-06 18:25:35 +08:00
parent b8dfbdb335
commit 53bb356fae
8 changed files with 292 additions and 72 deletions
+17 -8
View File
@@ -3194,6 +3194,8 @@ export const RUN_COMPARE_TEXT = {
menu: '与上一次对比',
/** 上一次失败、取消、还没结束:基准是再往前成功的那一次,不能说成「上一次」 */
menuLastOk: '与上一次成功的运行对比',
/** 没有默认基准(多为工作流的第一次运行):没有「上一次」可比,点了去对比页手动选 */
menuPick: '选择基准对比…',
menuSealedOnly: '运行结束并封存后才能对比',
/** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */
menuNoWorkflow: '工作流未保存或已删除,无法对比',
@@ -3240,8 +3242,8 @@ export const RUN_COMPARE_TEXT = {
pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`,
// 页面状态
noTarget: '未指定要对比的运行',
/** 菜单项的两种叫法都写上:上一次没有成功时,菜单里写的是「与上一次成功的运行对比」 */
noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」或「与上一次成功的运行对比」,即可对照本次与上一次成功的运行',
/** 不逐个列菜单项的叫法(看有没有默认基准、是不是紧挨着的上一次,有三种),只说在哪里打开、默认比的是哪一次 */
noTargetHint: '可从运行详情的「更多」菜单打开对比,默认以上一次成功的运行为基准',
toRuns: '前往记录',
finding: '正在查找基准运行…',
loading: '正在对比两次运行…',
@@ -3400,17 +3402,24 @@ export const RUN_COMPARE_TEXT = {
/**
* 运行详情「工件」页签(pages/runs/ArtifactsPane.tsx):这次运行按内容校验值存下的每一件东西,按节点分组。筛选按
* pages/runs/model.ts 的 artifactGroup 分三组:证据快照是结论里数字的出处;核查记录是双盲复算、反方审稿、审批质询对
* 结论的核查与问答,单独一组,不和数字的出处混在一起;节点产出是过程里的中间值。各种工件的名字和说明在 model.ts,
* 后来新加的放在这里(质询回答的在 INQUIRY_TEXT)
* pages/runs/model.ts 的 artifactGroup 分三组:证据快照是取数、检索的原始结果和口径卡、报告文档、表结构;核查记录是
* 双盲复算、反方审稿、审批质询对结论的核查与问答,单独一组;节点产出是过程里的中间值。按种类归组:核查、质询时执行的
* 查询仍是查询快照,在证据快照里(一次运行里这类查询可能比流程本身的还多),所以证据快照不能说成「结论里的数字都从
* 这里来」。各种工件的名字和说明在 model.ts,后来新加的放在这里(质询回答的在 INQUIRY_TEXT)
*/
export const RUN_ARTIFACTS_TEXT = {
/** 运行详情「工件」页签头上的悬停:人先看到的这句要和页签里的三组对得上。清单还没取回来时不写件数 */
tabTitle: (n?: number | null) =>
(n == null ? '本次运行保存的证据、核查记录和产出' : `${n} 件:证据快照、核查记录和每个节点每一次的产出`),
filterGroup: '按类型筛选工件',
filter: { all: '全部', evidence: '证据快照', verify: '核查记录', output: '节点产出' } as Record<string, string>,
/** 筛选按钮的悬停:这一组装的是什么。核查时执行的查询按种类仍是查询快照,归在证据快照里,要说一声 */
/**
* 筛选按钮的悬停:这一组装的是什么。核查、质询时执行的查询按种类仍是查询快照、在证据快照里,两组都要说一声;
* 核查记录这一组列的是行上的种类名(双盲复算对照、反方审稿记录、质询回答)
*/
filterHint: {
evidence: '查询、工具调用、知识检索的原始结果,以及口径卡、报告文档和查询时的表结构:结论里的数字从这里来',
verify: '双盲复算的对照、反方审稿的记录、审批质询的回答:对结论的核查与问答。核查时执行的查询,快照归在证据快照里',
evidence: '查询、工具调用、知识检索的原始结果,口径卡、报告文档和表结构;含核查、质询时执行的查询',
verify: '对结论的核查与问答:双盲复算对照、反方审稿记录、质询回答;其间执行的查询见「证据快照」',
output: '每个节点每执行一次的产出:循环里每一轮都有一件',
} as Record<string, string>,
/** 同一个节点同一类的第几件:循环里几十件节点产出要分得开 */
+8 -3
View File
@@ -80,7 +80,8 @@ const FIRST_ROWS = 300
*
* 每一行按列表这一栏自己的宽度排(容器查询,不看视口:详情区在宽屏上也可能很窄)。窄时
* 说明换到第二行占满整行,第一行留种类、校验值、大小和时刻;以前挤在一行里,390px
* 宽时说明只剩三个字。序号和第几次尝试放在截断的范围外,按钮的悬停写全说明。
* 宽时说明只剩三个字。序号和第几次尝试放在截断的范围外,按钮的悬停写全说明。再窄
* (320px 的手机)时收起校验值,每行仍是两行。
*/
export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
list: ArtifactList
@@ -261,8 +262,12 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, t1, onOpen, onM
<span className="min-w-0 truncate" data-artifact-about="">{about}</span>
{extra && <span className="tnum shrink-0 text-faint" data-artifact-nth="">{extra}</span>}
</span>
<span className="mono shrink-0 text-2xs text-faint @max-[35rem]:ml-auto" title={`内容校验值 ${a.id}`}>{a.id.slice(0, 8)}</span>
<span className="tnum w-16 shrink-0 text-right text-2xs text-faint">{formatBytes(a.size)}</span>
{/* 校验值和大小一起排、一起折行:分开时更窄的屏上大小会单独折到种类下面、校验值留在右边。窄于 22rem(320px
的手机)时收起校验值,第一行才放得下种类和大小;打开后的查看器里写着校验值 */}
<span className="flex shrink-0 items-center gap-2 @max-[35rem]:ml-auto">
<span className="mono text-2xs text-faint @max-[22rem]:hidden" title={`内容校验值 ${a.id}`}>{a.id.slice(0, 8)}</span>
<span className="tnum w-16 text-right text-2xs text-faint" data-artifact-size="">{formatBytes(a.size)}</span>
</span>
</button>
{at != null && onMoment ? (
<button type="button" className="btn btn-xs btn-ghost tnum w-[86px] shrink-0 justify-end"
+2 -2
View File
@@ -25,7 +25,7 @@ import {
} from './model'
import { ArtifactsPane, useRunArtifacts } from './ArtifactsPane'
import { EvidencePane } from './EvidencePane'
import { EVIDENCE_AUDIT_TEXT } from '../../lib/terms'
import { EVIDENCE_AUDIT_TEXT, RUN_ARTIFACTS_TEXT } from '../../lib/terms'
import { ClassChip, MoreMenu, RunTabs, TierChip, copyText, type MenuItem, type TabItem } from './parts'
import { ProvenanceBar, Telemetry, runClocks } from './Telemetry'
import { TracePane } from './TracePane'
@@ -538,7 +538,7 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
{ key: 'evidence', label: EVIDENCE_AUDIT_TEXT.tab, title: EVIDENCE_AUDIT_TEXT.tabTitle },
{
key: 'artifacts', label: '工件', count: artifactCount, unit: '件', tone: 'quiet',
title: artifactCount == null ? '本次运行保存的证据和产出' : `${artifactCount} 件:查询和工具调用的原始结果、每个节点每一次的产出`,
title: RUN_ARTIFACTS_TEXT.tabTitle(artifactCount),
},
]
+51 -17
View File
@@ -6,6 +6,7 @@ import { toast } from '../../../components/ui'
import { RUN_COMPARE_TEXT as T } from '../../../lib/terms'
import type { Run } from '../../../types'
import type { MenuItem } from '../parts'
import { compareErrorKind } from './model'
/** 对比页的地址。via 是默认基准的来历(同一定时任务 / 同一工作流),只跟着这一次跳转走,换了基准就不再显示 */
export interface CompareVia { base: string; reason: string }
@@ -15,29 +16,41 @@ export const comparePath = (target: string, base?: string | null) =>
/** 查找超过这么久才挂「正在查找」的提示:答复快的不闪一下 */
const NOTE_DELAY_MS = 300
/**
* 预先问默认基准最多等这么久:连得上却卡着不回(代理、网络卡住)时按没问到算,入口不一直停在不可点的「正在查找」。
* 平常一两百毫秒就回来;点了之后那一次不设这个限,慢了有「正在查找」的提示
*/
const ASK_TIMEOUT_MS = 5000
/**
* 预先问到的默认基准。key 是问的哪一次运行的哪一份封存(运行 id + 清单哈希:失败后接着运行完会重新封存,基准可能跟着
* 变);previous 为 null 是没问到(找不到默认基准、出错),叫法退回「与上一次对比」,点了照常再问、照实说
* 变)。base:previous 是紧挨着的上一次,earlier 是再往前成功的那一次(上一次失败、取消、还没结束),none 是没有默认
* 基准(对比页会写「没有默认基准」、让人手动选);null 是没问到(出错、超时),叫法退回「与上一次对比」,点了照常再问、
* 照实说
*/
interface Known { key: string; previous: boolean | null }
interface Known { key: string; base: 'previous' | 'earlier' | 'none' | null }
/**
* 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务上一次成功的运行,没有就同一工作流、
* 同一类别上一次成功的运行;定时任务触发历史里同名的入口由服务端用同一个函数挑,两处比的是同一对运行),找到了再跳到
* 对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
* 对比页;点了才发现找不到(预先问时还有)时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
* 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据
* 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。
*
* 叫法和触发历史里的链接一致:基准不是紧挨着的上一次(上一次失败、取消、还没结束)时写「与上一次成功的运行对比」。
* 点之前就得知道,所以拿到能对比的运行就预先问一次(previous)。问到之前这一项只写「正在查找」、不给点:菜单开着时不会
* 先写一个叫法、问到了再换成另一个,也点不到一个说错了的入口;平常运行一打开就问,人点开菜单时早已问到。预先问不到
* (找不到默认基准、出错)不提示,叫法退回「与上一次对比」,点了照常再问一次、照实说。
* 先写一个叫法、问到了再换成另一个,也点不到一个说错了的入口;平常运行一打开就问,人点开菜单时早已问到。问到没有默认
* 基准(多为工作流的第一次运行)时写「选择基准对比…」,点了直接去只带本次的对比页手动选:没有上一次可比还写「与上一次
* 对比」会误导人,再问一遍、弹一条提示也是多走一步。预先问不到(出错、ASK_TIMEOUT_MS 内没回来)不提示,叫法退回
* 「与上一次对比」、可点,点了照常再问一次、照实说。
*
* 点了总是再问一次、按这一次的答复跳:详情页可能开了很久,中间有运行接着运行完、被删掉,基准跟着变,预先问到的不一定还是
* 现在的。菜单点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、
* 收起提示,晚到的答复不再把人带去对比页。卸载取消不了全部:地址已经换了、新页面还没画出来(路由按过渡渲染)的那一下
* 答复回来,组件还在,请求也没取消。所以答复回来时再看一眼地址:人已经不在点菜单时的那个运行详情上,就不跳、也不提示。
* 切去别的标签页、窗口再切回来时再问一次:详情页可能开了很久,这期间在别处删掉、接着运行完了哪一次,基准跟着变。再问
* 期间保留原来的叫法、照样可点(不退回「正在查找」,菜单开着也不闪),问到了不一样才换,问不到不改。
*
* 点了总是再问一次、按这一次的答复跳(没有默认基准的除外,对比页自己会再找一次):预先问到的不一定还是现在的。菜单
* 点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、收起提示,
* 晚到的答复不再把人带去对比页。卸载取消不了全部:地址已经换了、新页面还没画出来(路由按过渡渲染)的那一下答复回来,
* 组件还在,请求也没取消。所以答复回来时再看一眼地址:人已经不在点菜单时的那个运行详情上,就不跳、也不提示。
*
* 写成 hook、在运行详情的提前返回之前调用:预先查找和点了之后查找中的状态都得在拿到运行之前就挂上。run 为空(还在读
* 运行记录)时交回的这一项不会被画出来
@@ -57,30 +70,50 @@ export function useCompareEntry(run: Run | null, live: boolean): MenuItem {
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
// 切回这一页的次数:每切回来一次再问一次
const [round, setRound] = useState(0)
useEffect(() => {
if (!key) return
const onShow = () => { if (document.visibilityState === 'visible') setRound((n) => n + 1) }
document.addEventListener('visibilitychange', onShow)
return () => document.removeEventListener('visibilitychange', onShow)
}, [key])
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 }) },
api.compare.base(target, { signal: ctrl.signal, timeoutMs: ASK_TIMEOUT_MS }).then(
(found) => { if (!ctrl.signal.aborted) setKnown({ key, base: found.previous === false ? 'earlier' : 'previous' }) },
(e: unknown) => {
if (ctrl.signal.aborted) return
// 没有默认基准算问到了(判法同对比页);出错、超时算没问到:头一回退回「与上一次对比」,再问时不改原来的叫法
if (compareErrorKind(e, true) === 'no-base') setKnown({ key, base: 'none' })
else setKnown((k) => (k?.key === key ? k : { key, base: null }))
},
)
return () => ctrl.abort()
}, [target, key])
}, [target, key, round])
const resolved = known && known.key === key ? known : null
// 预先问的那一次还没回来:这一项只写正在查找,不先写一个叫法、问到了再换
const asking = !!key && !resolved
const label = asking ? T.menuFinding
: resolved?.base === 'earlier' ? T.menuLastOk : resolved?.base === 'none' ? T.menuPick : T.menu
return {
key: 'compare',
label: asking ? T.menuFinding : resolved?.previous === false ? T.menuLastOk : T.menu,
label,
icon: <GitCompareArrows size={12} />,
disabled: orphan || unsealed || asking || busy,
hint: orphan ? T.menuNoWorkflow : unsealed ? T.menuSealedOnly : busy ? T.menuFinding : undefined,
onSelect: () => {
if (!run || !key) return
// 已经知道没有默认基准:直接去只带本次的对比页,那里自己再找一次(这期间有了就用上),找不到写明、在上方手动选
if (resolved?.base === 'none') {
navigate(comparePath(run.id))
return
}
const ctrl = new AbortController()
// 点菜单时所在的运行详情。只比路径:同一页里换视图、改列表筛选(改查询串)不算离开
const from = window.location.pathname
@@ -98,13 +131,14 @@ export function useCompareEntry(run: Run | null, live: boolean): MenuItem {
api.compare.base(run.id, { signal: ctrl.signal }).then((found) => {
if (left()) return
// 这一次的答复也定叫法:跳不走(地址没换成)时菜单上写的和刚问到的一致
setKnown({ key, previous: found.previous !== false })
setKnown({ key, base: found.previous === false ? 'earlier' : 'previous' })
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 })
// 点了才发现没有默认基准(这期间基准那一次被删了,或者预先问没问到):照实说,下次打开菜单写「选择基准对比…」
setKnown({ key, base: compareErrorKind(e, true) === 'no-base' ? 'none' : null })
toast.info(e.message, { action: { label: T.pickManually, onClick: () => navigate(comparePath(run.id)) } })
} else {
toast.error(e)
+4 -4
View File
@@ -369,10 +369,10 @@ export function artifactDescription(kind: string, meta?: Record<string, any> | n
}
/**
* 工件页签上的三组:evidence 证据快照(查询、工具、检索的快照,口径卡、报告文档、表结构,结论里的数字从这里来);
* verify 核查记录(双盲复算、反方审稿、审批质询对结论的核查与问答:不是数字的出处,以前混在证据快照里,和「原始结果」
* 的说明对不上);output 节点产出(过程里的中间值,一次循环就是几十件)。按种类分:核查时执行的查询种类仍是查询快照,
* 归在证据快照;认不出的种类也算证据快照,宁可醒目
* 工件页签上的三组:evidence 证据快照(查询、工具、检索的快照,口径卡、报告文档、表结构);verify 核查记录(双盲复算、
* 反方审稿、审批质询对结论的核查与问答:以前混在证据快照里,和「原始结果」的说明对不上);output 节点产出(过程里的
* 中间值,一次循环就是几十件)。按种类分:核查、质询时执行的查询种类仍是查询快照,归在证据快照(两组的悬停说明都写明
* 了);认不出的种类也算证据快照,宁可醒目
*/
export type ArtifactGroup = 'evidence' | 'verify' | 'output'
const VERIFY_KINDS: ReadonlySet<string> = new Set(['dual_check', 'challenge', 'inquiry_answer'])
+158 -27
View File
@@ -12,7 +12,9 @@
// 不存在时不给注定失败的「重试」。默认基准的说法照服务端的挑法(api/compare.py 的 pick_base)写「上一次成功的运行」
// ——上一次失败了,基准是再往前成功的那一次;定时任务触发历史里的「与上一次对比」和运行详情里的同名入口打开同一对
// 运行、写同一句来历,基准不是紧挨着的上一次时两处看得见的字都写明「与上一次成功的运行对比」(运行详情一打开就预先问
// 默认基准,问到之前菜单项不给点,不先写一个叫法再换成另一个;预先问不到时不提示,点了照常再问);找不到默认基准时
// 默认基准,问到之前菜单项不给点,不先写一个叫法再换成另一个;预先问出错、卡着不回时到点不提示,叫法退回、点了照常
// 再问;问到没有默认基准时写「选择基准对比…」,点了直接去对比页手动选;切回这一页时再问一次,问的期间叫法不变);
// 运行详情「工件」页签的悬停写上证据快照、核查记录和节点产出三组;找不到默认基准时
// 标题不说「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。
//
// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、
@@ -197,6 +199,10 @@ const NO_BASE_SERVER = '没有找到上一次成功的运行:需要同一工
/** 运行详情「更多」里对比入口的两种叫法,和触发历史里的链接一致:基准是紧挨着的上一次时、不是时(上一次失败了) */
const MENU = '与上一次对比'
const MENU_LAST_OK = '与上一次成功的运行对比'
/** 预先问到没有默认基准时的叫法:点了直接去只带本次的对比页手动选 */
const MENU_PICK = '选择基准对比…'
/** 预先问默认基准最多等这么久(同 pages/runs/compare/entry.tsx 的 ASK_TIMEOUT_MS),到点按没问到算 */
const ASK_TIMEOUT_MS = 5000
/**
* 发起本次和基准的定时任务(RUNS 里的 schedule_id)和它的触发历史:本次那一行带服务端自动对比的计数,基准是 B、不是
@@ -413,6 +419,22 @@ await section('从运行详情进入', async () => {
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('运行详情「工件」页签的悬停:写上证据快照、核查记录和节点产出', async () => {
// 工件页签按证据快照、核查记录、节点产出分三组(pages/runs/ArtifactsPane.tsx),页签头上的悬停是人先看到的那句说明,
// 不能还只写「原始结果和节点产出」。这里顺带查:本脚本本来就用假接口打开真的运行详情
const { page, errors } = await open(`/runs/${T}`)
await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
const tab = page.locator(`[data-run-detail="${T}"] [role=tab][data-tab=artifacts]`)
// 工件清单取回来之后悬停里才有件数
const title = await until(async () => {
const t = (await tab.getAttribute('title').catch(() => null)) ?? ''
return /^\d+ 件/.test(t) ? t : null
}, 3000) ?? (await tab.getAttribute('title').catch(() => null)) ?? ''
check('「工件」页签的悬停写上件数和三组:证据快照、核查记录、每个节点的产出', /^\d+ 件/.test(title)
&& ['证据快照', '核查记录', '产出'].every((w) => title.includes(w)), title)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
/** 查找基准期间离开去的那一页:定时任务列表。进去之后不再自己换地址(/data 会再换成 /data/databases),晚到的跳转盖不住 */
const AWAY = '/schedules'
/** 那一页落定:列表读回来了(假接口给的空列表) */
@@ -421,15 +443,16 @@ const NO_BASE = '同一工作流没有其他已封存的正式运行'
/**
* 在运行详情里点「与上一次对比」,点了之后那一次 compare/base 的答复(answer,默认找到 B)先压住不放;打开详情时预先
* 问的那一次照常答(同一个答复),菜单项才可点。交回页面、放行的函数、点了之后那一请求了结的 Promise(答完或者被取消,
* 交回那个请求)、点了之后有没有挂出「正在查找」的提示,和点了之后走过的路径(framenavigated 连 pushState、
* replaceState 都算):晚到的答复哪怕只让页面在对比页一闪而过、又被别的跳转盖掉,走过的路径里也留着
* 问的那一次照常答、找到了 B(点了再问时找不到,是这期间基准那一次被删了),菜单项才可点。交回页面、放行的函数、点了
* 之后那一请求了结的 Promise(答完或者被取消,交回那个请求)、点了之后有没有挂出「正在查找」的提示,和点了之后走过的
* 路径(framenavigated 连 pushState、replaceState 都算):晚到的答复哪怕只让页面在对比页一闪而过、又被别的跳转盖掉,
* 走过的路径里也留着
*/
async function findingBase(answer = { json: BASE_OF_T }) {
let release
const gate = new Promise((r) => { release = r })
let clicked = false
const opened_ = await open(`/runs/${T}`, { base: () => (clicked ? { ...answer, wait: gate } : answer) })
const opened_ = await open(`/runs/${T}`, { base: () => (clicked ? { ...answer, wait: gate } : { json: BASE_OF_T }) })
const { page, log } = opened_
const ended = new Promise((resolve) => {
const done = (req) => { if (clicked && new URL(req.url()).pathname.endsWith('/compare/base')) resolve(req) }
@@ -651,7 +674,7 @@ await section('菜单的叫法:问到默认基准之前不给点,不先写
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('预先问不到默认基准:不提示,叫法退回「与上一次对比」,点了照常再问', async () => {
await section('预先问出错、超时:不提示,叫法退回「与上一次对比」、可点,点了照常再问', async () => {
// 服务端出错:打开详情时不弹报错(人还没点),点了再问一次,这次问到了就照常跳
{
let n = 0
@@ -671,26 +694,121 @@ await section('预先问不到默认基准:不提示,叫法退回「与上
`${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(';'))
let n = 0
let release
const gate = new Promise((r) => { release = r })
// 不等 networkidle:压住的那一次要到点才了结,等它就看不到到点之前的样子了
const { page, log, errors } = await open(`/runs/${T}`, { base: () => (++n === 1 ? { json: BASE_OF_T, wait: gate } : { json: BASE_OF_T }),
waitUntil: 'load' })
try {
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.isDisabled()
const freed = await until(async () => !(await item.isDisabled()), ASK_TIMEOUT_MS + 4000)
const label = (await item.innerText()).trim()
check(`预先问卡着不回:先不可点、写正在查找,${ASK_TIMEOUT_MS / 1000} 秒后按没问到算,入口可点、叫法退回「与上一次对比」`,
waiting && !!freed && label === MENU, `${(await states()).join(' → ')}`)
check('……不弹报错', await page.locator('[data-toast]').count() === 0,
(await page.locator('[data-toast]').allInnerTexts()).join(';'))
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(';'))
} finally {
release()
}
}
})
await section('预先问到没有默认基准:叫法写「选择基准对比…」,点了直接去对比页手动选', async () => {
// 没有默认基准(多为工作流的第一次运行)时还写「与上一次对比」,会让人以为有上一次可比。预先问已经知道没有:叫法照实
// 写,点了不再问一遍、弹提示,直接去只带本次的对比页——那里自己再找一次(这期间有了就用上),找不到写「没有默认基准」,
// 在上方手动选
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_PICK, label)
const clickAt = log.length
await item.click()
await page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 })
await settled(page, 'error')
const u = new URL(page.url())
check('……点了直接去对比页,只带本次,不弹「没有找到上一次成功的运行」的提示', u.searchParams.get('target') === T
&& !u.searchParams.get('base') && await toast.count() === 0, `${u.search};提示 ${await toast.count()} 条`)
const noBase = page.locator('[data-compare-error="no-base"]')
check('……对比页写「没有默认基准」和服务端的原因,下拉可以手动选', await noBase.count() === 1
&& (await noBase.innerText()).includes(NO_BASE_SERVER) && await page.locator('select[data-compare-base-picker]').isEnabled(),
(await maybeText(noBase)).replace(/\s+/g, ' '))
check('……点了之后只有对比页自己找了一次默认基准,菜单没有先问一遍', askedBase(log.slice(clickAt)) === 1,
`点了之后问了 ${askedBase(log.slice(clickAt))} 次`)
check('……页面没有报错', errors.length === 0, errors.join(';'))
})
await section('回到这一页时再问一次默认基准:再问期间叫法不变、可点,问到不一样才换', async () => {
// 详情页可能开了很久,这期间在别处删掉、接着运行完了哪一次,基准跟着变。切去别的标签页、窗口再切回来时再问一次;
// 浏览器切标签页时改 visibilityState、发 visibilitychange,这里照样改、照样发
let answer = { json: { ...BASE_OF_T, previous: true } }
const { page, log, errors } = await open(`/runs/${T}`, { base: () => answer })
const flip = (state) => page.evaluate((s) => {
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => s })
document.dispatchEvent(new Event('visibilitychange'))
}, state)
await page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
const more = page.getByRole('button', { name: '更多操作' })
const item = page.locator('[data-menu=compare]')
await more.click()
await until(async () => !(await item.isDisabled()), 3000)
const first = (await item.innerText()).trim()
check('先问到的是紧挨着的上一次:写「与上一次对比」', first === MENU, first)
await page.keyboard.press('Escape')
await flip('hidden')
await page.waitForTimeout(300)
check('切走时不问', askedBase(log) === 1, `问了 ${askedBase(log)} 次`)
// 这期间上一次改成了失败的(基准成了再往前的那一次)。再问的那一次压住:菜单开着看,叫法还是原来的、可点
let release
const gate = new Promise((r) => { release = r })
answer = { json: BASE_OF_T, wait: gate }
await flip('visible')
const again = await until(async () => askedBase(log) === 2, 3000)
await more.click()
await item.waitFor()
const during = `${(await item.isDisabled()) ? '不可点' : '可点'}:${(await item.innerText()).trim()}`
check('切回来再问一次;问的期间叫法还是原来的、可点,不退回「正在查找」', again && during === `可点:${MENU}`, during)
const states = await watchItem(page)
release()
const after = await until(async () => ((await item.innerText()).trim() === MENU_LAST_OK ? MENU_LAST_OK : null), 3000)
check('问到不一样了:换成「与上一次成功的运行对比」,照样可点', after === MENU_LAST_OK && !(await item.isDisabled()),
(await states()).join(' → '))
// 再问出错(或者超时):不改已经问到的叫法,也不弹报错
answer = { status: 500, json: { detail: '读取运行记录时出错' } }
await page.keyboard.press('Escape')
await flip('hidden')
await flip('visible')
await until(async () => askedBase(log) === 3, 3000)
await page.waitForTimeout(400)
await more.click()
await item.waitFor()
const kept = `${(await item.isDisabled()) ? '不可点' : '可点'}:${(await item.innerText()).trim()}`
check('再问出错:保留已经问到的叫法、可点,不弹报错', askedBase(log) === 3 && kept === `可点:${MENU_LAST_OK}`
&& await page.locator('[data-toast]', { hasText: '读取运行记录时出错' }).count() === 0, `${kept};问了 ${askedBase(log)} 次`)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('两次运行与摘要', async () => {
const { page, errors } = await open(comparePath(T, B))
await settled(page)
@@ -1177,14 +1295,25 @@ await section('出错与找不到基准', async () => {
check('只带本次:自己找到基准、补进地址', new URL(auto.page.url()).searchParams.get('base') === B
&& (await text(auto.page, '[data-compare-reason]')).trim() === REASON)
// 运行详情里找不到基准:提示原因,给「手动选择基准」
const menu = await open(`/runs/${T}`, { base: () => ({ status: 404, json: { detail: '同一工作流没有其他已封存的正式运行' } }) })
// 运行详情里预先问到了基准,点了再问时却找不到(这期间基准那一次被删了):提示原因,给「手动选择基准」;再打开菜单,
// 叫法照这一次的答复写「选择基准对比…」。服务端的 404 不带机读码时也算找不到默认基准(同对比页的 compareErrorKind)
let menuAsks = 0
const menu = await open(`/runs/${T}`, { base: () => (++menuAsks === 1 ? { json: BASE_OF_T }
: { status: 404, json: { detail: '同一工作流没有其他已封存的正式运行' } }) })
await menu.page.locator(`[data-run-detail="${T}"]`).waitFor({ timeout: 10000 })
await menu.page.getByRole('button', { name: '更多操作' }).click()
await menu.page.locator('[data-menu=compare]').click()
const toast = menu.page.locator('[data-toast]', { hasText: '同一工作流没有其他已封存的正式运行' })
check('运行详情里找不到基准:提示原因、留在原页', await until(() => toast.count().then((c) => c === 1))
check('运行详情里点了之后找不到基准:提示原因、留在原页', await until(() => toast.count().then((c) => c === 1))
&& new URL(menu.page.url()).pathname === `/runs/${T}`)
await menu.page.getByRole('button', { name: '更多操作' }).click()
const relabel = await until(async () => {
const t = (await maybeText(menu.page.locator('[data-menu=compare]'))).trim()
return t === MENU_PICK ? t : null
}, 3000)
check('……再打开菜单:叫法照这一次的答复写「选择基准对比…」', relabel === MENU_PICK,
(await maybeText(menu.page.locator('[data-menu=compare]'))).trim())
await menu.page.keyboard.press('Escape')
await toast.getByRole('button', { name: '手动选择基准' }).click()
await menu.page.waitForURL((u) => u.pathname === '/runs/compare', { timeout: 8000 })
check('「手动选择基准」去到对比页,只带本次', new URL(menu.page.url()).searchParams.get('target') === T
@@ -1192,8 +1321,10 @@ await section('出错与找不到基准', async () => {
const none = await open('/runs/compare')
await settled(none.page, 'no-target')
const howTo = await text(none.page, '[data-compare-page]')
check('什么都没带:说明从哪里进来,菜单项的两种叫法都写上', howTo.includes(`「${MENU}」`) && howTo.includes(`「${MENU_LAST_OK}」`), howTo)
// 说明不逐个列菜单项的叫法(有三种),只说从「更多」菜单打开、默认基准是上一次成功的运行;一句话不超过 40 字
const howTo = (await text(none.page, '[data-compare-page]')).split('\n').map((l) => l.trim()).find((l) => l.includes('「更多」')) ?? ''
check('什么都没带:说明从「更多」菜单打开、默认以上一次成功的运行为基准,不超过 40 字', howTo.includes('上一次成功的运行')
&& [...howTo].length <= 40 && !howTo.includes(`「${MENU}」`), `${[...howTo].length} 字:${howTo}`)
})
await section('基准运行读不到(删了、链接过期)', async () => {
+10 -2
View File
@@ -1519,7 +1519,10 @@ await section('详情:触发历史和运行详情的「与上一次对比」
started_at: r.started_at, finished_at: r.finished_at, schedule_id: r.schedule_id })
const compareApi = (method, path, body, url) => {
if (method !== 'GET') return null
if (path === '/runs/run-f10/compare/base') return { json: { base_run_id: 'run-f12', reason: 'same_schedule', base: ref(f12) } }
// previous 同服务端 pick_base:上一次(f11)失败了,基准不是紧挨着的上一次,同触发历史里 f10 那一行(COMPARE_F10)
if (path === '/runs/run-f10/compare/base') {
return { json: { base_run_id: 'run-f12', reason: 'same_schedule', base: ref(f12), previous: COMPARE_F10.previous } }
}
if (path === '/runs/run-f10/compare' && url.searchParams.get('base') === 'run-f12') {
return { json: { target: ref(f10), base: ref(f12), summary: COMPARE_F10.summary, metrics: [], queries: [], notes: [],
template: { base_version: 3, target_version: 3, same_graph: true, nodes: [] } } }
@@ -1536,7 +1539,11 @@ await section('详情:触发历史和运行详情的「与上一次对比」
const menu = await open('/runs/run-f10', { state: stateOf() })
await menu.page.locator('[data-run-detail="run-f10"]').waitFor({ timeout: 10000 })
await menu.page.getByRole('button', { name: '更多操作' }).click()
await menu.page.locator('[data-menu=compare]').click()
// 菜单项等预先问到默认基准才可点,叫法照它的 previous 定
const item = menu.page.locator('[data-menu=compare]')
await until(async () => !(await item.isDisabled().catch(() => true)))
const menuLabel = (await item.innerText().catch(() => '')).trim()
await item.click()
const a = await landed(menu.page)
const hist = await open('/schedules/s-weekly?tab=history', { state: stateOf() })
const link = hist.page.locator('[data-fire-id="f10"] [data-fire-compare]')
@@ -1550,6 +1557,7 @@ await section('详情:触发历史和运行详情的「与上一次对比」
const [ra, rb] = [await reasonOf(menu.page), await reasonOf(hist.page)]
check('……两处写的默认基准一字不差:同一定时任务上一次成功的运行', ra === REASON_SCHEDULE && rb === REASON_SCHEDULE, `${ra} | ${rb}`)
check('……触发历史上看得见的字说明比的是上一次成功的运行', label === '与上一次成功的运行对比', label)
check('……运行详情「更多」里的入口和触发历史的链接叫法一字不差', menuLabel === label, `${menuLabel} | ${label}`)
const tiles = (page) => until(async () => (await 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)]
+42 -9
View File
@@ -23,8 +23,9 @@
// - 出具缺口:证据页的出具横幅(真的 EvidencePane 里那一个 IssuanceBanner,和问数据、画布右栏同一个)和成果节点印章的
// 悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」「核查未完成」两组,不放在「校验未全部完成」下;
// - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名;和质询回答一起归在「核查记录」一组,
// 不再算进说明写着「原始结果」的「证据快照」,筛选的件数、悬停说明跟着改;390px 窄屏上说明换到第二行(以前挤在一行里
// 只剩三个字),第几件、第几次尝试在截断范围外看得见,悬停写全说明;
// 不再算进说明写着「原始结果」的「证据快照」,筛选的件数、悬停说明跟着改(核查、质询时执行的查询仍在证据快照,两组的
// 悬停都写明);390px 窄屏上说明换到第二行(以前挤在一行里只剩三个字),第几件、第几次尝试在截断范围外看得见,悬停
// 写全说明;320px 时校验值和大小不被拆开,每行仍是两行;
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的
// 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是
// 和反方审稿共用的控件(verifyFields.tsx);盲算模型不藏在高级选项里
@@ -1251,7 +1252,7 @@ const artifactRows = (page) => page.locator('[data-run-artifacts] [data-artifact
// 说明看得见的宽度、是否被截断(scrollWidth 比看得见的宽)
visible: about ? Math.round(about.getBoundingClientRect().width) : 0, cut: about ? about.scrollWidth > about.clientWidth + 1 : false,
row: rect(el), chip: box('[data-artifact-kind-label]'), line: box('[data-artifact-about-line]'),
nthBox: box('[data-artifact-nth]'), hash: box('[title^="内容校验值"]'),
nthBox: box('[data-artifact-nth]'), hash: box('[title^="内容校验值"]'), size: box('[data-artifact-size]'),
when: box('[data-action="artifact-moment"]') ?? box('[data-artifact-time]'),
}
}))
@@ -1267,11 +1268,16 @@ await section('记录页的工件页签:核查记录单独一组,筛选的
check('筛选:全部、证据快照、核查记录、节点产出四档,件数对得上', JSON.stringify(filters.map((f) => f.text))
=== JSON.stringify(['全部 22', '证据快照 10', '核查记录 4', '节点产出 8']), filters.map((f) => f.text).join('、'))
const hint = Object.fromEntries(filters.map((f) => [f.key, f.title]))
check('「核查记录」的悬停说清装的是双盲复算、反方审稿、审批质询,核查时执行的查询在证据快照里',
['双盲复算', '反方审稿', '审批质询', '证据快照'].every((w) => hint.verify?.includes(w)), hint.verify)
check('「证据快照」的悬停不再只说「原始结果」:口径卡、报告文档、表结构也写上,不提核查',
['查询', '口径卡', '报告文档', '表结构'].every((w) => hint.evidence?.includes(w)) && !/核查|复算|审稿|质询/.test(hint.evidence ?? ''),
hint.evidence)
// 归类按种类:双盲复算、反方审稿、审批质询执行的查询仍是查询快照,归在证据快照(夹具里 6 件查询快照有 4 件是这样来的)。
// 两组的悬停都得说清,证据快照不能说成「结论里的数字从这里来」,核查记录要指明其间执行的查询在哪一组
check('「核查记录」的悬停说清装的是双盲复算对照、反方审稿记录、质询回答,其间执行的查询见「证据快照」',
['双盲复算对照', '反方审稿记录', '质询回答', '「证据快照」'].every((w) => hint.verify?.includes(w)), hint.verify)
check('「证据快照」的悬停:原始结果之外写上口径卡、报告文档、表结构,并点明含核查、质询时执行的查询,不说结论的数字都从这里来',
['查询', '口径卡', '报告文档', '表结构', '核查', '质询', '执行的查询'].every((w) => hint.evidence?.includes(w))
&& !(hint.evidence ?? '').includes('结论里的数字'), hint.evidence)
const long = Object.entries(hint).filter(([, t]) => [...(t ?? '')].length > 45)
check('三组的悬停都在 45 字以内(用语规范:说明约 40 字)', long.length === 0,
long.map(([k, t]) => `${k} ${[...t].length} 字`).join('、'))
const all = await artifactRows(page)
check('「全部」列出 22 件,每件都标了分组', all.length === 22 && all.every((r) => ['evidence', 'verify', 'output'].includes(r.group)),
`${all.length} 件`)
@@ -1308,7 +1314,7 @@ await section('记录页的工件页签:核查记录单独一组,筛选的
check('页面没有报错', errors.length === 0, errors.join(';'))
})
await section('记录页的工件页签:390px 说明换到第二行,第几件在截断范围外,不横向溢出、深浅色可读', async () => {
await section('记录页的工件页签:390px、320px 说明换到第二行,第几件在截断范围外,不横向溢出、深浅色可读', async () => {
for (const theme of ['dark', 'light']) {
const { page, errors } = await preview('run-artifacts', { width: 390, height: 844, theme })
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
@@ -1371,6 +1377,33 @@ await section('记录页的工件页签:390px 说明换到第二行,第几
await shot(page, `run-artifacts-narrow-${theme}`)
check(`390px(${theme}):页面没有报错`, errors.length === 0, errors.join(';'))
}
// 再窄(320px 的小屏手机):第一行放不下种类、校验值、大小时,以前大小单独折到种类下面、校验值留在右边,行成了三行。
// 校验值和大小一起排;这一栏窄于 22rem 时收起校验值(打开后的查看器里写着),每行仍是两行:种类、大小、时刻在第一行,
// 说明在第二行
{
const { page, errors } = await preview('run-artifacts', { width: 320, height: 720, theme: 'dark' })
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
const rows = await artifactRows(page)
const o = await page.evaluate(() => {
const doc = document.documentElement
const scroller = document.querySelector('[data-run-artifacts] .overflow-y-auto')
return { page: doc.scrollWidth - doc.clientWidth, list: scroller ? scroller.scrollWidth - scroller.clientWidth : -1 }
})
check('320px:页签不横向溢出', o.page <= 0 && o.list <= 0, JSON.stringify(o))
const mid = (b) => (b.top + b.bottom) / 2
const split = rows.filter((r) => !(r.size && r.chip && r.size.width > 0 && Math.abs(mid(r.size) - mid(r.chip)) <= 4))
check('320px:大小留在第一行、和种类对齐,不单独折到种类下面', split.length === 0,
split.map((r) => `${r.kind}(大小 ${JSON.stringify(r.size)},种类 ${JSON.stringify(r.chip)})`).slice(0, 3).join(';'))
const apart = rows.filter((r) => r.hash && r.hash.width > 0 && r.size && Math.abs(mid(r.hash) - mid(r.size)) > 4)
check('320px:校验值和大小不被拆开(收起,或者同在一行)', apart.length === 0, apart.map((r) => r.kind).join('、'))
const below = rows.filter((r) => r.line && r.chip && r.line.top >= r.chip.bottom - 1)
check('320px:说明在种类下面的第二行', below.length === rows.length, rows.filter((r) => !below.includes(r)).map((r) => r.kind).join('、'))
const narrowest = rows.filter((r) => r.cut).reduce((m, r) => Math.min(m, r.visible), Infinity)
check('320px:被截断的说明看得见的宽度至少 150px', rows.every((r) => !r.cut || r.visible >= 150),
`截断的最窄 ${narrowest === Infinity ? '—' : `${narrowest}px`}`)
await shot(page, 'run-artifacts-320-dark')
check('320px:页面没有报错', errors.length === 0, errors.join(';'))
}
const wide = await preview('run-artifacts', { width: 1280, height: 900, theme: 'light' })
await wide.page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
await shot(wide.page, 'run-artifacts-wide-light')