记录页、画布上找得到被驳回的运行:分段、计数、搜索词、遥测、缩略条、运行胶囊

为什么:被驳回的显示码 rejected 不在 RUN_STATUS_ORDER 里,也不在计数的 COUNTED 里,搜索词表里没有「驳回」。点「已取消」
分段时服务端交回了它,前端按显示码一筛又筛掉了;以前它算在「失败」里,现在哪个计数里都没有;搜「驳回」不当状态词认;
详情的状态格没有副标题,缩略条没有结束标记。画布的运行胶囊更糟:相位是 cancelled,写「已取消」,一句话写「张工 取消了
本次运行 · 停在「研究协作团队」」——驳回的人并没有停过它。

做了什么:
- lib/status:RUN_STATUS_ORDER 在已取消后面加上 rejected(分段、搜索词表都从它生成),查询时照旧换回 cancelled。
- 计数加上被驳回;搜索词另认「驳回」「已驳回」;缩略条和已取消共用一个结束标记(同一个剪影和颜色)。
- 遥测的状态格:副标题写谁在哪一步驳回、备注(REJECTED_TEXT.sub,和时间线那一行同一句);run.cancelled 还没取到时
  按驳回的那一步说。
- 运行胶囊:run.cancelled 带 reason=rejected 或运行记录 end_reason=rejected 时,状态写「被驳回」,一句话写
  「张工 驳回了「主管审批」:口径不对」,不再接「停在…」;回放到中途照旧说那一刻的相位。手动停止照旧。

检查:check-runs 加一节(伪造被驳回、手动停止、失败各一条):分段和计数里有被驳回、已取消和失败的计数都不含它、点被驳回
只留它、点已取消只留手动停止、搜「驳回」「被驳回」都筛得出、失败页签里没有它、详情显示码和状态格。check-canvas-fx 加
一节被驳回的胶囊。把这几处改动退回去回跑,check-runs 4 项、check-canvas-fx 3 项不通过;改完都通过。
This commit is contained in:
ThearchyHelios
2026-10-16 12:46:35 +08:00
parent 5c34ac6bc5
commit 58c26baa33
7 changed files with 140 additions and 21 deletions
+4 -3
View File
@@ -183,10 +183,11 @@ export function statusLabel(status: string | null | undefined, opts?: StatusOpts
/** /**
* 运行列表的状态筛选用:按显示顺序排好的运行状态。 * 运行列表的状态筛选用:按显示顺序排好的运行状态。
* 这是显示用的码,不能直接当查询参数——waiting / held 在后端都是 interrupted, * 这是显示用的码,不能直接当查询参数——waiting / held 在后端都是 interrupted,
* 查询前用 serverStatusOf 换回去。 * rejected 在后端是 cancelled(+ end_reason=rejected),查询前用 serverStatusOf 换回去。
* 被驳回单独一项、紧跟在已取消后面:不在这里的码,分段、计数、搜索词表里都没有它,被驳回的运行只剩「全部」里看得到
*/ */
export const RUN_STATUS_ORDER: StatusCode[] = [ export const RUN_STATUS_ORDER: StatusCode[] = [
'running', 'waiting', 'held', 'failed', 'succeeded', 'cancelled', 'queued', 'running', 'waiting', 'held', 'failed', 'succeeded', 'cancelled', 'rejected', 'queued',
] ]
/** /**
@@ -194,7 +195,7 @@ export const RUN_STATUS_ORDER: StatusCode[] = [
* *
* waiting、held、suspended 在后端都是 interrupted:按它查回来的是两种混在一起, * waiting、held、suspended 在后端都是 interrupted:按它查回来的是两种混在一起,
* 要再用 resolveStatus(run.status, {pendingApproval: hasPendingApproval(approvals, * 要再用 resolveStatus(run.status, {pendingApproval: hasPendingApproval(approvals,
* run.id)}) 在前端分开。节点才有的状态(idle / skipped / blocked / unreached) * run.id)}) 在前端分开。rejected 同理查 cancelled,回来按 end_reason 分开(被驳回 / 手动停止)。节点才有的状态(idle / skipped / blocked / unreached)
* 不是运行状态,返回 null。 * 不是运行状态,返回 null。
*/ */
export function serverStatusOf(code: StatusCode): RunStatus | null { export function serverStatusOf(code: StatusCode): RunStatus | null {
+5 -1
View File
@@ -10,7 +10,7 @@ import { type StatusCode } from '../../lib/status'
import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace' import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace'
import { useRunClock } from '../../run/useRunClock' import { useRunClock } from '../../run/useRunClock'
import type { Approval, Run } from '../../types' import type { Approval, Run } from '../../types'
import { BUNDLE_TEXT, COST_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms' import { BUNDLE_TEXT, COST_TEXT, REJECTED_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms'
import { import {
LONG_WAIT_MS, ageMs, cancelReason, runScope, runTiming, type RunTiming, LONG_WAIT_MS, ageMs, cancelReason, runScope, runTiming, type RunTiming,
} from './model' } from './model'
@@ -116,6 +116,10 @@ export function Telemetry({ run, trace, phase, code, streaming, pending, labelOf
sub = '断点完好,可继续运行' sub = '断点完好,可继续运行'
} else if (code === 'cancelled') { } else if (code === 'cancelled') {
sub = cancelNote(run.error, cancelled) sub = cancelNote(run.error, cancelled)
} else if (code === 'rejected') {
// 谁在哪一步驳回、备注:和时间线那一行同一句(REJECTED_TEXT.sub)。run.cancelled 还没取到时按驳回的那一步说
sub = cancelled?.reason === 'rejected' ? REJECTED_TEXT.sub(cancelled)
: REJECTED_TEXT.sub({ label: labelOf(run.error_node_id) })
} }
const nodesTotal = proj?.nodesTotal ?? 0 const nodesTotal = proj?.nodesTotal ?? 0
+4 -2
View File
@@ -93,6 +93,8 @@ for (const [word, codes] of [
['挂起', ['held']], ['中断', ['held']], ['已中断', ['held']], ['可继续运行', ['held']], ['挂起', ['held']], ['中断', ['held']], ['已中断', ['held']], ['可继续运行', ['held']],
['interrupted', ['waiting', 'held']], ['interrupted', ['waiting', 'held']],
['取消', ['cancelled']], ['canceled', ['cancelled']], ['取消', ['cancelled']], ['canceled', ['cancelled']],
// 被驳回(审批人的决定,不是失败):屏幕上写「被驳回」,人也会只搜「驳回」
['驳回', ['rejected']], ['已驳回', ['rejected']],
['出错', ['failed']], ['报错', ['failed']], ['error', ['failed']], ['出错', ['failed']], ['报错', ['failed']], ['error', ['failed']],
['排队', ['queued']], ['执行中', ['running']], ['进行中', ['running']], ['排队', ['queued']], ['执行中', ['running']], ['进行中', ['running']],
] as [string, StatusCode[]][]) addWord(word, codes) ] as [string, StatusCode[]][]) addWord(word, codes)
@@ -217,7 +219,7 @@ export type ShapeKind = 'active' | 'wait' | 'other' | 'unknown' | 'live'
export interface RunShape { export interface RunShape {
/** 各段占墙钟的比例,加起来是 1;空数组 = 连墙钟都不知道 */ /** 各段占墙钟的比例,加起来是 1;空数组 = 连墙钟都不知道 */
segs: { kind: ShapeKind; frac: number; open?: boolean }[] segs: { kind: ShapeKind; frac: number; open?: boolean }[]
/** 收在哪种结局上:只给要注意的几种,已完成不标 */ /** 收在哪种结局上:只给要注意的几种,已完成不标。被驳回和已取消同一个结束标记(同 lib/status 的剪影和颜色) */
end: 'failed' | 'cancelled' | 'suspended' | null end: 'failed' | 'cancelled' | 'suspended' | null
title: string title: string
} }
@@ -266,7 +268,7 @@ export function runShape(
} }
} }
const end: RunShape['end'] = code === 'failed' ? 'failed' : code === 'cancelled' ? 'cancelled' const end: RunShape['end'] = code === 'failed' ? 'failed' : code === 'cancelled' || code === 'rejected' ? 'cancelled'
: code === 'held' || code === 'suspended' ? 'suspended' : null : code === 'held' || code === 'suspended' ? 'suspended' : null
if (t.wallMs == null || t.wallMs <= 0) { if (t.wallMs == null || t.wallMs <= 0) {
return { segs: [], end, title: t.activeMs != null ? timingTitle(t) : '没有时长记录' } return { segs: [], end, title: t.activeMs != null ? timingTitle(t) : '没有时长记录' }
+5 -2
View File
@@ -127,8 +127,11 @@ export function useRunList(filters: ListFilters, enabled: boolean): RunList {
// 计数 // 计数
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** 数的是"需要看一眼"的几类;已完成不数——它是常态,数字只会分散注意力 */ /**
const COUNTED: StatusCode[] = ['running', 'queued', 'waiting', 'held', 'failed', 'cancelled'] * 数的是"需要看一眼"的几类;已完成不数——它是常态,数字只会分散注意力。被驳回以前记成失败、算在「失败」里,现在单独
* 一类,不数就哪个计数里都没有它
*/
const COUNTED: StatusCode[] = ['running', 'queued', 'waiting', 'held', 'failed', 'cancelled', 'rejected']
const COUNT_LIMIT = 200 const COUNT_LIMIT = 200
export interface StatusCounts { export interface StatusCounts {
+28 -13
View File
@@ -8,7 +8,7 @@ import { confirmDialog, Kbd, Spinner, StatusBadge, toast } from '../components/u
import { formatCost, formatDuration, formatLapse, formatOffset, formatSpan, formatTokens, NONE } from '../lib/format' import { formatCost, formatDuration, formatLapse, formatOffset, formatSpan, formatTokens, NONE } from '../lib/format'
import { isTypingTarget, matchShortcut } from '../lib/keys' import { isTypingTarget, matchShortcut } from '../lib/keys'
import { statusMeta, STATUS, type StatusCode } from '../lib/status' import { statusMeta, STATUS, type StatusCode } from '../lib/status'
import { COST_TEXT, issuanceLabel, runClassLabel, runCostText } from '../lib/terms' import { COST_TEXT, REJECTED_TEXT, issuanceLabel, runClassLabel, runCostText } from '../lib/terms'
import { lastStampOf, projectSettled, useDock, waitedMs } from '../canvas/RunTimeline' import { lastStampOf, projectSettled, useDock, waitedMs } from '../canvas/RunTimeline'
import { structureSig, useStudio } from '../store/studio' import { structureSig, useStudio } from '../store/studio'
import type { RunEvent } from '../types' import type { RunEvent } from '../types'
@@ -111,29 +111,35 @@ export function phaseLabel(phase: RunPhase): string {
/** /**
* 这次运行是怎么取消的:run.cancelled 带着谁放弃的(actor)和一句说明(放弃等审批、 * 这次运行是怎么取消的:run.cancelled 带着谁放弃的(actor)和一句说明(放弃等审批、
* 挂起的运行时才有,如「放弃了这次运行,1 条待审批一并关闭」)。按事件数组缓存 * 挂起的运行时才有,如「放弃了这次运行,1 条待审批一并关闭」)。按事件数组缓存。
*
* 被人驳回结束的也是 run.cancelled(reason=rejected,05-已知遗留 (a)):rejected 为真,note 写谁在哪一步驳回、备注
* (REJECTED_TEXT.sub,同时间线那一行)。以前一律写「张工 取消了本次运行」,驳回的人并没有停过它
*/ */
const cancelCache = new WeakMap<RunEvent[], string>() const cancelCache = new WeakMap<RunEvent[], { note: string; rejected: boolean }>()
function cancelNote(events: RunEvent[]): string { function cancelNote(events: RunEvent[]): { note: string; rejected: boolean } {
const hit = cancelCache.get(events) const hit = cancelCache.get(events)
if (hit != null) return hit if (hit != null) return hit
let note = '' let out = { note: '', rejected: false }
for (let i = events.length - 1; i >= 0; i -= 1) { for (let i = events.length - 1; i >= 0; i -= 1) {
const e = events[i] const e = events[i]
if (!String(e.type).startsWith('run.')) continue if (!String(e.type).startsWith('run.')) continue
if (e.type === 'run.cancelled') { if (e.type === 'run.cancelled' && e.data?.reason === 'rejected') {
out = { note: REJECTED_TEXT.sub(e.data), rejected: true }
} else if (e.type === 'run.cancelled') {
const who = typeof e.data?.actor === 'string' ? e.data.actor.trim() : '' const who = typeof e.data?.actor === 'string' ? e.data.actor.trim() : ''
const what = typeof e.data?.message === 'string' ? e.data.message.trim() : '' const what = typeof e.data?.message === 'string' ? e.data.message.trim() : ''
note = what ? `${who ? `${who} ` : ''}${what}` : who ? `${who} 取消了本次运行` : '' out = { note: what ? `${who ? `${who} ` : ''}${what}` : who ? `${who} 取消了本次运行` : '', rejected: false }
} }
break break
} }
cancelCache.set(events, note) cancelCache.set(events, out)
return note return out
} }
function headlineOf(t: Trace, phase: RunPhase, states: Record<string, NodeState>, at: number, function headlineOf(t: Trace, phase: RunPhase, states: Record<string, NodeState>, at: number,
label: (id: string) => string, errorText: string | null | undefined, cancelled = ''): string { label: (id: string) => string, errorText: string | null | undefined, cancelled = '',
rejected = false): string {
const running = Object.keys(states).filter((id) => states[id] === 'running') const running = Object.keys(states).filter((id) => states[id] === 'running')
switch (phase) { switch (phase) {
case 'queued': case 'queued':
@@ -167,6 +173,8 @@ function headlineOf(t: Trace, phase: RunPhase, states: Record<string, NodeState>
case 'succeeded': case 'succeeded':
return t.issuance?.tier ? `运行完成 · ${issuanceLabel(t.issuance.tier)}` : '运行完成' return t.issuance?.tier ? `运行完成 · ${issuanceLabel(t.issuance.tier)}` : '运行完成'
case 'cancelled': { case 'cancelled': {
// 被驳回:那一句已经写了在哪一步(「张工 驳回了「付款」:…」),不再接「停在「付款」」
if (rejected) return cancelled || REJECTED_TEXT.title
const stopped = Object.keys(states).filter((id) => states[id] === 'cancelled') const stopped = Object.keys(states).filter((id) => states[id] === 'cancelled')
const where = stopped.length ? `停在「${label(stopped[0])}」` : '已取消' const where = stopped.length ? `停在「${label(stopped[0])}」` : '已取消'
return cancelled ? `${cancelled} · ${where}` : where return cancelled ? `${cancelled} · ${where}` : where
@@ -187,7 +195,9 @@ export function useRunGlance(active: boolean): RunGlance {
const runError = useStudio((s) => s.run?.error) const runError = useStudio((s) => s.run?.error)
const nodes = useStudio((s) => s.nodes) const nodes = useStudio((s) => s.nodes)
const edges = useStudio((s) => s.edges) const edges = useStudio((s) => s.edges)
const cancelled = useStudio((s) => (s.runPhase === 'cancelled' ? cancelNote(s.events) : '')) const ending = useStudio((s) => (s.runPhase === 'cancelled' ? cancelNote(s.events) : null))
// 被驳回只看服务端说的(run.cancelled 的 reason,或运行记录的 end_reason),不按报错原文猜
const runRejected = useStudio((s) => s.run?.end_reason === 'rejected')
const limitMs = useRunLimit() const limitMs = useRunLimit()
const ticking = active && replayAt == null && isActivePhase(phaseLive) const ticking = active && replayAt == null && isActivePhase(phaseLive)
const now = useRunClock(ticking) const now = useRunClock(ticking)
@@ -229,6 +239,11 @@ export function useRunGlance(active: boolean): RunGlance {
const remainingMs = limitMs != null && phase === 'running' && drive && drive[1] == null const remainingMs = limitMs != null && phase === 'running' && drive && drive[1] == null
? Math.max(0, limitMs - (at - drive[0])) : null ? Math.max(0, limitMs - (at - drive[0])) : null
// 结局是被驳回:只在没拖游标、停在最后时这么说(回放到中途说的是那一刻的相位)
const rejectedEnd = replayAt == null && phase === 'cancelled' && (!!ending?.rejected || runRejected)
const cancelled = ending?.note ?? ''
const code: StatusCode = rejectedEnd ? 'rejected' : phase
const attention: Attention[] = [] const attention: Attention[] = []
for (const c of cells) { for (const c of cells) {
if (c.state !== 'failed') continue if (c.state !== 'failed') continue
@@ -251,9 +266,9 @@ export function useRunGlance(active: boolean): RunGlance {
} }
return { return {
phase, code: phase, label: phaseLabel(phase), replay: replayAt != null, at, phase, code, label: code === 'rejected' ? STATUS.rejected.label : phaseLabel(phase), replay: replayAt != null, at,
elapsedMs: proj.elapsedMs, activeMs: proj.activeMs, waitMs: proj.waitMs, elapsedMs: proj.elapsedMs, activeMs: proj.activeMs, waitMs: proj.waitMs,
headline: headlineOf(trace, phase, states, at, label, runError, replayAt == null ? cancelled : ''), headline: headlineOf(trace, phase, states, at, label, runError, replayAt == null ? cancelled : '', rejectedEnd),
executing: phase === 'running' && cells.some((c) => c.state === 'running' && !trace.nodes[c.id]?.looping), executing: phase === 'running' && cells.some((c) => c.state === 'running' && !trace.nodes[c.id]?.looping),
cells, done, visited, parallelNow: proj.parallelNow, series: trace.parallelSeries, cells, done, visited, parallelNow: proj.parallelNow, series: trace.parallelSeries,
tokens, cost, unpriced, remainingMs, limitMs, attention, tokens, cost, unpriced, remainingMs, limitMs, attention,
+15
View File
@@ -706,6 +706,21 @@ await settleCase('取消:清除、回放', [
ev(9, 'run.cancelled', null, { timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }, 9), ev(9, 'run.cancelled', null, { timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }, 9),
], { text: '已取消', actions: ['清除', '回放'] }) ], { text: '已取消', actions: ['清除', '回放'] })
// 人的驳回也是 run.cancelled(reason=rejected,05-已知遗留 (a)):胶囊写「被驳回」、谁在哪一步驳回,不写「张工 取消了本次运行」
await settleCase('被驳回:胶囊写被驳回和谁在哪一步驳回', [
ev(9, 'run.cancelled', null, { reason: 'rejected', node_id: 'review', label: '主管审批', kind: 'approve', decision: 'reject',
actor: '张工', note: '口径不对', timing: { wall_ms: 9000, active_ms: 9000, wait_ms: 0 } }, 9),
], {
text: '被驳回', actions: ['清除', '回放'],
more: async (page) => {
const cap = await capText(page)
check('胶囊不写「已取消」', !cap.includes('已取消'), cap)
const title = await capsule(page).locator('.sf-cap-main').getAttribute('title') ?? ''
check('一句话写谁在哪一步驳回、备注,不写「取消了本次运行」', title.includes('张工 驳回了「主管审批」:口径不对')
&& !title.includes('取消了本次运行'), title)
},
})
await settleCase('服务重启挂起:接着跑', [ await settleCase('服务重启挂起:接着跑', [
ev(9, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关闭时这次运行仍在进行,已挂起;重启后可从断点继续运行' }, 9), ev(9, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关闭时这次运行仍在进行,已挂起;重启后可从断点继续运行' }, 9),
], { ], {
+79
View File
@@ -2441,6 +2441,85 @@ await section('生效记录面板', async () => {
fakes.delete('GET /api/actions') fakes.delete('GET /api/actions')
}) })
await section('被驳回:分段、计数、搜索、详情都找得到(05-已知遗留 (a))', async () => {
// 人的驳回结束在 cancelled + end_reason=rejected,显示码是 rejected。以前它记成失败,算在「失败」里;只在显示上分出来、
// 分段和计数里没有它的话,点「已取消」被筛掉、搜「驳回」认不出,被驳回的运行只剩「全部」里看得到
if (!base) { check('沙箱里有一条成功的运行', false); return }
const t = Date.now() / 1000 - 3600
const at = (sec) => new Date(sec * 1000).toISOString()
const ended = (id, status, endReason, error, back) => ({ ...base, id, status, end_reason: endReason, error,
error_node_id: status === 'failed' || endReason === 'rejected' ? 'gate' : null, workflow_id: null,
created_at: at(t - back), started_at: at(t - back), finished_at: at(t - back + 6), usage: { wall_ms: 6000, active_ms: 6000, wait_ms: 0 } })
const REJ = 'fake0rej0rejected'
const rows = [
ended(REJ, 'cancelled', 'rejected', '「付款」未确认(被驳回),没有发送:金额不对', 0),
ended('fake0rej0stopped', 'cancelled', 'stopped', '用户取消', 60),
ended('fake0rej0failed', 'failed', null, '查询超时', 120),
]
fakes.set('GET /api/runs', (url) => {
const want = (url.searchParams.get('status') ?? '').split(',').filter(Boolean)
return { status: 200, json: want.length ? rows.filter((r) => want.includes(r.status)) : rows }
})
const graph = { nodes: [
{ id: 'start', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: {} } },
{ id: 'gate', type: 'human', position: { x: 200, y: 0 }, data: { label: '付款', config: { mode: 'approve', stop_on_reject: true } } },
], edges: [{ source: 'start', target: 'gate' }] }
fakes.set(`GET /api/runs/${REJ}`, () => ({ status: 200, json: rows[0] }))
fakes.set(`GET /api/runs/${REJ}/graph`, () => ({ status: 200, json: { graph, workflow_id: null, version: null } }))
fakes.set(`GET /api/runs/${REJ}/events`, () => ({ status: 200, json: [
{ seq: 1, type: 'run.started', node_id: null, ts: t, data: { nodes: 2 } },
{ seq: 2, type: 'node.started', node_id: 'start', ts: t + 0.1, data: {} },
{ seq: 3, type: 'node.finished', node_id: 'start', ts: t + 0.2, data: { duration_ms: 100 } },
{ seq: 4, type: 'node.started', node_id: 'gate', ts: t + 0.3, data: {} },
{ seq: 5, type: 'node.failed', node_id: 'gate', ts: t + 5.9, data: { error: rows[0].error } },
{ seq: 6, type: 'run.cancelled', node_id: null, ts: t + 6, data: { reason: 'rejected', node_id: 'gate', label: '付款',
kind: 'approve', decision: 'reject', actor: '张工', note: '金额不对', timing: { wall_ms: 6000, active_ms: 6000, wait_ms: 0 } } },
] }))
try {
await page.goto(`${WEB}/runs`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
const seg = page.locator('[data-segment=rejected]')
check('「全部」的分段里有「被驳回」', await seg.count() === 1 && (await seg.innerText()).includes('被驳回'))
check('计数里有被驳回的那一条', (await seg.locator('.tnum').innerText().catch(() => '')) === '1',
await seg.innerText().catch(() => ''))
check('「已取消」的计数不含被驳回的', (await page.locator('[data-segment=cancelled] .tnum').innerText().catch(() => '')) === '1')
check('「失败」的计数不含被驳回的', (await page.locator('[data-segment=failed] .tnum').innerText().catch(() => '')) === '1')
const row = page.locator(`[data-runs-list] [data-run-id="${REJ}"]`)
check('列表行写「被驳回」和原话,状态码是 rejected', await row.getAttribute('data-status') === 'rejected'
&& (await row.innerText()).includes('被驳回'), (await row.innerText().catch(() => '')).replace(/\n/g, ' / '))
check('缩略条收在和已取消同一个结束标记上', await row.locator('[data-shape-end=cancelled]').count() === 1)
await listAfter(() => seg.click(), (p) => p.get('status') === 'cancelled')
check('点「被驳回」:查 cancelled、只留被驳回的', (await ids()).join(',') === REJ, (await ids()).join(','))
check('分段写进地址 ?status=rejected', new URL(page.url()).searchParams.get('status') === 'rejected')
// 两段查的都是 cancelled:先回「全部」,不然这一次不会重查
await listAfter(() => page.locator('[data-segment=all]').click(), (p) => !p.get('status'))
await listAfter(() => page.locator('[data-segment=cancelled]').click(), (p) => p.get('status') === 'cancelled')
check('点「已取消」:只留手动停止的', (await ids()).join(',') === 'fake0rej0stopped', (await ids()).join(','))
await page.locator('[data-segment=all]').click()
await settle()
for (const word of ['驳回', '被驳回']) {
await search(word, (p) => p.get('status') === 'cancelled')
check(`搜「${word}」按状态筛出被驳回的`, (await ids()).join(',') === REJ
&& (await page.locator('[data-runs-hint]').innerText()).includes(word), (await ids()).join(','))
await search('', (p) => !p.get('status'))
}
await page.locator('[role=tab][data-tab=failed]').click()
await settle()
check('「失败」页签里没有被驳回的(不是故障)', (await ids()).join(',') === 'fake0rej0failed', (await ids()).join(','))
await page.goto(`${WEB}/runs/${REJ}`, { waitUntil: 'networkidle' })
await page.locator('[data-run-detail]').waitFor()
await page.waitForTimeout(400)
check('详情的显示码是 rejected', await page.locator('[data-run-detail]').getAttribute('data-run-code') === 'rejected')
const tele = (await page.locator('[data-run-telemetry]').innerText()).replace(/\n/g, ' / ')
check('状态格写「被驳回」,副标题写谁在哪一步驳回、备注', tele.includes('被驳回') && tele.includes('张工 驳回了「付款」:金额不对')
&& !tele.includes('已取消'), tele.slice(0, 160))
} finally {
for (const k of ['GET /api/runs', `GET /api/runs/${REJ}`, `GET /api/runs/${REJ}/graph`, `GET /api/runs/${REJ}/events`]) fakes.delete(k)
}
})
await section('收尾', async () => { await section('收尾', async () => {
// 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 POST // 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 POST
// (同样被探针拦下,不落库)。赶上它们发出来之前就离开了画布的话就没有 // (同样被探针拦下,不落库)。赶上它们发出来之前就离开了画布的话就没有