合入期 5 前置修复 L3:记录页的工件列表把双盲复算对照、反方审稿记录、质询回答归到「核查记录」一组,窄屏下说明换到第二行、序号不被截掉;运行对比的默认基准接口带上 previous,运行详情的对比入口在基准不是紧挨着的上一次时写明「与上一次成功的运行对比」,和触发历史一致

This commit is contained in:
ThearchyHelios
2026-10-06 22:19:25 +08:00
15 changed files with 926 additions and 177 deletions
+10 -7
View File
@@ -1,11 +1,12 @@
"""运行对比:同一个工作流的两次运行按「变化三分法」对照——数据变了、口径变了、还是模板 / SQL 变了(engine/compare.py)。
GET /api/runs/{run_id}/compare/base:默认拿哪一次来比,{base_run_id, reason, base}。规则在 pick_base,定时任务结束后
的自动对比(scheduler/auto_compare.py)用的是同一个函数——触发历史里的「与上一次对比」和运行详情里的同名入口必须拿到
同一个基准,计数才对得上;触发历史里存着的那一份,事后基准不再是这里挑出来的那一次(中间失败的那次被接着运行成功
了)就不再给出(auto_compare.outdated)。这次运行是定时任务发起的,取同一定时任务、同一工作流、比它先发起的运行里
GET /api/runs/{run_id}/compare/base:默认拿哪一次来比,{base_run_id, reason, base, previous}。规则在 pick_base,定时
任务结束后的自动对比(scheduler/auto_compare.py)用的是同一个函数——触发历史里的「与上一次对比」和运行详情里的同名入口
必须拿到同一个基准,计数才对得上;触发历史里存着的那一份,事后基准不再是这里挑出来的那一次(中间失败的那次被接着运行
成功了)就不再给出(auto_compare.outdated)。这次运行是定时任务发起的,取同一定时任务、同一工作流、比它先发起的运行里
成功结束并封存的最近一次(reason: same_schedule);不是、或者同一任务之前没有成功的,取同一工作流、同一运行类别里
同样条件的最近一次(reason: same_workflow)。找不到 404。
同样条件的最近一次(reason: same_workflow)。previous 说基准是不是紧挨着的上一次(同自动对比记下的 previous):不是时
两个入口都写「与上一次成功的运行对比」。找不到 404。
GET /api/runs/{run_id}/compare?base={base_id}:{target, base, summary, template, metrics, queries, notes},每个字段
的形状见 engine/compare.py。两次运行都必须已结束、封存且封存核对一致,封存判定照搬证据接口(api/evidence._sealed):
@@ -62,11 +63,13 @@ BASE = "作为基准的运行"
@router.get("/{run_id}/compare/base")
async def compare_base(run_id: str) -> dict[str, Any]:
"""默认拿来对比的那一次运行:{base_run_id, reason: same_schedule | same_workflow, base}。"""
"""默认拿来对比的那一次运行:{base_run_id, reason: same_schedule | same_workflow, base, previous}。previous 是
pick_base 算的「是不是紧挨着的上一次」:运行详情「更多」里的入口按它定叫法(不是时写「与上一次成功的运行对比」),
和触发历史里同名的链接(自动对比记下的 previous)说法一致。"""
found = await default_base(run_id)
if found is None:
raise CodedHTTPException(404, NO_BASE_TEXT, NO_BASE)
return {"base_run_id": found.run.id, "reason": found.reason, "base": run_ref(found.run)}
return {"base_run_id": found.run.id, "reason": found.reason, "base": run_ref(found.run), "previous": found.previous}
@router.get("/{run_id}/compare")
+45 -1
View File
@@ -7,7 +7,8 @@
- 对比只读:两次运行的 last_seq、事件、工件引用、工件文件一样不多
- 遮罩列(查询当时记下的、数据源现在设的)的值不出现在结果里
- 默认基准:同一定时任务优先,否则同一工作流、同一运行类别;只挑比这次先发起、成功结束并封存的运行,按发起先后取
最近一次(失败、取消的跳过,和定时任务的自动对比挑的是同一次,见 test_scheduler_compare.py)
最近一次(失败、取消的跳过,和定时任务的自动对比挑的是同一次,见 test_scheduler_compare.py);previous 说它是不是
紧挨着的上一次
- 真跑:同一条 SQL 数据变了是 data_only;只是代进去的日期不同是 params_only,图一样
"""
from __future__ import annotations
@@ -335,6 +336,49 @@ async def test_the_default_base_follows_launch_order(client):
assert (await get(client, f"/api/runs/{waiting}/compare/base"))["base_run_id"] == later
async def test_the_default_base_says_whether_it_is_the_previous_run(client):
"""previous:基准是不是紧挨着的上一次,语义同 pick_base——同一范围(同一定时任务,或同一工作流、同一运行类别)里比
这次先发起、比基准后发起的运行一次都没有。失败、取消、还没结束、没封存的都算隔着;别的运行类别、别的定时任务(这次
是定时任务发起的)、比这次晚发起的不在范围里,不算。运行详情「更多」里的入口按它写「与上一次对比」还是「与上一次
成功的运行对比」,和触发历史里同名的链接一个说法"""
day = timedelta(days=1)
async def pick(run_id: str) -> tuple[str, str, bool]:
body = await get(client, f"/api/runs/{run_id}/compare/base")
return body["base_run_id"], body["reason"], body["previous"]
# 同一定时任务:紧挨着的上一次就是成功的
workflow = await new_workflow()
first = await bare(workflow, T0, schedule_id="sched-weekly")
await bare(workflow, T0 + day) # 手动发起的:不在同一定时任务的范围里
await bare(workflow, T0 + day, schedule_id="sched-daily", status="failed") # 别的定时任务失败的:同上
second = await bare(workflow, T0 + 2 * day, schedule_id="sched-weekly")
await bare(workflow, T0 + 3 * day, schedule_id="sched-weekly", status="failed") # 比 second 晚发起
assert await pick(second) == (first, "same_schedule", True)
# 中间隔着一次没成功的(失败、取消、还没结束、结束了没封存):基准是再往前成功的那一次,不是紧挨着的上一次
for between in ({"status": "failed"}, {"status": "cancelled"}, {"status": "running", "sealed": False},
{"status": "interrupted", "sealed": False}, {"sealed": False}):
workflow = await new_workflow()
first = await bare(workflow, T0, schedule_id="sched-weekly")
await bare(workflow, T0 + day, schedule_id="sched-weekly", **between)
current = await bare(workflow, T0 + 2 * day, schedule_id="sched-weekly")
assert await pick(current) == (first, "same_schedule", False), between
# 同一工作流、同一运行类别(手动发起的):别的运行类别不算隔着,同一类别里定时任务发起的失败的那次算
workflow = await new_workflow()
first = await bare(workflow, T0)
await bare(workflow, T0 + day, run_class="exploratory", status="failed")
second = await bare(workflow, T0 + 2 * day)
assert await pick(second) == (first, "same_workflow", True)
await bare(workflow, T0 + 3 * day, schedule_id="sched-weekly", status="failed")
third = await bare(workflow, T0 + 4 * day)
assert await pick(third) == (second, "same_workflow", False)
# 定时任务的第一次退回同一工作流:隔没隔着按退回的那个范围算
lone = await bare(workflow, T0 + 5 * day, schedule_id="sched-monthly")
assert await pick(lone) == (third, "same_workflow", True)
# --------------------------------------------------------------------------
# 真跑:虚构的 SQLite 库,两次运行之间改了数、换了日期参数
# --------------------------------------------------------------------------
+9 -2
View File
@@ -256,6 +256,9 @@ async def test_finished_run_is_compared_with_the_previous_run_of_its_schedule(re
items = {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
assert items[fire_id]["compare"] == {"base_run_id": base_id, "summary": SUMMARY, "previous": True}
assert items[base_fire]["compare"] is None
# 运行详情的默认基准:同一次、同样是紧挨着的上一次(中间手动的、别的定时任务的两次不在同一定时任务的范围里)
base = (await client.get(f"/api/runs/{target_id}/compare/base")).json()
assert (base["base_run_id"], base["previous"]) == (base_id, True)
[listed] = [s for s in (await client.get("/api/schedules")).json()["items"] if s["id"] == sid]
assert listed["last_fire"]["id"] == fire_id and listed["last_fire"]["compare"]["base_run_id"] == base_id
@@ -321,9 +324,10 @@ async def test_after_a_failed_run_the_base_is_the_last_successful_one(recorder,
assert (body["compare"]["base_run_id"], body["compare"]["previous"]) == (first, False)
items = {f["id"]: f for f in (await client.get(f"/api/schedules/{sid}/fires")).json()["items"]}
assert items[fire_id]["compare"] == {"base_run_id": first, "summary": SUMMARY, "previous": False}
# 运行详情「与上一次对比」的默认基准挑的是同一次(同一个函数):两个同名入口比的是同一对运行、计数一致
# 运行详情「与上一次对比」的默认基准挑的是同一次(同一个函数):两个同名入口比的是同一对运行、计数一致;
# previous 也一样,运行详情的菜单项和触发历史的链接都写「与上一次成功的运行对比」
base = (await client.get(f"/api/runs/{target_id}/compare/base")).json()
assert (base["base_run_id"], base["reason"]) == (first, "same_schedule")
assert (base["base_run_id"], base["reason"], base["previous"]) == (first, "same_schedule", False)
async def fires_of(client, sid: str) -> dict[str, dict]:
@@ -396,6 +400,9 @@ async def test_a_stored_compare_stays_while_its_base_is_still_the_pick(recorder,
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")
# 运行详情的默认基准按现在的运行记录算:中间那次删掉了,第 1 周就是紧挨着的上一次(菜单写「与上一次对比」,
# 触发历史照当时记下的写「与上一次成功的运行对比」,两句都属实,比的是同一对运行)
assert (await client.get(f"/api/runs/{third}/compare/base")).json()["previous"] is True
async def test_without_an_earlier_success_there_is_no_base(recorder, client):
+2 -2
View File
@@ -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<RunCompareBase>(`${run(runId)}/compare/base`, opts),
/**
@@ -0,0 +1,89 @@
import { useMemo, useState, type JSX } from 'react'
import { parseServerTime } from '../../lib/format'
import { ArtifactsPane, type ArtifactList } from '../../pages/runs/ArtifactsPane'
import type { RunArtifact } from '../../pages/runs/model'
import { emptyTrace } from '../../run/trace'
import type { GraphSpec, NodeType } from '../../types'
/**
* 运行详情「工件」页签(pages/runs/ArtifactsPane)的预览用例,scripts/check-verify.mjs 按它检查分组和窄屏。
*
* - ?syn=run-artifacts:虚构的「门店周报」一次正式运行,各种工件都有:取数节点的查询快照、表结构快照和产出,循环里
* 逐区汇总的三次产出(第二次是重试),口径卡的指标输入、指标集,写周报的报告文档,双盲复算、反方审稿各自执行的查询
* 和对照、审稿记录,封存后追加的两条质询回答(一条问题很长)和质询执行的查询。宽窄、深浅色都用这一个,检查脚本换
* 视口宽度和主题。
*
* 工件列表直接交给页签(不走接口);航迹的起止照这次运行的开始、结束时间给:运行期间产出的能跳到航迹里那一刻,封存后
* 追加的只写时间。点过的跳转记在 data-fixture-moment 上。门店、片区、金额、数据源都是虚构的。
*/
const STARTED = '2026-10-05T06:00:00'
const FINISHED = '2026-10-05T06:12:00'
const NODES: [string, NodeType, string][] = [
['pull', 'tool', '拉取门店销量'],
['summarize', 'llm', '逐区汇总'],
['caliber', 'metrics', '周报口径'],
['write', 'report', '写周报'],
['dual', 'dual_check', '双盲复算'],
['review', 'challenge', '反方审稿'],
]
const GRAPH: GraphSpec = {
nodes: NODES.map(([id, type, label], i) => ({ id, type, position: { x: i * 220, y: 0 }, data: { label, config: {} } })),
edges: NODES.slice(1).map(([id], i) => ({ source: NODES[i][0], target: id })),
}
const LABELS = Object.fromEntries(NODES.map(([id, , label]) => [id, label]))
/** 虚构的内容校验值:64 位十六进制,各不相同 */
const sha = (n: number) => (n * 2654435761 >>> 0).toString(16).padStart(8, '0').repeat(8)
let seq = 0
const art = (kind: string, node: string | null, at: string, size: number, meta: Record<string, any> | null = null): RunArtifact => (
{ id: sha(++seq), kind, node_id: node, size, meta, created_at: `2026-10-05T${at}` })
/** 质询的问题:服务端只记前 40 字(engine/inquiry.py 的 QUESTION_BRIEF),满 40 字的说明会很长 */
const LONG_QUESTION = '青禾片区的线上订单是按下单门店还是按收货地址归到片区的?如果按收货地址,跨片区配'
const RUN_ARTIFACTS: RunArtifact[] = [
art('schema_snapshot', 'pull', '06:00:05', 3120, { source: '门店库', tables: 3 }),
art('query_snapshot', 'pull', '06:00:10', 18_400, { source: '门店库', tables: ['orders', 'stores'] }),
art('query_snapshot', 'pull', '06:00:20', 2_060, { source: '门店库', tables: ['refunds'] }),
art('node_output', 'pull', '06:00:30', 1_210, { type: 'tool', attempt: 1 }),
art('node_output', 'summarize', '06:01:00', 860, { type: 'llm', attempt: 1 }),
art('node_output', 'summarize', '06:02:00', 910, { type: 'llm', attempt: 2 }),
art('node_output', 'summarize', '06:03:00', 880, { type: 'llm', attempt: 1 }),
art('metric_input', 'caliber', '06:04:00', 5_400, { path: 'vars.region_rows' }),
art('metric_set', 'caliber', '06:04:30', 2_380, { caliber: '周报口径', version: 'v3' }),
art('node_output', 'caliber', '06:04:40', 1_020, { type: 'metrics', attempt: 1 }),
art('report_doc', 'write', '06:05:00', 16_820),
art('node_output', 'write', '06:05:10', 4_310, { type: 'report', attempt: 1 }),
art('query_snapshot', 'dual', '06:06:00', 1_480, { source: '门店库', tables: ['orders'] }),
art('dual_check', 'dual', '06:06:30', 6_920, { metrics: 3 }),
art('node_output', 'dual', '06:07:00', 740, { type: 'dual_check', attempt: 1 }),
art('query_snapshot', 'review', '06:08:00', 1_150, { source: '门店库', tables: ['refunds'] }),
art('query_snapshot', 'review', '06:09:00', 1_330, { source: '门店库', tables: ['orders'] }),
art('challenge', 'review', '06:10:00', 8_640, { report: 'write' }),
art('node_output', 'review', '06:10:30', 690, { type: 'challenge', attempt: 1 }),
// 封存后追加的:审批质询(不挂在节点上)
art('query_snapshot', null, '07:00:00', 1_260, { source: '门店库', tables: ['orders'] }),
art('inquiry_answer', null, '07:01:00', 2_510, { report: 'write', unit: 'u3', question: LONG_QUESTION }),
art('inquiry_answer', null, '07:20:00', 1_940, { report: 'write', unit: 'u5', question: '退货率怎么算的?' }),
]
function RunArtifacts(): JSX.Element {
const list: ArtifactList = useMemo(() => ({ items: RUN_ARTIFACTS, state: 'ok', error: null, reload: () => undefined }), [])
const trace = useMemo(() => ({
...emptyTrace(), phase: 'succeeded' as const,
startedAt: parseServerTime(STARTED)?.getTime(), endedAt: parseServerTime(FINISHED)?.getTime(),
}), [])
const [moment, setMoment] = useState<number | null>(null)
return (
<div className="h-screen min-w-0" data-fixture="run-artifacts" data-fixture-moment={moment ?? ''}>
<ArtifactsPane list={list} graph={GRAPH} trace={trace} labelOf={(id) => (id ? LABELS[id] : undefined)}
onMoment={(at) => setMoment(at)} />
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'run-artifacts': () => <RunArtifacts />,
}
+46 -5
View File
@@ -3202,10 +3202,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 个。只有个数,
@@ -3272,8 +3272,13 @@ export const SCHEDULE_TEXT = {
export const RUN_COMPARE_TEXT = {
title: '运行对比',
hint: '把两次运行的指标变化拆成数据、口径、模板与 SQL 三类',
// 入口:运行详情的「更多」菜单
// 入口:运行详情的「更多」菜单。叫法看服务端给的 previous(基准是不是紧挨着的上一次),和定时任务触发历史里的同名
// 链接一个说法(SCHEDULE_TEXT.compareLink、compareLinkLastOk 取的就是这两条)
menu: '与上一次对比',
/** 上一次失败、取消、还没结束:基准是再往前成功的那一次,不能说成「上一次」 */
menuLastOk: '与上一次成功的运行对比',
/** 没有默认基准(多为工作流的第一次运行):没有「上一次」可比,点了去对比页手动选 */
menuPick: '选择基准对比…',
menuSealedOnly: '运行结束并封存后才能对比',
/** 没有 workflow_id 的运行:画布、问数据里没保存就运行的,或者工作流删掉之后被置空的。服务端只比同一个已保存的工作流 */
menuNoWorkflow: '工作流未保存或已删除,无法对比',
@@ -3320,7 +3325,8 @@ export const RUN_COMPARE_TEXT = {
pickGone: (id: string, missing: boolean) => `${id}(${missing ? '记录不存在' : '无法读取'})`,
// 页面状态
noTarget: '未指定要对比的运行',
noTargetHint: '在运行详情的「更多」菜单中选择「与上一次对比」,即可对照本次与上一次成功的运行',
/** 不逐个列菜单项的叫法(看有没有默认基准、是不是紧挨着的上一次,有三种),只说在哪里打开、默认比的是哪一次 */
noTargetHint: '可从运行详情的「更多」菜单打开对比,默认以上一次成功的运行为基准',
toRuns: '前往记录',
finding: '正在查找基准运行…',
loading: '正在对比两次运行…',
@@ -3477,6 +3483,41 @@ export const RUN_COMPARE_TEXT = {
notes: '说明',
}
/**
* 运行详情「工件」页签(pages/runs/ArtifactsPane.tsx):这次运行按内容校验值存下的每一件东西,按节点分组。筛选按
* 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: '对结论的核查与问答:双盲复算对照、反方审稿记录、质询回答;其间执行的查询见「证据快照」',
output: '每个节点每执行一次的产出:循环里每一轮都有一件',
} as Record<string, string>,
/** 同一个节点同一类的第几件:循环里几十件节点产出要分得开 */
nth: (n: number) => `#${n}`,
attempt: (n: number) => `第 ${n} 次尝试`,
/** 打开一件的悬停写全说明:窄屏、长问题的说明在行上会截断 */
openTitle: (kind: string, about: string) => `打开${kind}:${about}(读取时按内容校验值重新校验)`,
/**
* 口径卡里容器型变量(vars.*)输入的完整值(engine/nodes/metrics.py 的 metric_input,工件行的 meta 里有 {path}):
* 推演时拿它还原输入。和口径卡一样归在证据快照
*/
kindMetricInput: '指标输入',
aboutMetricInput: (path?: string | null) => (path ? `口径卡输入「${path}」的完整值` : '口径卡输入的完整值'),
}
/**
* 可验证报告:把一次已封存运行的报告导出成一个 HTML 文件,收件人不连服务也能在浏览器里核对。
* 入口在记录页的封存凭证条上;页面本身的文案在服务端 bundle/verifier.js 里(它是离开系统独立打开的文件)
+48 -34
View File
@@ -4,12 +4,12 @@ import clsx from 'clsx'
import { api } from '../../api/client'
import { EmptyState, ErrorState, Skeleton, StatusBadge } from '../../components/ui'
import { formatBytes, formatClock, formatDateTime, formatNumber, formatTime, parseServerTime } from '../../lib/format'
import { nodeTypeLabel } from '../../lib/terms'
import { RUN_ARTIFACTS_TEXT as T, nodeTypeLabel } from '../../lib/terms'
import { ArtifactViewer } from '../../run/AssistantStream'
import { topology } from '../../run/derive'
import type { Trace } from '../../run/trace'
import type { GraphSpec } from '../../types'
import { artifactDescription, artifactKindLabel, isEvidence, type RunArtifact } from './model'
import { artifactDescription, artifactGroup, artifactKindLabel, type ArtifactGroup, type RunArtifact } from './model'
// -------------------------------------------------------------------------
// 数据
@@ -65,22 +65,23 @@ export function useRunArtifacts(runId: string, refreshKey: string): ArtifactList
// 页签
// -------------------------------------------------------------------------
type KindFilter = 'all' | 'evidence' | 'output'
const FILTER_LABEL: Record<KindFilter, string> = { all: '全部', evidence: '证据快照', output: '节点产出' }
const FILTER_HINT: Record<KindFilter, string> = {
all: '',
evidence: '查询、工具调用、知识检索的原始结果:结论里的数字从这里来',
output: '每个节点每执行一次的产出:循环里每一轮都有一件',
}
/** 筛选:全部,或者 model.ts 的三组之一(证据快照、核查记录、节点产出) */
type KindFilter = 'all' | ArtifactGroup
const FILTERS: readonly KindFilter[] = ['all', 'evidence', 'verify', 'output']
/** 一次循环能有上千件:先列这么多,其余点了再展开 */
const FIRST_ROWS = 300
/**
* 工件:这次运行按内容哈希存下来的每一件东西——查询和工具调用的原始结果、每个
* 节点每一次的产出。审阅出具物时从这里下钻到证据;打开时按哈希复验。
* 工件:这次运行按内容哈希存下来的每一件东西——查询和工具调用的原始结果、核查留下的
* 记录、每个节点每一次的产出。审阅出具物时从这里下钻到证据;打开时按哈希复验。
*
* 按节点分组、按执行顺序排,和航迹的泳道同一个顺序。能对上时刻的,一键跳到
* 航迹里它产出的那一刻。
*
* 每一行按列表这一栏自己的宽度排(容器查询,不看视口:详情区在宽屏上也可能很窄)。窄时
* 说明换到第二行占满整行,第一行留种类、校验值、大小和时刻;以前挤在一行里,390px
* 宽时说明只剩三个字。序号和第几次尝试放在截断的范围外,按钮的悬停写全说明。再窄
* (320px 的手机)时收起校验值,每行仍是两行。
*/
export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
list: ArtifactList
@@ -96,17 +97,17 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
const items = list.items
const counts = useMemo(() => {
const c: Record<KindFilter, number> = { all: 0, evidence: 0, output: 0 }
const c: Record<KindFilter, number> = { all: 0, evidence: 0, verify: 0, output: 0 }
for (const a of items ?? []) {
c.all += 1
c[isEvidence(a.kind) ? 'evidence' : 'output'] += 1
c[artifactGroup(a.kind)] += 1
}
return c
}, [items])
const bytes = useMemo(() => (items ?? []).reduce((n, a) => n + (a.size ?? 0), 0), [items])
const groups = useMemo(() => {
const shown = (items ?? []).filter((a) => filter === 'all' || (filter === 'evidence') === isEvidence(a.kind))
const shown = (items ?? []).filter((a) => filter === 'all' || artifactGroup(a.kind) === filter)
const order = graph ? topology(graph).order : []
const rank = new Map(order.map((id, i) => [id, i]))
const byNode = new Map<string, RunArtifact[]>()
@@ -144,14 +145,14 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
return (
<div className="flex h-full min-h-0 flex-col" data-run-artifacts="">
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-1.5 text-2xs text-faint">
<div className="flex gap-0.5" role="group" aria-label="按类型筛选工件">
{(Object.keys(FILTER_LABEL) as KindFilter[]).map((k) => (
<div className="flex flex-wrap gap-0.5" role="group" aria-label={T.filterGroup}>
{FILTERS.map((k) => (
<button
key={k}
type="button"
aria-pressed={filter === k}
disabled={k !== 'all' && !counts[k]}
title={FILTER_HINT[k] || undefined}
title={T.filterHint[k]}
onClick={() => setFilter(k)}
data-artifact-kind={k}
className={clsx(
@@ -159,7 +160,7 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
filter === k ? 'border-[var(--border-strong)] bg-hover text-fg' : 'border-transparent hover:bg-hover hover:text-dim',
)}
>
{FILTER_LABEL[k]} <span className="tnum">{counts[k]}</span>
{T.filter[k]} <span className="tnum">{counts[k]}</span>
</button>
))}
</div>
@@ -169,7 +170,9 @@ export function ArtifactsPane({ list, graph, trace, labelOf, onMoment }: {
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{/* 行按这一栏的宽度排(容器查询)。容器设在列表上、不设在整个页签上:查看工件的弹窗挂在页签里、是 fixed 定位,
早期实现容器查询的浏览器给容器加布局隔离,会把它当成 fixed 元素的定位参照,弹窗就只盖住这一栏 */}
<div className="@container min-h-0 flex-1 overflow-y-auto">
{groups.map(([nodeId, rows]) => {
if (budget <= 0) return null
const take = rows.slice(0, budget)
@@ -219,7 +222,8 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, t1, onOpen, onM
style={{ background: node ? `var(--nt-${node.type}, var(--text-faint))` : 'var(--text-faint)' }} />
<span className="min-w-0 truncate font-medium text-fg">{label}</span>
{node && <span className="shrink-0 text-faint">{nodeTypeLabel(node.type)}</span>}
{nodeId && label !== nodeId && <span className="mono shrink-0 text-faint">{nodeId}</span>}
{/* 窄时让给名字:节点 id 是给对照画布用的,名字和类型已经认得出是哪个节点 */}
{nodeId && label !== nodeId && <span className="mono shrink-0 text-faint @max-[35rem]:hidden">{nodeId}</span>}
{state && <StatusBadge status={state} size={11} animate={false} />}
<span className="flex-1" />
<span className="tnum shrink-0 text-faint">{total} 件</span>
@@ -228,33 +232,42 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, t1, onOpen, onM
const n = (ordinal.get(a.kind) ?? 0) + 1
ordinal.set(a.kind, n)
const kind = artifactKindLabel(a.kind)
const group = artifactGroup(a.kind)
const about = artifactDescription(a.kind, a.meta)
const attempt = Number(a.meta?.attempt)
const created = parseServerTime(a.created_at ?? null)?.getTime()
const at = t0 != null && created != null && created >= t0 && (t1 == null || created <= t1) ? created : null
const numbered = (perKind.get(a.kind) ?? 0) > 1
const title = `${label} · ${kind}${numbered ? ` #${n}` : ''}`
// 第几件、第几次尝试:说明再长、截得再短也要看得见,放在截断的范围外
const extra = [numbered ? T.nth(n) : '', attempt > 1 ? T.attempt(attempt) : ''].filter(Boolean).join(' · ')
return (
<div key={a.id} className="flex items-center gap-2 border-b px-4 py-1 last:border-0 hover:bg-hover"
data-artifact-id={a.id}>
<div key={a.id} className="flex items-center gap-2 border-b px-4 py-1 last:border-0 hover:bg-hover @max-[35rem]:items-start"
data-artifact-id={a.id} data-artifact-class={group}>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 rounded py-0.5 text-left outline-none focus-visible:ring-1 focus-visible:ring-[var(--accent)]"
className="flex min-w-0 flex-1 items-center gap-2 rounded py-0.5 text-left outline-none focus-visible:ring-1 focus-visible:ring-[var(--accent)] @max-[35rem]:flex-wrap @max-[35rem]:gap-y-0.5"
onClick={() => onOpen({ id: a.id, title })}
title={`打开${kind}(读取时按内容校验值重新校验)`}
title={T.openTitle(kind, extra ? `${about} ${extra}` : about)}
data-action="artifact-open"
>
<span className={clsx('chip shrink-0', isEvidence(a.kind) && 'text-fg')}
style={isEvidence(a.kind) ? { borderColor: 'var(--border-strong)' } : undefined}>
<span className={clsx('chip shrink-0', group !== 'output' && 'text-fg')}
style={group !== 'output' ? { borderColor: 'var(--border-strong)' } : undefined}
data-artifact-kind-label="">
{kind}
</span>
<span className="min-w-0 truncate text-xs text-dim">
{artifactDescription(a.kind, a.meta)}
{numbered && <span className="tnum text-faint"> #{n}</span>}
{attempt > 1 && <span className="text-faint"> · 第 {attempt} 次尝试</span>}
{/* 宽时排在种类后面、占满中间;窄时换到第二行占满整行(order-last + basis-full),第一行留种类、校验值和大小 */}
<span className="flex min-w-0 flex-1 items-baseline gap-1 text-xs text-dim @max-[35rem]:order-last @max-[35rem]:basis-full"
data-artifact-about-line="">
<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>
{/* 校验值和大小一起排、一起折行:分开时更窄的屏上大小会单独折到种类下面、校验值留在右边。窄于 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>
<span className="flex-1" />
<span className="mono shrink-0 text-2xs text-faint" 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>
</button>
{at != null && onMoment ? (
<button type="button" className="btn btn-xs btn-ghost tnum w-[86px] shrink-0 justify-end"
@@ -263,7 +276,8 @@ function Group({ nodeId, rows, total, graph, trace, labelOf, t0, t1, onOpen, onM
<History size={11} aria-hidden /> T+{formatClock(Math.round(at - t0!))}
</button>
) : (
<span className="tnum w-[86px] shrink-0 text-right text-2xs text-faint" title={formatDateTime(a.created_at ?? null)}>
<span className="tnum w-[86px] shrink-0 text-right text-2xs text-faint @max-[35rem]:mt-1"
title={formatDateTime(a.created_at ?? null)} data-artifact-time="">
{formatTime(a.created_at ?? null)}
</span>
)}
+6 -5
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'
@@ -63,8 +63,9 @@ export function RunDetailView({ runId, onChange, onDeleted }: {
const [feedback, setFeedback] = useState<Feedback | null>(null)
const streamBox = useRef<HTMLDivElement>(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: <Link2 size={12} />,
onSelect: () => void copyText(`${location.origin}/runs/${run.id}`, '链接'),
},
compareEntry(run, live),
compareItem,
{
key: 'delete', label: '删除记录…', icon: <Trash2 size={12} />, danger: true, onSelect: () => void remove(),
disabled: live || busy === 'delete',
@@ -537,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),
},
]
+117 -50
View File
@@ -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'
@@ -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,23 +16,49 @@ export const comparePath = (target: string, base?: string | null) =>
/** 查找超过这么久才挂「正在查找」的提示:答复快的不闪一下 */
const NOTE_DELAY_MS = 300
/**
* 预先问默认基准最多等这么久:连得上却卡着不回(代理、网络卡住)时按没问到算,入口不一直停在不可点的「正在查找」。
* 平常一两百毫秒就回来;点了之后那一次不设这个限,慢了有「正在查找」的提示
*/
const ASK_TIMEOUT_MS = 5000
/**
* 预先问到的默认基准。key 是问的哪一次运行的哪一份封存(运行 id + 清单哈希:失败后接着运行完会重新封存,基准可能跟着
* 变)。base:previous 是紧挨着的上一次,earlier 是再往前成功的那一次(上一次失败、取消、还没结束),none 是没有默认
* 基准(对比页会写「没有默认基准」、让人手动选);null 是没问到(出错、超时),叫法退回「与上一次对比」,点了照常再问、
* 照实说
*/
interface Known { key: string; base: 'previous' | 'earlier' | 'none' | null }
/**
* 运行详情「更多」菜单里的「与上一次对比」。先问服务端默认的基准(同一定时任务上一次成功的运行,没有就同一工作流、
* 同一类别上一次成功的运行;定时任务触发历史里同名的入口由服务端用同一个函数挑,两处比的是同一对运行),找到了再跳到
* 对比页;找不到时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
* 对比页;点了才发现找不到(预先问时还有)时留在原页,提示服务端给的原因,并给「手动选择基准」去对比页自己挑。
* 这几种不给点,点了也只会被拒:没封存、还在运行的(对比只认已封存的运行,409);不属于已保存工作流的——画布、问数据
* 里没保存就运行的,和工作流删掉之后 workflow_id 被置空的(服务端找不到同一工作流的运行,手动选也是 422)。
*
* 菜单点完就收起:查找慢于一眨眼时挂一条提示,接口慢的时候不至于像没反应。离开运行详情(组件卸载)时取消请求、
* 收起提示,晚到的答复不再把人带去对比页。卸载取消不了全部:地址已经换了、新页面还没画出来(路由按过渡渲染)的那一下
* 答复回来,组件还在,请求也没取消。所以答复回来时再看一眼地址:人已经不在点菜单时的那个运行详情上,就不跳、也不提示。
* 叫法和触发历史里的链接一致:基准不是紧挨着的上一次(上一次失败、取消、还没结束)时写「与上一次成功的运行对比」。
* 点之前就得知道,所以拿到能对比的运行就预先问一次(previous)。问到之前这一项只写「正在查找」、不给点:菜单开着时不会
* 先写一个叫法、问到了再换成另一个,也点不到一个说错了的入口;平常运行一打开就问,人点开菜单时早已问到。问到没有默认
* 基准(多为工作流的第一次运行)时写「选择基准对比…」,点了直接去只带本次的对比页手动选:没有上一次可比还写「与上一次
* 对比」会误导人,再问一遍、弹一条提示也是多走一步。预先问不到(出错、ASK_TIMEOUT_MS 内没回来)不提示,叫法退回
* 「与上一次对比」、可点,点了照常再问一次、照实说。
*
* 写成 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<Known | null>(null)
// 在路上的那一次:stop 收起(或者不再挂出)「正在查找」的提示
const pending = useRef<{ ctrl: AbortController; stop: () => void } | null>(null)
useEffect(() => () => {
@@ -40,48 +67,88 @@ 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: <GitCompareArrows size={12} />,
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)
}
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)
})
const orphan = !run?.workflow_id
const unsealed = live || !run?.manifest_hash
// 能对比的才预先问:对比页只认已封存、属于已保存工作流的运行,这两种入口本来就不给点,先问没有用处
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, 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 }))
},
}
}, [navigate, busy])
)
return () => ctrl.abort()
}, [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,
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
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, 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, 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)
}
}).finally(() => {
stop()
if (pending.current?.ctrl !== ctrl) return
pending.current = null
setBusy(false)
})
},
}
}
+17 -3
View File
@@ -10,7 +10,7 @@ import {
RUN_STATUS_ORDER, STATUS, resolveStatus, serverStatusOf, type StatusCode,
} from '../../lib/status'
import { formatSpan, parseServerTime } from '../../lib/format'
import { INQUIRY_TEXT } from '../../lib/terms'
import { INQUIRY_TEXT, RUN_ARTIFACTS_TEXT } from '../../lib/terms'
import { hasPendingApproval } from '../../store/catalog'
// -------------------------------------------------------------------------
@@ -336,6 +336,7 @@ export const ARTIFACT_KIND_LABEL: Record<string, string> = {
dual_check: '双盲复算对照',
challenge: '反方审稿记录',
inquiry_answer: INQUIRY_TEXT.artifactKind,
metric_input: RUN_ARTIFACTS_TEXT.kindMetricInput,
}
export const artifactKindLabel = (kind: string): string => ARTIFACT_KIND_LABEL[kind] ?? kind
@@ -361,12 +362,25 @@ export function artifactDescription(kind: string, meta?: Record<string, any> | n
// 审批质询的回答文档(工件行的 meta 里有 {report, unit, question: 问题的前 40 字}):不挂在节点上,靠问题认出是哪一条
case 'inquiry_answer': return typeof meta?.question === 'string' && meta.question.trim()
? INQUIRY_TEXT.artifactAbout(meta.question) : INQUIRY_TEXT.artifactAboutNone
// 口径卡里容器型变量输入的完整值(工件行的 meta 里有 {path})
case 'metric_input': return RUN_ARTIFACTS_TEXT.aboutMetricInput(typeof meta?.path === 'string' ? meta.path : null)
default: return '工件'
}
}
/** 证据:查询、工具、检索的快照。节点产出是过程里的中间值,一次循环就是几十件 */
export const isEvidence = (kind: string): boolean => kind !== 'node_output'
/**
* 工件页签上的三组:evidence 证据快照(查询、工具、检索的快照,口径卡、报告文档、表结构);verify 核查记录(双盲复算、
* 反方审稿、审批质询对结论的核查与问答:以前混在证据快照里,和「原始结果」的说明对不上);output 节点产出(过程里的
* 中间值,一次循环就是几十件)。按种类分:核查、质询时执行的查询种类仍是查询快照,归在证据快照(两组的悬停说明都写明
* 了);认不出的种类也算证据快照,宁可醒目
*/
export type ArtifactGroup = 'evidence' | 'verify' | 'output'
const VERIFY_KINDS: ReadonlySet<string> = new Set(['dual_check', 'challenge', 'inquiry_answer'])
export const artifactGroup = (kind: string): ArtifactGroup =>
kind === 'node_output' ? 'output' : VERIFY_KINDS.has(kind) ? 'verify' : 'evidence'
/** 证据快照这一组的(见 artifactGroup) */
export const isEvidence = (kind: string): boolean => artifactGroup(kind) === 'evidence'
/** 三种时长写进 title:「总时长 1 分 27 秒 · 执行时长 7.6 s · 等待审批 1 分 14 秒」 */
export function timingTitle(t: RunTiming): string {
+5
View File
@@ -3439,6 +3439,11 @@ export interface RunCompareBase {
base_run_id: string
reason: RunCompareBaseReason | (string & {})
base: RunCompareRef
/**
* 基准是不是紧挨着的上一次(服务端 pick_base 算的,同触发历史里自动对比的 previous):上一次失败、取消、还没结束时
* 为 false,运行详情「更多」里的入口据此写「与上一次成功的运行对比」。老服务端不给,按 true 算
*/
previous?: boolean
}
/**
+316 -53
View File
@@ -11,8 +11,11 @@
// 都不能出现对比页)。下一步的提示要和页面上真能做的事对得上:下拉里没有能换的运行就不叫人去上方换,本次的记录
// 不存在时不给注定失败的「重试」。默认基准的说法照服务端的挑法(api/compare.py 的 pick_base)写「上一次成功的运行」
// ——上一次失败了,基准是再往前成功的那一次;定时任务触发历史里的「与上一次对比」和运行详情里的同名入口打开同一对
// 运行、写同一句来历,基准不是紧挨着的上一次时触发历史上看得见的字写明「上一次成功的运行」;找不到默认基准时标题不说
// 「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。
// 运行、写同一句来历,基准不是紧挨着的上一次时两处看得见的字都写明「与上一次成功的运行对比」(运行详情一打开就预先问
// 默认基准,问到之前菜单项不给点,不先写一个叫法再换成另一个;预先问出错、卡着不回时到点不提示,叫法退回、点了照常
// 再问;问到没有默认基准时写「选择基准对比…」,点了直接去对比页手动选;切回这一页时再问一次,问的期间叫法不变);
// 运行详情「工件」页签的悬停写上证据快照、核查记录和节点产出三组;找不到默认基准时
// 标题不说「没有可作为基准的运行」(失败、取消的照样能手动选),换基准的下拉和基准卡写明哪次失败、取消了。
//
// 全部用 page.route 假接口:虚构的「区域销售周报」三次已封存的正式运行和一次没封存的,运行详情、运行列表、
// compare/base、compare、定时任务列表、发起它们的定时任务和触发历史都在浏览器层答掉,别的 GET 放行(外壳要的工作流、
@@ -193,6 +196,13 @@ const FINDING = '正在查找上一次成功的运行'
const NO_OTHER = '同一工作流没有其他可换作基准的已封存运行'
/** 服务端(api/compare.py 的 NO_BASE_TEXT)找不到默认基准时的原话 */
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、不是
@@ -211,6 +221,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 +249,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 +266,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 +289,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 +377,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(' → '))
@@ -391,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'
/** 那一页落定:列表读回来了(假接口给的空列表) */
@@ -398,18 +442,20 @@ 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)先压住不放;打开详情时预先
* 问的那一次照常答、找到了 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 } : { json: BASE_OF_T }) })
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 +463,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 +556,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 +591,222 @@ 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(';'))
}
// 连得上却卡着不回(代理、网络卡住):预先问的那一次压住不答。到点按没问到算,入口不能一直停在不可点的「正在查找」
{
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 () => {
@@ -1046,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
@@ -1061,7 +1321,10 @@ await section('出错与找不到基准', async () => {
const none = await open('/runs/compare')
await settled(none.page, 'no-target')
check('什么都没带:说明从哪里进来', (await text(none.page, '[data-compare-page]')).includes('与上一次对比'))
// 说明不逐个列菜单项的叫法(有三种),只说从「更多」菜单打开、默认基准是上一次成功的运行;一句话不超过 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 -8
View File
@@ -11,8 +11,8 @@
// 不醒目、409 / 422 不照服务端的中文说明显示(或者问题文字丢了)、Esc 关不掉提问框(或者连带收起了别的、焦点掉到页面上)、
// 已处理的审批和封存了的正式运行还给入口、封存了的探索运行反而没有;同一次运行的几处问答串要对的审批不同、审批列表
// 取不到时后一处一直不列别的审批里问的(头上写着次数,下面一条没有),或者把没能确认的说成「报告其他版本」、不给重试;
// 工件页签里质询的回答只写英文的种类名、截断截在空格上的问题摘要不补省略号、封存后追加的工件给一个跳到运行结束之后的
// 航迹时刻;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
// 工件页签里质询的回答只写英文的种类名、不归在「核查记录」、截断截在空格上的问题摘要不补省略号、悬停里没有整句说明、
// 封存后追加的工件给一个跳到运行结束之后的航迹时刻;窄屏(390px)被撑出横向滚动、浅色主题下看不清。
//
// 跑在预览页上:/preview.html?syn=inquiry-*(用例见 frontend/src/dev/fixtures/inquiry.tsx)。夹具在
// frontend/src/dev/fixtures/inquiry.json,报告文档和回答文档是后端 build_catalog + compose_doc 真实组装的(虚构的数据)。
@@ -835,12 +835,12 @@ await section('运行详情「证据」页签:封存了的探索运行有入
await section('运行详情「工件」页签:质询的回答有名字和说明,不出现英文的工件种类名;封存后追加的不给航迹跳转', async () => {
const { ctx, page, errors } = await open('inquiry-run-artifacts')
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
// 每一行:种类(行首的标签)、说明(同一种类有几件时末尾带「#n」,去掉再比)、悬停
// 每一行:种类(行首的标签)、说明(同一种类有几件时的「#n」另在截断范围外,不在说明里)、悬停、分组
const rows = await page.locator('[data-artifact-id]').evaluateAll((els) => els.map((el) => {
const open = el.querySelector('[data-action="artifact-open"]')
const spans = open ? [...open.children] : []
return { id: el.getAttribute('data-artifact-id'), kind: spans[0]?.textContent?.trim() ?? '',
about: (spans[1]?.textContent ?? '').replace(/\s*#\d+$/, '').trim(), title: open?.getAttribute('title') ?? '' }
return { id: el.getAttribute('data-artifact-id'), kind: el.querySelector('[data-artifact-kind-label]')?.textContent?.trim() ?? '',
about: el.querySelector('[data-artifact-about]')?.textContent?.trim() ?? '', title: open?.getAttribute('title') ?? '',
group: el.getAttribute('data-artifact-class') }
}))
const answers = fx.run_artifacts.filter((a) => a.kind === 'inquiry_answer').map((a) => rows.find((r) => r.id === a.id))
check('质询的回答:种类写「质询回答」', answers.length === 4 && answers.every((r) => r?.kind === '质询回答'), JSON.stringify(answers))
@@ -864,7 +864,9 @@ await section('运行详情「工件」页签:质询的回答有名字和说
await page.locator(`[data-artifact-id="${doc.id}"] [data-action="artifact-moment"]`).click()
const jumped = await page.locator('[data-fixture="inquiry-run-artifacts"]').getAttribute('data-fixture-moment')
check('……点报告文档的跳转:到它产出的那一刻', Number(jumped) === Date.parse(`${doc.created_at}Z`), jumped)
check('悬停写「打开质询回答」', answers.every((r) => r?.title.startsWith('打开质询回答')), answers.map((r) => r?.title).join(' / '))
check('悬停写「打开质询回答」,接着是整句说明(行上截断了也看得全)', answers.every((r) => r?.title.startsWith(`打开质询回答:${r.about}`)),
answers.map((r) => r?.title).join(' / '))
check('质询回答归在「核查记录」,不算证据快照', answers.every((r) => r?.group === 'verify'), answers.map((r) => r?.group).join('、'))
const pane = await text(page.locator('[data-run-artifacts]'))
check('工件页签里不出现英文的工件种类名', !/inquiry_answer|report_doc|query_snapshot/.test(pane) && rows.every((r) => !/[a-z_]{4,}/.test(r.kind)),
rows.map((r) => r.kind).join('、'))
@@ -1531,7 +1533,7 @@ await section('窄屏与深浅色', async () => {
const row = `[data-artifact-id="${long.id}"] [data-action="artifact-open"]`
await page.locator(row).waitFor()
const o = await overflow(page, '[data-run-artifacts]')
const ratios = { kind: await ratioOf(page, `${row} > span:nth-child(1)`), about: await ratioOf(page, `${row} > span:nth-child(2)`) }
const ratios = { kind: await ratioOf(page, `${row} [data-artifact-kind-label]`), about: await ratioOf(page, `${row} [data-artifact-about]`) }
check(`390px 工件页签(${theme}):质询回答的长说明截断、不横向溢出,种类和说明对比度 ≥ 4.5`,
o.page <= 0 && o.box <= 0 && ratios.kind >= 4.5 && ratios.about >= 4.5,
JSON.stringify({ o, kind: Number(ratios.kind.toFixed(2)), about: Number(ratios.about.toFixed(2)) }))
+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)]
+196 -5
View File
@@ -22,7 +22,10 @@
// 标出,老工件照旧纯文本;
// - 出具缺口:证据页的出具横幅(真的 EvidencePane 里那一个 IssuanceBanner,和问数据、画布右栏同一个)和成果节点印章的
// 悬停说明把双盲复算、反方审稿报上来的缺口另起「核查未通过」「核查未完成」两组,不放在「校验未全部完成」下;
// - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名;
// - 记录页的工件列表:双盲复算对照、反方审稿记录两种工件有名字和说明,不露键名;和质询回答一起归在「核查记录」一组,
// 不再算进说明写着「原始结果」的「证据快照」,筛选的件数、悬停说明跟着改(核查、质询时执行的查询仍在证据快照,两组的
// 悬停都写明);390px 窄屏上说明换到第二行(以前挤在一行里只剩三个字),第几件、第几次尝试在截断范围外看得见,悬停
// 写全说明;320px 时校验值和大小不被拆开,每行仍是两行;
// - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的
// 叫法和服务端校验报错里的(engine/labels.py 的 DUAL_CHECK_FIELD_LABEL)一字不差;选口径卡、盲算模型、启用三态用的是
// 和反方审稿共用的控件(verifyFields.tsx);盲算模型不藏在高级选项里
@@ -32,8 +35,8 @@
// - 390px 窄屏不横向溢出,深浅色都读得清,键盘能操作。
//
// 页面都跑在预览页(/preview.html?syn=…,用例在 frontend/src/dev/fixtures/dual_check.tsx,夹具 dual_check.json 两边
// 共用)和设置页上,接口一律 page.route 伪造:完整对照的工件、审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉,
// 不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py、backend/app/engine/labels.py,terms.ts 借 vite
// 共用;工件页签的用例在 run_artifacts.tsx,工件列表直接交给页签)和设置页上,接口一律 page.route 伪造:完整对照的工件、
// 审批回复、设置的读写都在浏览器里答,别的非 GET 拦掉,不碰真实的写接口。文案对照那一节不开浏览器:读 backend/app/api/settings.py、backend/app/engine/labels.py,terms.ts 借 vite
// 转译后直接 import。
//
// node scripts/check-verify.mjs
@@ -1203,21 +1206,209 @@ await section('记录页的工件列表:双盲复算对照、反方审稿记
const { page, errors } = await preview('dual-check-approval')
const got = await page.evaluate(async () => {
const m = await import('/src/pages/runs/model.ts')
const kinds = ['dual_check', 'challenge', 'inquiry_answer', 'query_snapshot', 'tool_snapshot', 'retrieval_snapshot',
'metric_set', 'metric_input', 'report_doc', 'schema_snapshot', 'node_output', 'some_future_kind']
return {
dual: m.artifactKindLabel('dual_check'), challenge: m.artifactKindLabel('challenge'),
dualDesc: m.artifactDescription('dual_check', { metrics: 2 }), dualBare: m.artifactDescription('dual_check', null),
challengeDesc: m.artifactDescription('challenge', { report: 'report' }),
evidence: m.isEvidence('dual_check') && m.isEvidence('challenge'),
input: m.artifactKindLabel('metric_input'), inputDesc: m.artifactDescription('metric_input', { path: 'vars.region_rows' }),
inputBare: m.artifactDescription('metric_input', null),
groups: Object.fromEntries(kinds.map((k) => [k, m.artifactGroup(k)])),
evidence: Object.fromEntries(kinds.map((k) => [k, m.isEvidence(k)])),
}
})
check('种类:双盲复算对照、反方审稿记录,不露键名', got.dual === '双盲复算对照' && got.challenge === '反方审稿记录', JSON.stringify(got))
check('……说明写清装的是什么:双盲复算带指标个数,没有 meta 的老数据也有一句', got.dualDesc === '双盲复算:2 个指标两边的 SQL、取值与差异说明'
&& got.dualBare === '双盲复算:两边的 SQL、取值与差异说明' && got.challengeDesc === '反方审稿:每句结论的判断、理由和反方执行的查询',
JSON.stringify(got))
check('……两种都算证据(列表里醒目)', got.evidence)
// 核查记录:双盲复算、反方审稿、审批质询对结论的核查与问答。以前算进「证据快照」,那一组的说明写的是「查询、工具调用、
// 知识检索的原始结果」,和装的东西对不上。别的种类的归类不变:核查时执行的查询仍是查询快照,认不出的种类宁可醒目
const want = { dual_check: 'verify', challenge: 'verify', inquiry_answer: 'verify', node_output: 'output' }
const wrong = Object.entries(got.groups).filter(([k, g]) => g !== (want[k] ?? 'evidence'))
check('……双盲复算对照、反方审稿记录、质询回答归在「核查记录」;查询、工具、检索的快照,口径卡、报告文档、表结构仍是证据快照',
wrong.length === 0, JSON.stringify(wrong))
check('……isEvidence 只认证据快照这一组', Object.entries(got.evidence).every(([k, v]) => v === ((want[k] ?? 'evidence') === 'evidence')),
JSON.stringify(got.evidence))
check('……口径卡里容器型变量输入的完整值(metric_input)也有名字和说明,不露键名', got.input === '指标输入'
&& got.inputDesc === '口径卡输入「vars.region_rows」的完整值' && got.inputBare === '口径卡输入的完整值', JSON.stringify(got))
check('……页面没有报错', errors.length === 0, errors.join(';'))
})
/** 工件页签里每一行:种类、说明、第几件、悬停、分组,和种类标签、说明、第几件、时刻各自的位置 */
const artifactRows = (page) => page.locator('[data-run-artifacts] [data-artifact-id]').evaluateAll((els) => els.map((el) => {
const rect = (n) => {
if (!n) return null
const r = n.getBoundingClientRect()
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width }
}
const box = (sel) => rect(el.querySelector(sel))
const about = el.querySelector('[data-artifact-about]')
return {
id: el.getAttribute('data-artifact-id'), group: el.getAttribute('data-artifact-class'),
kind: el.querySelector('[data-artifact-kind-label]')?.textContent?.trim() ?? '',
about: about?.textContent?.trim() ?? '', nth: el.querySelector('[data-artifact-nth]')?.textContent?.trim() ?? '',
title: el.querySelector('[data-action="artifact-open"]')?.getAttribute('title') ?? '',
// 说明看得见的宽度、是否被截断(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^="内容校验值"]'), size: box('[data-artifact-size]'),
when: box('[data-action="artifact-moment"]') ?? box('[data-artifact-time]'),
}
}))
await section('记录页的工件页签:核查记录单独一组,筛选的件数和说明跟着改', async () => {
// 夹具(frontend/src/dev/fixtures/run_artifacts.tsx):22 件,证据快照 10 件(查询快照 6、表结构、指标输入、口径卡、
// 报告文档),核查记录 4 件(双盲复算对照、反方审稿记录、两条质询回答),节点产出 8 件
const { page, errors } = await preview('run-artifacts')
await page.locator('[data-run-artifacts] [data-artifact-id]').first().waitFor()
const filters = await page.locator('[data-run-artifacts] [data-artifact-kind]').evaluateAll((els) => els.map((el) => ({
key: el.getAttribute('data-artifact-kind'), text: el.textContent.replace(/\s+/g, ' ').trim(), title: el.getAttribute('title') ?? '',
})))
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]))
// 归类按种类:双盲复算、反方审稿、审批质询执行的查询仍是查询快照,归在证据快照(夹具里 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} 件`)
check('种类都是中文,不露键名(metric_input 写「指标输入」)', all.every((r) => r.kind && !/[a-z_]{4,}/.test(r.kind)),
[...new Set(all.map((r) => r.kind))].join('、'))
await page.locator('[data-run-artifacts] [data-artifact-kind="verify"]').click()
const verify = await artifactRows(page)
check('点「核查记录」:只剩双盲复算对照、反方审稿记录、质询回答 4 件', verify.length === 4 && verify.every((r) => r.group === 'verify')
&& JSON.stringify([...new Set(verify.map((r) => r.kind))].sort()) === JSON.stringify(['双盲复算对照', '反方审稿记录', '质询回答'].sort()),
verify.map((r) => r.kind).join('、'))
check('……按节点分组:双盲复算、反方审稿各一组,质询回答在「不属于某个节点」', JSON.stringify(await page.locator('[data-run-artifacts] [data-artifact-group]')
.evaluateAll((els) => els.map((e) => e.getAttribute('data-artifact-group')))) === JSON.stringify(['dual', 'review', '']))
await page.locator('[data-run-artifacts] [data-artifact-kind="evidence"]').click()
const evidence = await artifactRows(page)
check('点「证据快照」:10 件,没有核查记录混在里面(核查时执行的查询快照照旧在这里)', evidence.length === 10
&& evidence.every((r) => r.group === 'evidence') && !evidence.some((r) => /双盲复算对照|反方审稿记录|质询回答/.test(r.kind))
&& evidence.filter((r) => r.kind === '查询快照').length === 6, evidence.map((r) => r.kind).join('、'))
await page.locator('[data-run-artifacts] [data-artifact-kind="output"]').click()
const output = await artifactRows(page)
check('点「节点产出」:8 件,都是节点产出', output.length === 8 && output.every((r) => r.group === 'output' && r.kind === '节点产出'),
output.map((r) => r.kind).join('、'))
await page.locator('[data-run-artifacts] [data-artifact-kind="all"]').click()
// 宽屏一行排下:说明和种类在同一行,第几件紧跟说明
const wide = await artifactRows(page)
const looped = wide.filter((r) => r.nth)
check('宽屏(1280):说明和种类在同一行', wide.every((r) => r.line && r.chip && Math.abs(r.line.top - r.chip.top) < 8),
JSON.stringify(wide.filter((r) => !(r.line && r.chip && Math.abs(r.line.top - r.chip.top) < 8)).map((r) => r.kind)))
check('宽屏:逐区汇总的三次产出编了号,重试的那次写明第 2 次尝试', JSON.stringify(looped.filter((r) => r.kind === '节点产出').map((r) => r.nth))
=== JSON.stringify(['#1', '#2 · 第 2 次尝试', '#3']), looped.map((r) => `${r.kind}${r.nth}`).join('、'))
check('宽屏:短的说明不截断', wide.filter((r) => [...r.about].length <= 20).every((r) => !r.cut),
wide.filter((r) => [...r.about].length <= 20 && r.cut).map((r) => r.about).join('、'))
check('悬停写全说明(打开哪一种、装的是什么、第几件)', wide.every((r) => r.title.startsWith(`打开${r.kind}:${r.about}`)
&& (!r.nth || r.title.includes(r.nth))), wide.find((r) => !r.title.startsWith(`打开${r.kind}:${r.about}`))?.title)
check('页面没有报错', errors.length === 0, errors.join(';'))
})
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()
const rows = await artifactRows(page)
const o = await page.evaluate(() => {
const doc = document.documentElement
const pane = document.querySelector('[data-run-artifacts]')
const scroller = pane?.querySelector('.overflow-y-auto')
return { page: doc.scrollWidth - doc.clientWidth, pane: pane ? pane.scrollWidth - pane.clientWidth : -1,
list: scroller ? scroller.scrollWidth - scroller.clientWidth : -1 }
})
check(`390px(${theme}):页签不横向溢出`, o.page <= 0 && o.pane <= 0 && o.list <= 0, JSON.stringify(o))
// 说明在种类下面另起一行;以前和种类、校验值、大小、时刻挤在一行,只剩三个字宽(约 35px)
const below = rows.filter((r) => r.line && r.chip && r.line.top >= r.chip.bottom - 1)
check(`390px(${theme}):每一行的说明都换到种类下面的第二行`, below.length === rows.length,
rows.filter((r) => !below.includes(r)).map((r) => r.kind).join('、'))
const widths = rows.map((r) => r.visible)
const narrowest = rows.filter((r) => r.cut).reduce((m, r) => Math.min(m, r.visible), Infinity)
check(`390px(${theme}):说明看得见的宽度:被截断的至少 200px,其余整句看得全`,
rows.every((r) => !r.cut || r.visible >= 200), `截断的最窄 ${narrowest === Infinity ? '—' : `${narrowest}px`};各行 ${widths.join('/')}`)
const long = rows.find((r) => r.kind === '质询回答' && r.about.endsWith('…」的回答'))
check(`390px(${theme}):长问题的质询回答截断了,悬停里是整句说明`, !!long && long.cut && long.title.includes(long.about),
long ? `${long.visible}px · ${long.title}` : '没找到长问题的那一条')
// 第几件:放在截断范围外,整个落在这一行里、看得见
const numbered = rows.filter((r) => r.nth)
check(`390px(${theme}):第几件、第几次尝试都看得见(不在被截断的说明里,不出这一行)`, numbered.length >= 5
&& numbered.every((r) => r.nthBox && r.nthBox.width > 0 && r.nthBox.right <= r.row.right + 0.5 && r.nthBox.left >= r.row.left),
numbered.map((r) => `${r.kind}${r.nth}`).join('、'))
// 第一行:种类、校验值、大小,时刻(或时间)和它们在同一行
check(`390px(${theme}):时刻(或时间)留在第一行,和种类对齐`, rows.every((r) => r.when && r.chip
&& Math.abs((r.when.top + r.when.bottom) / 2 - (r.chip.top + r.chip.bottom) / 2) <= 4),
JSON.stringify(rows.filter((r) => !(r.when && r.chip && Math.abs((r.when.top + r.when.bottom) / 2 - (r.chip.top + r.chip.bottom) / 2) <= 4))
.map((r) => [r.kind, r.when, r.chip]).slice(0, 2)))
check(`390px(${theme}):内容校验值还在第一行`, rows.every((r) => r.hash && r.chip && r.hash.bottom <= r.line.top + 1))
const c = await page.evaluate(() => {
const base = getComputedStyle(document.body).backgroundColor
const pick = (sel) => {
const el = document.querySelector(sel)
if (!el) return null
let bg = 'rgba(0, 0, 0, 0)'
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
const v = getComputedStyle(n).backgroundColor
if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') { bg = v; break }
}
return { fg: getComputedStyle(el).color, bg }
}
return { base, kind: pick('[data-artifact-class="verify"] [data-artifact-kind-label]'),
about: pick('[data-artifact-class="verify"] [data-artifact-about]'), nth: pick('[data-artifact-nth]'),
filter: pick('[data-artifact-kind="verify"]') }
})
for (const key of ['kind', 'about', 'nth', 'filter']) {
const v = c[key]
const ratio = v ? contrast(v.fg, v.bg, c.base) : 0
check(`${theme}:${({ kind: '种类', about: '说明', nth: '第几件', filter: '「核查记录」筛选' })[key]}对比度 ≥ 4.5`, ratio >= 4.5,
`${ratio.toFixed(2)}(${v?.fg} on ${v?.bg})`)
}
const bar = await page.locator('[data-run-artifacts] [data-artifact-kind]').evaluateAll((els) =>
els.every((el) => { const r = el.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth && r.width > 0 }))
check(`390px(${theme}):四个筛选按钮都在屏幕里`, bar)
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')
})
// ---------------------------------------------------------------- 检查器与节点卡片
/**