Files
AgentLab/scripts/check-schedules.mjs
T
ThearchyHelios 656a550408 数据事件触发按复核意见修正:途经驳回、去抖积压、source.changed 补写、按期次分窗口、补算缺省不认、示例出口字段同一套过滤
M1 途经驳回(编排者已定):outbox.stage_run_finished 收可选的 rejected_via(缺省 None 即没途经驳回),事件内容带 rejected 和
rejected_via,说法接「(途经驳回)」,模板有 {{ event.rejected }}(true / false)。触发条件缺省只认没途经驳回的,trigger 加
include_rejected(界面「也认途经驳回的」勾选,说明照实写)。runner._finalize 把 _rejected_via(state) 传进来那一下留给编排者
在合并时接(runner 的 _rejected_via 在 W1-b 的分支上);测试直接调 stage_run_finished 传参覆盖。

C1 去抖积压:以前一拍只走一个窗口,积压时后面的越等越久、全过了宽限,而且每个窗口各记一条跳过,原因还说调度没在跑。
现在 _drain 按发生时刻把等着的事件切成窗口,一拍里把到点的处理完(最多 100 个窗口、5000 条);排在前面已过补发宽限的合成
一条 skipped_misfire(合并了几次事件、几个窗口、时间跨度,上传表格另写合掉的期次),派给任务时已经晚于宽限才说「调度未运行」,
否则照实说积压;宽限内的照常发起(照重叠规则跳过的照实记)。

C3 source.changed:这一处确实没法和变化记录同一个事务(一次探查、剖析的变化分好几批落库,一批只触发一次要等任务结束才知道),
改成说实话:models_triggers 的说明写清哪两种同事务、哪一种不是。trigger_events 加 dedupe_key(source.changed:<任务号>,唯一),
emit_source_changed 按它去重;调度的一拍(刚起的服务第一拍就补,之后每 10 分钟)由 backfill_source_changes 按任务号补写:2 天内
测到、还开着的要处理级变化,任务已结束却没有事件的补一条。分组在 Python 里做,不用各库写法不同的 JSON 取值。

C4 按期次分窗口:triggers.window_key 让上传表格按期次各自成窗口,9 月、10 月连着启用各有各的触发;同一期的重传照旧合并。
两个窗口结束在同一刻时后一个挪 1 微秒,fire_at 不撞唯一约束。

C5 应用补算:workflow.finished 缺省不认 app_backfill 发起的(升版补算连发好几条、各是某一期的历史输入),trigger 加
include_backfill(界面「也认应用补算的」),选了「只认应用补算」时本来就认;说法写「途经驳回、应用补算的不算」。

C6 示例出口字段:triggers.outputs_of 一处过滤「_」开头的键,真派发、预览、立即运行共用;示例事件也按 matches 跳过缺省不认的
途经驳回、应用补算,取合条件的最近一次。

测试:新加 test_schedule_events_review.py(11 项,先写、先看着失败;去掉 matches 的驳回判断、把每拍窗口数改回 1 回跑,对应用例
各自失败),test_schedule_events_api.py 加一项「也认」两项经接口存取和说法;check-schedules 加「上游工作流的请求体」一段、纯函数
几条、触发历史里合并跳过那一行的夹具。
验证:后端全量 14479 通过;tsc、check-copy;check-schedules 447 项、check-runs 327 项通过(拿锁、--stacks 1)。
2026-10-18 18:09:20 +08:00

2538 lines
190 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 定时任务(/schedules、/schedules/new、/schedules/:id,记录页里运行名旁的「定时」标记)的回归检查。
//
// 守的是这些「看着没坏、其实不能用」的地方:预设生成的 cron 不对(选了每周一 09:00,交上去的是每天)、时区按本机写
// 而不是按定时任务的时区、预览不防抖、非法 cron 只弹一句不知道指哪一项、启用开关不带版本号(别人改过也照样覆盖)、
// 立即运行之后没有下文、触发历史只有一页、保存过的 Webhook 地址又被原样显示出来、改了别的字段却把地址清掉、
// 记录页看不出哪些运行来自定时任务、390px 窄屏被表单撑出横向滚动。运行成功结束后的自动对比:触发历史里「与上一次
// 对比」(和运行详情「更多」菜单同一个叫法)链到对比页,旁边写有几个指标有变化,悬停只写各类指标的个数,基准不是
// 紧挨着的上一次时链接上看得见的字写「与上一次成功的运行对比」、悬停写明是哪一次;两种都带「默认基准:同一定时任务
// 上一次成功的运行」的来历,和运行详情「更多」里的同名入口打开同一对运行(服务端用同一个函数挑基准)。运行详情页头的
// 「由定时任务「X」发起」是键盘到得了的真链接(记录页的「定时」标记在整行按钮里,只能鼠标点),带日历图标,窄窗口里
// (768、820、900 宽,探索运行多一个「提取为草稿」)不被挤没,定时任务删掉之后写成纯文本。
// 还有几处时序和口径:晚到的刷新把刚切换的开关盖回去、先开触发历史再开设置时带着旧版本撞假的 409、保存途中又改了
// 别的字段时替换过的地址明文留在框里、服务端会拒的组合(未发布 + 正式运行、必填没填、已发布版本里没有的字段)
// 照样放行、测试消息的结果把服务端的整句又套一层、错过已跳过的那条悬停另说一个原因(和本条记的原因对不上)。
//
// 数据事件触发(docs/design/orchestration-round/00-总方案.md §4):新建时选「定时 / 事件触发」,事件触发的没有频率、换成事件那一节
// (事件种类、对象、期次 / 应用 / 来源、去抖、时区),不能选的对象照服务端给的原因标出来、选不了;换事件种类时对象清掉;变量
// 按事件种类取({{ event.* }});预览按一次事件渲染输入、不列触发时间;请求体带 trigger_kind、trigger、cron 交空串。列表、
// 详情写触发说明和在等的事件(调度没开时照实说不会派发),修改时只读地写出触发方式、只交改了的触发条件;触发历史写「由事件
// 触发:…」和合并了几次、第几层、立即运行的示例;运行详情页头写「由事件触发:…(定时任务「X」)」。
//
// 浏览器的本机时区故意设成 America/Los_Angeles,和定时任务的时区(Asia/Shanghai、America/New_York)都不同:
// 两者相同时「按定时任务时区写」和「按本机写」在页面上看不出区别,时间相关的断言一律比对按定时任务时区写出的原文。
//
// 页面是真页面(/schedules 走真的应用外壳),接口全部在浏览器层答掉:定时任务、触发历史、预览、变量、工作流列表
// 都是这里的假数据(虚构的门店周报、库存日报),写请求照约定改这里的假状态;设置接口读真的、只把 notify 一组换成
// 假的,写请求不放行。别的 GET 放行到服务端(外壳要它),别的写请求一律拦成 503,不碰真实写接口。
// 另有一节纯函数(pages/schedules/model.ts:预设 ⇄ cron、按时区写时间、表单 ⇄ 请求体),借 vite 转译后直接调用。
//
// node scripts/check-schedules.mjs
// 对别的实例跑时带上地址:AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api …
// SCHEDULES_SHOTS=<目录> 时亮暗两套各截几张,供人眼复核;CHECK_ONLY=列表,新建 只跑段名里含这些字的段。
import { mkdirSync } from 'node:fs'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const SHOTS = process.env.SCHEDULES_SHOTS ?? ''
if (SHOTS) mkdirSync(SHOTS, { recursive: true })
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const ONLY = (process.env.CHECK_ONLY ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
// ---------------------------------------------------------------- 纯函数:借 vite 转译(同 check-trace)
const loaded = new Map()
async function load(path) {
if (loaded.has(path)) return loaded.get(path)
const res = await fetch(`${WEB}${path}?t=${Date.now()}`)
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`)
let code = await res.text()
const deps = new Set([...code.matchAll(/from\s+["'](\/src\/[^"'?]+)(?:\?[^"']*)?["']/g)].map((m) => m[1]))
for (const dep of deps) {
const url = await load(dep)
const pattern = new RegExp(`(["'])${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\?[^"']*)?\\1`, 'g')
code = code.replace(pattern, JSON.stringify(url))
}
const url = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64')
loaded.set(path, url)
return url
}
await section('纯函数:预设 ⇄ cron', async () => {
const m = await import(await load('/src/pages/schedules/model.ts'))
const f = (patch) => ({ ...m.DEFAULT_FREQ, ...patch })
const cases = [
['每天 09:30', f({ preset: 'daily', time: '09:30' }), '30 9 * * *'],
['每周一 09:00', f({ preset: 'weekly', time: '09:00', weekdays: [1] }), '0 9 * * 1'],
['工作日连成区间', f({ preset: 'weekly', time: '09:00', weekdays: [5, 1, 3, 2, 4] }), '0 9 * * 1-5'],
['周末逐个列出', f({ preset: 'weekly', time: '09:00', weekdays: [6, 0] }), '0 9 * * 0,6'],
['不连着的逐个列出', f({ preset: 'weekly', time: '18:15', weekdays: [1, 2, 4] }), '15 18 * * 1,2,4'],
['一周七天写 *', f({ preset: 'weekly', time: '09:00', weekdays: [0, 1, 2, 3, 4, 5, 6] }), '0 9 * * *'],
['每月 15 日 08:05', f({ preset: 'monthly', time: '08:05', monthday: 15 }), '5 8 15 * *'],
['每隔 6 小时在第 15 分', f({ preset: 'hourly', everyHours: 6, minute: 15 }), '15 */6 * * *'],
['每隔 1 小时写 *', f({ preset: 'hourly', everyHours: 1, minute: 0 }), '0 * * * *'],
['自定义去掉多余空白', f({ preset: 'custom', cron: ' 0 9 * * 1 ' }), '0 9 * * 1'],
['钟点没填不猜', f({ preset: 'daily', time: '' }), ''],
['每周一天都没选不猜', f({ preset: 'weekly', weekdays: [] }), ''],
['每隔 5 小时不是 24 的约数', f({ preset: 'hourly', everyHours: 5, minute: 0 }), ''],
]
const bad = cases.filter(([, freq, want]) => m.cronOf(freq) !== want)
check(`cronOf:${cases.length} 种写法`, !bad.length, bad.map(([n, freq, want]) => `${n}:${m.cronOf(freq)} ≠ ${want}`).join(';'))
const back = [
['0 9 * * 1', { preset: 'weekly', time: '09:00', weekdays: [1] }],
['0 9 * * 1-5', { preset: 'weekly', weekdays: [1, 2, 3, 4, 5] }],
['0 9 * * 7', { preset: 'weekly', weekdays: [0] }],
['30 7 * * *', { preset: 'daily', time: '07:30' }],
['5 8 15 * *', { preset: 'monthly', time: '08:05', monthday: 15 }],
['15 */6 * * *', { preset: 'hourly', everyHours: 6, minute: 15 }],
['0 * * * *', { preset: 'hourly', everyHours: 1, minute: 0 }],
['0 9 1,15 * *', { preset: 'custom' }],
['0 9 * 1 *', { preset: 'custom' }],
['*/5 * * * *', { preset: 'custom' }],
['0 */5 * * *', { preset: 'custom' }],
['0 9 * * MON', { preset: 'custom', cron: '0 9 * * MON' }],
['0 9 * *', { preset: 'custom' }],
]
const wrong = back.filter(([cron, want]) => {
const got = m.freqOf(cron)
return Object.entries(want).some(([k, v]) => JSON.stringify(got[k]) !== JSON.stringify(v))
})
check(`freqOf:${back.length} 条 cron 落回预设,认不出的算自定义、原文照放`, !wrong.length,
wrong.map(([cron]) => `${cron} → ${JSON.stringify(m.freqOf(cron))}`).join(';'))
const round = ['0 9 * * 1', '0 9 * * 1-5', '0 9 * * 0,6', '30 7 * * *', '5 8 15 * *', '15 */6 * * *', '0 9 1,15 * *']
.filter((c) => m.cronOf(m.freqOf(c)) !== c)
check('认得出的写法来回一趟不变', !round.length, round.join(';'))
})
await section('纯函数:按定时任务的时区写时间', async () => {
const m = await import(await load('/src/pages/schedules/model.ts'))
const d = new Date('2026-10-05T01:00:00Z')
const now = new Date('2026-10-01T00:00:00Z')
check('Asia/Shanghai:2026-10-05 周一 09:00', m.formatZoned(d, 'Asia/Shanghai', 'full') === '2026-10-05 周一 09:00',
m.formatZoned(d, 'Asia/Shanghai', 'full'))
check('America/New_York:同一时刻是前一天 21:00', m.formatZoned(d, 'America/New_York', 'full') === '2026-10-04 周日 21:00',
m.formatZoned(d, 'America/New_York', 'full'))
check('同一年的短写法省掉年份', m.formatZoned(d, 'Asia/Shanghai', 'short', now) === '10/5 周一 09:00',
m.formatZoned(d, 'Asia/Shanghai', 'short', now))
check('跨年的短写法带年份', m.formatZoned(new Date('2027-01-04T01:00:00Z'), 'Asia/Shanghai', 'short', now) === '2027/1/4 周一 09:00')
check('偏移:北京 UTC+08:00、纽约十月 UTC-04:00、一月 UTC-05:00、UTC',
m.offsetLabel('Asia/Shanghai', d) === 'UTC+08:00' && m.offsetLabel('America/New_York', d) === 'UTC-04:00'
&& m.offsetLabel('America/New_York', new Date('2027-01-04T01:00:00Z')) === 'UTC-05:00' && m.offsetLabel('UTC', d) === 'UTC',
[m.offsetLabel('Asia/Shanghai', d), m.offsetLabel('America/New_York', d), m.offsetLabel('UTC', d)].join(' '))
check('浏览器不认的时区名不抛错', m.zonedParts(d, 'Mars/Base') === null && typeof m.formatZoned(d, 'Mars/Base') === 'string')
const t = d.getTime()
const until = [
[t - 3 * 3600e3 - 5 * 60e3, '3 小时后'], [t - 59.6 * 60e3, '1 小时后'], [t - 30e3, '不到 1 分钟后'],
[t - 12 * 60e3, '12 分钟后'], [t - 23.6 * 3600e3, '1 天后'], [t - 50 * 3600e3, '2 天后'],
].filter(([nowMs, want]) => m.formatUntil(d, nowMs) !== want)
check('距下次运行:先取整再判档', !until.length, until.map(([n, w]) => `${m.formatUntil(d, n)} ≠ ${w}`).join(';'))
check('已经过了的不写「…后」', m.formatUntil(d, t + 1000) === null)
const opts = m.timezoneOptions('Europe/Berlin', 'Asia/Shanghai', d)
check('时区下拉:本机排第一、正在用的紧跟、不重复,写城市和偏移',
opts[0].tz === 'Asia/Shanghai' && opts[0].label.includes('本机') && opts[0].label.includes('UTC+08:00')
&& opts[1].tz === 'Europe/Berlin' && new Set(opts.map((o) => o.tz)).size === opts.length,
opts.slice(0, 2).map((o) => o.label).join(' | '))
const odd = m.timezoneOptions('Antarctica/Troll', 'Asia/Shanghai', d)
check('不在常用表里的时区也列上', odd.some((o) => o.tz === 'Antarctica/Troll'))
})
await section('纯函数:表单 ⇄ 请求体', async () => {
const m = await import(await load('/src/pages/schedules/model.ts'))
const saved = {
id: 's1', name: '门店周报', workflow_id: 'wf-weekly', workflow_name: '门店周报', cron: '0 9 * * 1', timezone: 'Asia/Shanghai',
run_class: 'formal', input_template: { date: '{{ schedule.yesterday }}' }, enabled: true, overlap: 'skip', misfire_grace_s: 3600,
webhook: { format: 'feishu', url_hint: 'open.feishu.cn/…ab12', has_secret: true, events: ['failed', 'finished'] },
next_run_at: null, last_fire: null, description_text: '每周一 09:00(Asia/Shanghai)', created_by: null,
created_at: '2026-10-01T00:00:00Z', updated_at: '2026-10-01T00:00:00Z', version: 3,
}
const base = m.formOf(saved)
check('读出来的表单:每周一 09:00、Webhook 开着、地址不回填', base.freq.preset === 'weekly' && base.freq.time === '09:00'
&& base.webhook.on && base.webhook.url === '' && !base.webhook.replaceUrl, JSON.stringify(base.freq))
check('什么都没改:不发请求', m.patchBody(base, base, saved) === null)
const reorder = { ...base, webhook: { ...base.webhook, events: ['finished', 'failed'] } }
check('事件只是换了勾选顺序:不算改动', m.patchBody(reorder, base, saved) === null)
const renamed = m.patchBody({ ...base, name: ' 门店周报(新)' }, base, saved)
check('只改名字:只交名字,不带 Webhook(地址省略即不改)', JSON.stringify(renamed) === JSON.stringify({ name: '门店周报(新)' }),
JSON.stringify(renamed))
const events = m.patchBody({ ...base, webhook: { ...base.webhook, events: ['finished'] } }, base, saved)
check('只改事件:Webhook 带格式和事件,不带地址和密钥', JSON.stringify(events) === JSON.stringify({ webhook: { format: 'feishu', events: ['finished'] } }),
JSON.stringify(events))
const replaced = m.patchBody({ ...base, webhook: { ...base.webhook, replaceUrl: true, url: ' https://open.feishu.cn/hook/new9 ' } }, base, saved)
check('替换地址:带新地址', replaced?.webhook?.url === 'https://open.feishu.cn/hook/new9' && !('secret' in replaced.webhook), JSON.stringify(replaced))
const removed = m.patchBody({ ...base, webhook: { ...base.webhook, on: false } }, base, saved)
check('删除 Webhook:地址交空串', removed?.webhook?.url === '' && removed.webhook.format === 'feishu', JSON.stringify(removed))
const cleared = m.patchBody({ ...base, webhook: { ...base.webhook, secretMode: 'clear' } }, base, saved)
check('清除密钥:密钥交空串、地址省略', cleared?.webhook?.secret === '' && !('url' in cleared.webhook), JSON.stringify(cleared))
const freq = m.patchBody({ ...base, freq: { ...base.freq, weekdays: [1, 3] } }, base, saved)
check('改频率:交新的 cron', freq?.cron === '0 9 * * 1,3' && Object.keys(freq).length === 1, JSON.stringify(freq))
const odd = { ...saved, cron: '0 9 * * 0-6' }
const oddForm = m.formOf(odd)
check('cron 原文写法不同(0-6)但人没动它:不算改了频率', m.patchBody({ ...oddForm, name: 'x' }, oddForm, odd)?.cron === undefined)
const blanks = m.patchBody({ ...base, inputs: { date: '{{ schedule.yesterday }}', region: ' ' } }, base, saved)
check('只填了空白的输入不算填了', blanks === null, JSON.stringify(blanks))
const fresh = { ...m.formOf(null, 'Asia/Shanghai'), name: ' 库存日报 ', workflowId: 'wf-stock',
freq: { ...m.DEFAULT_FREQ, preset: 'weekly', weekdays: [1], time: '09:00' }, inputs: { date: '{{ schedule.yesterday }}', region: '' } }
const created = m.createBody({ ...fresh, webhook: { ...fresh.webhook, on: true, format: 'wecom', url: 'https://qyapi.weixin.qq.com/x?key=1', events: ['failed', 'finished'] } })
check('新建的请求体:cron、时区、去掉没填的输入、没填密钥就不带', created.cron === '0 9 * * 1' && created.timezone === 'Asia/Shanghai'
&& created.name === '库存日报' && JSON.stringify(created.input_template) === JSON.stringify({ date: '{{ schedule.yesterday }}' })
&& created.webhook && !('secret' in created.webhook) && JSON.stringify(created.webhook.events) === '["finished","failed"]'
&& created.overlap === 'skip' && created.misfire_grace_s === 3600 && created.enabled === true, JSON.stringify(created))
check('Webhook 关着:交 null', m.createBody(fresh).webhook === null)
const probs = m.formProblems({ ...m.formOf(null, 'Asia/Shanghai'), webhook: { ...fresh.webhook, on: true, url: 'ftp://x', events: [] } }, null)
check('问题:缺名称、缺工作流、地址不是 http(s)、一个事件都没选', !!probs.name && !!probs.workflow && !!probs.url && !!probs.events
&& !probs.freq, JSON.stringify(probs))
const keep = m.formProblems({ ...base }, saved)
check('已保存过地址、没点替换:不要求重填地址', !keep.url, JSON.stringify(keep))
check('频率不完整各有说法', m.freqProblem({ ...m.DEFAULT_FREQ, preset: 'weekly', weekdays: [] }) === '至少选择一天'
&& m.freqProblem({ ...m.DEFAULT_FREQ, preset: 'custom', cron: '0 9 * *' }) === 'cron 表达式应为五段,以空格分隔'
&& m.freqProblem({ ...m.DEFAULT_FREQ, preset: 'daily', time: '' }) === '请填写时间')
})
await section('纯函数:非工作流目标(评测题集)的表单 ⇄ 请求体', async () => {
const m = await import(await load('/src/pages/schedules/model.ts'))
check('新建时能选的只有工作流和评测题集(数据源维护在数据源页建)', JSON.stringify(m.CREATABLE_TARGETS) === '["workflow","eval_suite"]')
const fresh = m.targetFormOf(null, { targetKind: 'eval_suite', targetId: 'suite-retail' }, 'Asia/Shanghai')
check('带着题集过来:目标种类、题集都填上', fresh.targetKind === 'eval_suite' && fresh.targetId === 'suite-retail' && fresh.enabled)
const body = m.targetCreateBody({ ...fresh, name: ' 每周回归 ', freq: { ...m.DEFAULT_FREQ, preset: 'weekly', weekdays: [1], time: '09:00' } })
check('建题集目标的请求体:目标种类和 id、cron、时区,不带工作流、输入模板、运行类别和 Webhook',
JSON.stringify(Object.keys(body).sort()) === JSON.stringify(['cron', 'enabled', 'misfire_grace_s', 'name', 'overlap', 'target_id', 'target_kind', 'timezone'])
&& body.name === '每周回归' && body.cron === '0 9 * * 1' && body.target_kind === 'eval_suite', JSON.stringify(body))
const saved = { id: 's-eval', name: '每周回归', target_kind: 'eval_suite', target_id: 'suite-retail', cron: '0 9 * * 1', timezone: 'Asia/Shanghai',
enabled: true, misfire_grace_s: 3600 }
const base = m.targetFormOf(saved)
check('什么都没改:不发请求', m.targetPatchBody(base, base) === null)
const patch = m.targetPatchBody({ ...base, name: '每天回归', freq: { ...base.freq, preset: 'daily' }, misfireGraceS: 300 }, base)
check('改名称、频率、补发宽限:只交这三项(目标、启用不在表单里改)', JSON.stringify(patch) === JSON.stringify({ name: '每天回归', cron: '0 9 * * *', misfire_grace_s: 300 }),
JSON.stringify(patch))
const probs = m.targetProblems({ ...m.targetFormOf(null), freq: { ...m.DEFAULT_FREQ, preset: 'weekly', weekdays: [] } })
check('问题:缺名称、缺题集、频率不完整', probs.name === '请填写名称' && probs.target === '请选择要定时的评测题集' && probs.freq === '至少选择一天',
JSON.stringify(probs))
})
await section('纯函数:数据源维护链到哪里、建了什么怎么写', async () => {
const m = await import(await load('/src/pages/schedules/model.ts'))
check('数据源卡片:数据源页认的 #source-<id>(id 编码)', m.sourceCardPath('ds-shop') === '/data/databases#source-ds-shop'
&& m.sourceCardPath('ds 1/2') === '/data/databases#source-ds%201%2F2')
check('剖析任务:目录页认的 ?pjob=', m.profileJobPath('ds-shop', 'pj-1') === '/data/catalog/ds-shop?pjob=pj-1')
check('探查任务链到卡片、剖析任务链到目录', m.maintenanceJobPath('ds-shop', { kind: 'introspect', id: 'ij-1' }) === '/data/databases#source-ds-shop'
&& m.maintenanceJobPath('ds-shop', { kind: 'profile', id: 'pj-1' }) === '/data/catalog/ds-shop?pjob=pj-1')
check('步骤照服务端的叫法写,合成的一个任务写成「a、b」,认不出的照原样',
m.maintenanceStepText('introspect') === '探查结构' && m.maintenanceStepText('profile_needed+observe') === '剖析需要的表、取值观察'
&& m.maintenanceStepText('later_step') === 'later_step')
const sum = m.maintenanceSummary({ jobs: [{ step: 'introspect', kind: 'introspect', id: 'ij1a2b3c4d', tables: 1200 }],
skipped: [{ step: 'observe', reason: '没有开启数据剖析' }] })
check('立即运行的详情:一个任务一行、没执行的一行', sum === '探查结构:探查任务 #ij1a2b(1,200 张表)\n取值观察没有执行:没有开启数据剖析', sum)
check('什么都没有:空串', m.maintenanceSummary(null) === '' && m.maintenanceSummary({}) === '')
})
await section('纯函数:服务端会拒的组合、保存回来怎么接、测试结果', async () => {
const m = await import(await load('/src/pages/schedules/model.ts'))
const fields = [{ name: 'date', required: true }, { name: 'region', required: true, default: '华东' }, { name: 'note' },
{ name: 'blank_default', required: true, default: '' }]
const ip = (inputs, fs = fields) => JSON.stringify(m.inputProblems(inputs, fs))
const cases = [
['都填了', { date: '{{ schedule.yesterday }}', blank_default: 'x' }, { unknown: [], missing: [] }],
['必填没填(默认值为空串也算没有默认值)', {}, { unknown: [], missing: ['date', 'blank_default'] }],
['只填了空白算没填', { date: ' ', blank_default: 'x' }, { unknown: [], missing: ['date'] }],
['多出来的字段', { date: 'a', blank_default: 'x', draft_only: 'y' }, { unknown: ['draft_only'], missing: [] }],
['多出来但留空的不交、不算', { date: 'a', blank_default: 'x', draft_only: '' }, { unknown: [], missing: [] }],
]
const bad = cases.filter(([, inputs, want]) => ip(inputs) !== JSON.stringify(want))
check(`inputProblems:${cases.length} 种情况`, !bad.length, bad.map(([n, inputs]) => `${n}:${ip(inputs)}`).join(';'))
check('入口没声明字段:什么键都收,不报', ip({ anything: 'x' }, []) === JSON.stringify({ unknown: [], missing: [] }))
const fresh = { ...m.formOf(null, 'Asia/Shanghai'), name: '库存日报', workflowId: 'wf-stock', runClass: 'formal' }
const p1 = m.formProblems(fresh, null, { published: null, fields: [{ name: 'warehouse', required: true }] })
check('新建:正式运行 + 没发布、必填没填,两样都拦', !!p1.runClass && !!p1.inputs, JSON.stringify(p1))
check('……工作流还没取回来(发布状态不知道)时不判', !m.formProblems(fresh, null, {}).runClass)
check('……探索运行不要求发布', !m.formProblems({ ...fresh, runClass: 'exploratory' }, null, { published: null }).runClass)
const saved = {
id: 's1', name: '门店周报', workflow_id: 'wf-weekly', workflow_name: '门店周报', cron: '0 9 * * 1', timezone: 'Asia/Shanghai',
run_class: 'formal', input_template: {}, enabled: true, overlap: 'skip', misfire_grace_s: 3600, webhook: null,
next_run_at: null, last_fire: null, description_text: '', created_by: null, created_at: '', updated_at: '', version: 3,
}
const edit = m.formOf(saved)
const ctx = { published: null, fields: [{ name: 'date', required: true }] }
check('修改:没动工作流、运行类别、输入时服务端不重新核对,前端也不拦(工作流后来撤了发布也能改名、停用)',
Object.keys(m.formProblems({ ...edit, name: '新名字' }, saved, { ...ctx, recheck: false })).length === 0)
check('……动了这三样就照新建一样拦', !!m.formProblems(edit, saved, { ...ctx, recheck: true }).runClass)
const sv = m.serverFieldValue
check('服务端指的那一项:改了它值就变,改别的不变', sv('name', { ...edit, name: 'X' }) !== sv('name', { ...edit, name: 'XY' })
&& sv('name', edit) === sv('name', { ...edit, misfireGraceS: 60 })
&& sv('run_class', edit) !== sv('run_class', { ...edit, runClass: 'exploratory' })
&& sv('misfire_grace_s', edit) !== sv('misfire_grace_s', { ...edit, misfireGraceS: 60 })
&& sv('not_a_field', edit) === null)
const wbase = { ...edit, webhook: { ...edit.webhook, on: true, format: 'feishu' } }
const sent = { ...wbase, webhook: { ...wbase.webhook, replaceUrl: true, url: 'https://open.feishu.cn/hook/secret-token-zz99' } }
const back = { ...wbase, webhook: { ...wbase.webhook, replaceUrl: false, url: '' } }
check('保存途中没再动:换成刚存下的', JSON.stringify(m.rebaseForm(sent, sent, back)) === JSON.stringify(back))
const during = m.rebaseForm({ ...sent, name: '途中改名' }, sent, back)
check('保存途中改了名字:名字留着,地址换成刚存下的(不再留明文)', during.name === '途中改名' && during.webhook.url === ''
&& during.webhook.replaceUrl === false, JSON.stringify(during.webhook))
const evs = m.rebaseForm({ ...sent, webhook: { ...sent.webhook, events: ['failed'] } }, sent, back)
check('……途中改了 Webhook 的事件:事件留着,地址照样换掉', JSON.stringify(evs.webhook.events) === '["failed"]' && evs.webhook.url === '')
const t = m.webhookTestText
const tests = [
[{ ok: true, http_status: 200, message: '请到接收端确认已收到' }, '测试消息已送达(HTTP 200):请到接收端确认已收到'],
[{ ok: true, http_status: 200, message: '测试消息已送达(HTTP 200)' }, '测试消息已送达(HTTP 200)'],
[{ ok: true, http_status: 204, message: '' }, '测试消息已送达(HTTP 204)'],
[{ ok: false, http_status: 404, message: 'open.feishu.cn 返回 HTTP 404:not found' }, '测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found'],
[{ ok: false, http_status: 404, message: '推送失败:open.feishu.cn 返回 HTTP 404:not found' }, '测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found'],
[{ ok: false, http_status: null, message: '无法连接 open.feishu.cn' }, '测试消息发送失败:无法连接 open.feishu.cn'],
[{ ok: false, http_status: 200, message: 'open.feishu.cn 返回错误码 19002:bad sign' }, '测试消息发送失败(HTTP 200):open.feishu.cn 返回错误码 19002:bad sign'],
]
const wrong = tests.filter(([r, want]) => t(r) !== want)
check(`测试结果:服务端给整句、给补充说明都只说一遍(${tests.length} 种)`, !wrong.length, wrong.map(([r]) => t(r)).join(';'))
})
await section('纯函数:事件触发的表单 ⇄ 请求体', async () => {
const m = await import(await load('/src/pages/schedules/model.ts'))
const fresh = m.formOf(null, 'Asia/Shanghai')
check('新建缺省按 cron 触发,事件那一份缺省是上传表格、去抖 1 分钟', fresh.triggerKind === 'cron'
&& fresh.trigger.event === 'table.version' && fresh.trigger.debounceS === 60 && fresh.trigger.subjectId === '')
check('cron 任务的请求体一字不差:不带触发方式、触发条件', !('trigger_kind' in m.createBody({ ...fresh, name: 'x', workflowId: 'wf' }))
&& !('trigger' in m.createBody({ ...fresh, name: 'x', workflowId: 'wf' })))
const ev = { ...fresh, name: ' 门店月报 ', workflowId: 'wf-weekly', triggerKind: 'event',
trigger: { ...fresh.trigger, subjectId: 'ds-sales', period: 'new', appId: 'app-x', originKind: 'hook', debounceS: 300 },
inputs: { date: '{{ event.period }}' } }
const body = m.createBody(ev)
check('事件触发的请求体:trigger_kind、对象按种类写 source_id、只带这种事件的过滤、cron 交空串',
body.trigger_kind === 'event' && JSON.stringify(body.trigger) === JSON.stringify({ event: 'table.version', debounce_s: 300,
source_id: 'ds-sales', period: 'new' }) && body.cron === '' && body.name === '门店月报'
&& JSON.stringify(body.input_template) === '{"date":"{{ event.period }}"}', JSON.stringify(body))
const wfForm = { event: 'workflow.finished', subjectId: 'wf-daily', period: 'new', appId: 'app-board', originKind: '',
includeRejected: false, includeBackfill: false, debounceS: 0 }
const wf = m.triggerBody(wfForm)
check('上游工作流:写 workflow_id,带应用、不带期次和空的来源,缺省不带「也认」两项', JSON.stringify(wf) === JSON.stringify({
event: 'workflow.finished', debounce_s: 0, workflow_id: 'wf-daily', app_id: 'app-board' }), JSON.stringify(wf))
const wide = m.triggerBody({ ...wfForm, includeRejected: true, includeBackfill: true })
check('勾了也认途经驳回的、应用补算的:各交 true', wide.include_rejected === true && wide.include_backfill === true, JSON.stringify(wide))
const onlyBackfill = m.triggerBody({ ...wfForm, originKind: 'app_backfill', includeBackfill: true })
check('只认应用补算的:不再另交「也认应用补算的」', onlyBackfill.origin_kind === 'app_backfill' && !('include_backfill' in onlyBackfill),
JSON.stringify(onlyBackfill))
check('表格的触发条件不带这两项', !('include_rejected' in m.triggerBody({ ...wfForm, event: 'table.version', includeRejected: true })))
const switched = m.switchEvent({ ...ev.trigger, includeRejected: true }, 'source.changed')
check('换事件种类:对象、过滤清掉,去抖留着', switched.subjectId === '' && switched.period === 'any' && switched.appId === ''
&& switched.originKind === '' && switched.includeRejected === false && switched.debounceS === 300 && m.switchEvent(ev.trigger, 'table.version') === ev.trigger)
check('问题:事件触发没选对象时说要选什么,不查频率', m.formProblems({ ...ev, trigger: switched, freq: { ...m.DEFAULT_FREQ, preset: 'weekly',
weekdays: [] } }, null).trigger === '请选择数据源' && !m.formProblems({ ...ev, freq: { ...m.DEFAULT_FREQ, preset: 'weekly', weekdays: [] } },
null).freq)
// 夹具里的定时任务在后面才定义(这一段在顶层先跑),这里另写一份
const saved = { id: 's-event', name: '门店月报', workflow_id: 'wf-weekly', workflow_name: '门店周报', cron: '', timezone: 'Asia/Shanghai',
run_class: 'formal', input_template: { date: '{{ event.period }}' }, enabled: true, overlap: 'skip', misfire_grace_s: 3600,
webhook: null, next_run_at: null, last_fire: null, description_text: '', created_by: null, created_at: '', updated_at: '', version: 2,
trigger_kind: 'event', trigger: { event: 'table.version', source_id: 'ds-sales', period: 'new', debounce_s: 300, subject_name: '门店销售' } }
const base = m.formOf(saved)
check('读出来的表单:触发方式、对象、期次、去抖', base.triggerKind === 'event' && base.trigger.subjectId === 'ds-sales'
&& base.trigger.period === 'new' && base.trigger.debounceS === 300)
check('什么都没改:不发请求(不因为 cron 是空串算成改了频率)', m.patchBody(base, base, saved) === null)
const patch = m.patchBody({ ...base, trigger: { ...base.trigger, debounceS: 900 } }, base, saved)
check('只改去抖:只交触发条件(整份)', JSON.stringify(patch) === JSON.stringify({ trigger: { event: 'table.version', debounce_s: 900,
source_id: 'ds-sales', period: 'new' } }), JSON.stringify(patch))
check('服务端指 trigger 时:换了对象或目标就不再摆着那句', m.serverFieldValue('trigger', base)
!== m.serverFieldValue('trigger', { ...base, trigger: { ...base.trigger, subjectId: 'ds-other' } })
&& m.serverFieldValue('trigger', base) !== m.serverFieldValue('trigger', { ...base, workflowId: 'wf-stock' })
&& m.serverFieldValue('trigger', base) === m.serverFieldValue('trigger', { ...base, name: '别的名字' }))
})
/** 对比页写的默认基准来历(定时任务发起的):只挑比本次先发起、成功结束并封存的运行,服务端 api/compare.py 的 pick_base */
const REASON_SCHEDULE = '默认基准:同一定时任务上一次成功的运行'
await section('纯函数:触发历史对比的文案', async () => {
const { SCHEDULE_TEXT: T, RUN_COMPARE_TEXT: C } = await import(await load('/src/lib/terms.ts'))
check('「与上一次对比」和运行详情「更多」菜单同一个叫法', T.compareLink === C.menu && T.compareLink === '与上一次对比', T.compareLink)
check('基准不是紧挨着的上一次时的叫法:与上一次成功的运行对比', T.compareLinkLastOk === '与上一次成功的运行对比', T.compareLinkLastOk)
check('默认基准的来历照服务端的挑法写「上一次成功的运行」', C.reason.same_schedule === REASON_SCHEDULE
&& C.reason.same_workflow === '默认基准:同一工作流、同一运行类别上一次成功的运行', JSON.stringify(C.reason))
const zero = { unchanged: 0, data_only: 0, caliber_and_data: 0, sql_changed: 0, params_only: 0, added: 0, removed: 0, incomparable: 0 }
const cases = [
['有变化的几类合起来', { ...zero, data_only: 3, caliber_and_data: 1, params_only: 2, unchanged: 4 }, '6 个指标有变化'],
['新增、移除也算变化', { ...zero, added: 1, removed: 2 }, '3 个指标有变化'],
['没有变化、有无法比较的', { ...zero, incomparable: 2, unchanged: 3 }, '2 个指标无法比较'],
['都未变', { ...zero, unchanged: 5 }, '指标都未变'],
['两边都没有指标:不写', zero, null],
]
const bad = cases.filter(([, sum, want]) => T.compareCount(sum) !== want)
check(`看得见的计数:${cases.length} 种情况`, !bad.length, bad.map(([n, sum]) => `${n}:${T.compareCount(sum)}`).join(';'))
check('悬停:基准不是紧挨着的上一次时写明是哪一次', T.compareTitle({ ...zero, data_only: 1 }, '#run-f1')
=== '与上一次成功的运行(#run-f1)相比:只数据变 1 个、口径与数据都变 0 个、SQL 结构变 0 个'
&& T.compareTitle(zero, '#run-f1') === '与上一次成功的运行(#run-f1)相比:两次运行都没有指标')
})
// ---------------------------------------------------------------- 夹具:虚构的门店周报、库存日报
const NOW = Date.now()
const iso = (ms) => new Date(ms).toISOString()
const H = 3600e3
/** 下一个北京时间周一 09:00(UTC 01:00)之后再往后 k 周(k 为负数是往前) */
function nextMonday9(k = 0) {
const d = new Date(NOW)
d.setUTCHours(1, 0, 0, 0)
while (d.getUTCDay() !== 1 || d.getTime() <= NOW) d.setUTCDate(d.getUTCDate() + 1)
return d.getTime() + k * 7 * 24 * H
}
// 按时区写时刻:和页面同一套写法,这里独立实现一遍,用来比对页面上的原文
const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const wallFormats = new Map()
/** 某个时刻在某个时区里的年月日、时分、星期(0 是周日) */
function wall(ms, tz) {
if (!wallFormats.has(tz)) {
wallFormats.set(tz, new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: 'numeric', weekday: 'short', hourCycle: 'h23' }))
}
const p = Object.fromEntries(wallFormats.get(tz).formatToParts(new Date(ms)).map((x) => [x.type, x.value]))
return { y: +p.year, mo: +p.month, d: +p.day, h: +p.hour % 24, mi: +p.minute,
wd: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(p.weekday) }
}
const pad2 = (n) => String(n).padStart(2, '0')
/** full:「2026-10-12 周一 09:00」;short:同一年省掉年份「10/12 周一 09:00」,跨年写「2027/1/4 周一 09:00」 */
function zoned(ms, tz, mode = 'short') {
const w = wall(ms, tz)
const hm = `${pad2(w.h)}:${pad2(w.mi)}`
if (mode === 'full') return `${w.y}-${pad2(w.mo)}-${pad2(w.d)} ${WEEKDAY[w.wd]} ${hm}`
const md = `${w.mo}/${w.d}`
return `${w.y === wall(Date.now(), tz).y ? md : `${w.y}/${md}`} ${WEEKDAY[w.wd]} ${hm}`
}
/**
* 一个时区里从现在往后符合「分 时 * * 星期」的前 n 个时刻(按 15 分钟一格往后找,够用),照真服务端那样按所选时区算。
* 认不出的写法返回 null
*/
function occurrences(cron, tz, n) {
const [mi, ho, dom, mon, dow] = String(cron).trim().split(/\s+/)
const minute = Number(mi)
const hour = Number(ho)
if (!/^\d+$/.test(mi ?? '') || !/^\d+$/.test(ho ?? '') || dom !== '*' || mon !== '*') return null
const days = dow === '*' ? null : new Set(String(dow).split(',').map((d) => Number(d) % 7))
const out = []
for (let t = Math.ceil((NOW + 1) / 900e3) * 900e3, i = 0; out.length < n && i < 8 * 7 * 96; t += 900e3, i++) {
const w = wall(t, tz)
if (w.h === hour && w.mi === minute && (!days || days.has(w.wd))) out.push(t)
}
return out
}
const WORKFLOWS = [
{
id: 'wf-weekly', name: '门店周报', description: '各门店上周销售', tags: [], version: 4, is_template: false, status: 'published',
published_version: 3, run_count: 12, created_at: iso(NOW - 40 * 24 * H), updated_at: iso(NOW - 2 * 24 * H),
graph: { nodes: [{ id: 'in', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: {
fields: [{ name: 'date', required: true }, { name: 'region', default: '华东' }, { name: 'draft_only' }] } } }], edges: [] },
},
{
id: 'wf-stock', name: '库存日报', description: '各仓库存', tags: [], version: 2, is_template: false, status: 'draft',
published_version: null, run_count: 3, created_at: iso(NOW - 10 * 24 * H), updated_at: iso(NOW - 24 * H),
graph: { nodes: [{ id: 'in', type: 'input', position: { x: 0, y: 0 }, data: { label: '输入', config: {
fields: [{ name: 'warehouse', required: true }] } } }], edges: [] },
},
]
/** 正式运行的字段取已发布的 v3(比画布少一个 draft_only) */
const VERSION3 = {
id: 'v3', version: 3, note: '发布', workflow_id: 'wf-weekly', published: true, graph: WORKFLOWS[0].graph,
input_fields: [{ name: 'date', required: true, description: '统计日期' }, { name: 'region', default: '华东' }],
}
/** ISO 周的补充说明照服务端(scheduler/service.ISO_WEEK_NOTE):几个周变量同一句 */
const ISO_NOTE = 'ISO 周从周一开始,含当年 1 月 4 日的那一周是第 1 周;跨年那几天按所在的周算,例如 2027-01-01(周五)属于 2026-W53'
const VARIABLES = [
{ name: 'schedule.yesterday', label: '昨天(YYYY-MM-DD)', example: '2026-10-04', note: '' },
{ name: 'schedule.today', label: '今天(YYYY-MM-DD)', example: '2026-10-05', note: '' },
{ name: 'schedule.this_week', label: '本周(ISO 周,YYYY-Www)', example: '2026-W41', note: ISO_NOTE },
{ name: 'schedule.last_week', label: '上周(ISO 周,YYYY-Www)', example: '2026-W40', note: ISO_NOTE },
{ name: 'schedule.last_week_start', label: '上周一(YYYY-MM-DD)', example: '2026-09-28', note: '' },
{ name: 'schedule.last_month', label: '上月(YYYY-MM)', example: '2026-09', note: '' },
]
const FEISHU_URL = 'https://open.feishu.cn/open-apis/bot/v2/hook/0f3c9a7e-demo-ab12'
const urlHint = (url) => {
const u = new URL(url)
return `${u.host}/…${url.slice(-4)}`
}
const fire = (id, patch = {}) => ({
id, fire_at: iso(NOW - 24 * H), kind: 'scheduled', status: 'started', run_id: `run-${id}`, run_status: 'succeeded',
reason: null, notified: [], created_at: iso(NOW - 24 * H), ...patch,
})
const S1 = {
id: 's-weekly', name: '门店周报 · 每周', workflow_id: 'wf-weekly', workflow_name: '门店周报', cron: '0 9 * * 1',
timezone: 'Asia/Shanghai', run_class: 'formal', input_template: { date: '{{ schedule.yesterday }}' }, enabled: true,
overlap: 'skip', misfire_grace_s: 3600,
webhook: { format: 'feishu', url_hint: urlHint(FEISHU_URL), has_secret: true, events: ['finished', 'failed'] },
next_run_at: iso(NOW + 3 * H + 5 * 60e3),
last_fire: fire('f-last', { fire_at: iso(NOW - 6 * 24 * H), run_id: 'run-sched-1', notified: [{ event: 'finished', status: 'ok', at: iso(NOW - 6 * 24 * H) }] }),
description_text: '每周一 09:00(Asia/Shanghai)', created_by: '检查脚本', created_at: iso(NOW - 30 * 24 * H),
updated_at: iso(NOW - 24 * H), version: 3,
}
const S2 = {
id: 's-stock', name: '库存日报', workflow_id: 'wf-stock', workflow_name: '库存日报', cron: '30 7 * * *', timezone: 'America/New_York',
run_class: 'exploratory', input_template: {}, enabled: false, overlap: 'skip', misfire_grace_s: 300, webhook: null,
next_run_at: null,
last_fire: fire('f-skip', { status: 'skipped_overlap', run_id: null, run_status: null, reason: '上一次运行(#a1b2c3)尚未结束' }),
description_text: '每天 07:30(America/New_York)', created_by: null, created_at: iso(NOW - 9 * 24 * H), updated_at: iso(NOW - 9 * 24 * H),
version: 1,
}
/**
* 触发历史:25 条,新的在前,按 20 条一页给。计划时间都是北京时间周一 09:00(往前数第 i+1 周)。
* 几种类型、结局、通知结果各放一条。错过、没有补发的,服务端一律记成 kind=misfire、status=skipped_misfire,
* 原因却有两种,各放一条、原因照服务端的原话写:f03 是停机一周多、在宽限内恢复时积压的那一次(最近一次就是补发的
* f02),f05 是超出补发宽限的。f06、f07 没有记原因(约定里 reason 可以是 null),看兜底的说明
*/
/** 服务端自动对比给的计数(只有个数):f00 和同一任务的上一次(run-f02)比过 */
const COMPARE_F00 = { base_run_id: 'run-f02', previous: true, summary: { unchanged: 4, data_only: 3, caliber_and_data: 1,
sql_changed: 0, params_only: 2, added: 0, removed: 0, incomparable: 0 } }
const COMPARE_F00_TITLE = '与上一次相比:只数据变 3 个、口径与数据都变 1 个、SQL 结构变 0 个、只参数变 2 个、未变 4 个'
const NO_METRICS = { base_run_id: 'run-f09', previous: true, summary: { unchanged: 0, data_only: 0, caliber_and_data: 0,
sql_changed: 0, params_only: 0, added: 0, removed: 0, incomparable: 0 } }
/** f10 的上一次(f11)失败了:基准是再往前成功的 f12,不是紧挨着的上一次 */
const COMPARE_F10 = { base_run_id: 'run-f12', previous: false, summary: { unchanged: 1, data_only: 1, caliber_and_data: 0,
sql_changed: 0, params_only: 0, added: 0, removed: 0, incomparable: 0 } }
function makeFires() {
const out = []
const local = (at) => {
const w = wall(Date.parse(at), 'Asia/Shanghai')
return `${w.y}-${pad2(w.mo)}-${pad2(w.d)} ${pad2(w.h)}:${pad2(w.mi)}`
}
const missed = { kind: 'misfire', status: 'skipped_misfire', run_id: null, run_status: null }
for (let i = 0; i < 25; i++) {
const at = iso(nextMonday9(-(i + 1)))
let f = fire(`f${String(i).padStart(2, '0')}`, { fire_at: at, created_at: at })
if (i === 0) f = { ...f, kind: 'manual', compare: COMPARE_F00, notified: [{ event: 'finished', status: 'ok', at }, { event: 'failed', status: 'failed', at }] }
if (i === 1) f = { ...f, status: 'skipped_overlap', run_id: null, run_status: null, reason: '上一次运行(#9f8e7d)尚未结束' }
if (i === 2) f = { ...f, kind: 'misfire', run_status: 'failed', compare: null }
if (i === 3) f = { ...f, ...missed, reason: `服务未运行,错过了 ${local(at)} 的触发;积压的触发不补,只发起最近一次` }
if (i === 4) f = { ...f, status: 'failed_to_start', run_id: null, run_status: null, reason: '工作流「门店周报」还没有发布:正式运行需要已发布的版本' }
if (i === 5) f = { ...f, ...missed, reason: `服务未运行,错过了 ${local(at)} 的触发,超出补发宽限(1 小时),没有发起` }
if (i === 6) f = { ...f, ...missed, reason: null }
if (i === 7) f = { ...f, status: 'failed_to_start', run_id: null, run_status: null, reason: null }
if (i === 8) f = { ...f, compare: NO_METRICS }
if (i === 10) f = { ...f, compare: COMPARE_F10 }
if (i === 11) f = { ...f, run_status: 'failed', compare: null }
out.push(f)
}
return out
}
const RUNS = [
{ id: 'run-sched-1', workflow_id: 'wf-weekly', workflow_name: '门店周报', status: 'succeeded', input: { date: '2026-10-04' },
output: { 周报: '上周销售额 1,234 万元' }, error: null, usage: { duration_ms: 4200 }, run_class: 'formal', version: 3,
started_by: '定时任务:门店周报(旧名)',
schedule_id: 's-weekly', created_at: iso(NOW - 2 * H), started_at: iso(NOW - 2 * H), finished_at: iso(NOW - 2 * H + 4200) },
{ id: 'run-manual-1', workflow_id: 'wf-weekly', workflow_name: '门店周报', status: 'succeeded', input: { date: '2026-10-03' },
output: { 周报: '手动补一份' }, error: null, usage: { duration_ms: 3900 }, run_class: 'exploratory', version: null,
schedule_id: null, created_at: iso(NOW - 3 * H), started_at: iso(NOW - 3 * H), finished_at: iso(NOW - 3 * H + 3900) },
]
/** 有人署名点了「立即运行」:署名照写,页头另接到定时任务的链接 */
const RUN_MANUAL_FIRE = { ...RUNS[0], id: 'run-sched-2', started_by: '检查员甲', created_at: iso(NOW - H), started_at: iso(NOW - H),
finished_at: iso(NOW - H + 4200) }
/** 探索运行成功了,页头多一个「提取为草稿」,这一行更挤:到点发起的、署名点了立即运行的各一次 */
const RUN_EXPLORE = { ...RUNS[0], id: 'run-sched-3', run_class: 'exploratory', version: null, started_by: '定时任务:门店周报 · 每周' }
const RUN_EXPLORE_MANUAL = { ...RUN_EXPLORE, id: 'run-sched-4', started_by: '检查员甲' }
/** 运行详情要的图和事件:一个节点、开始到结束两条,够页头和时间线画出来 */
const RUN_GRAPH = { nodes: [{ id: 'out', type: 'output', position: { x: 0, y: 0 }, data: { label: '成果', config: {} } }], edges: [] }
function runEvents(run) {
const t0 = Date.parse(run.started_at) / 1000
return [
{ seq: 1, type: 'run.started', node_id: null, ts: t0, data: { nodes: 1 } },
{ seq: 2, type: 'run.finished', node_id: null, ts: t0 + 4.2,
data: { output: run.output, usage: {}, timing: { wall_ms: 4200, active_ms: 4200, wait_ms: 0 } } },
]
}
/**
* 目标不是工作流的定时任务(next-round X4:定时只有一套):评测题集。没有工作流、输入模板、运行类别、Webhook;
* 立即运行发起的是一次评测(触发记录带 eval_run_id、没有 run_id)
*/
const S_EVAL = {
id: 's-eval', name: '零售题集 · 每周回归', target_kind: 'eval_suite', target_id: 'suite-retail', target_label: '评测题集',
target_name: '零售样例题集', target_exists: true, workflow_id: '', workflow_name: '零售样例题集', cron: '0 9 * * 1',
timezone: 'Asia/Shanghai', run_class: 'exploratory', input_template: {}, enabled: true, overlap: 'skip',
misfire_grace_s: 3600, webhook: null, next_run_at: iso(NOW + 3 * H + 5 * 60e3),
last_fire: fire('f-eval', { run_id: null, run_status: null, eval_run_id: 'ev1a2b3c4d5e' }),
description_text: '每周一 09:00(Asia/Shanghai)', created_by: '检查脚本', created_at: iso(NOW - 3 * 24 * H),
updated_at: iso(NOW - 24 * H), version: 2,
}
/** 数据源维护的定时(next-round 03 B4):建和改在数据源页,这里只读 */
const S_MAINT = {
...S_EVAL, id: 's-maint', name: '门店库 · 定时维护', target_kind: 'source_maintenance', target_id: 'ds-shop', target_label: '数据源维护',
target_name: '门店库', workflow_name: '门店库', cron: '0 2 * * *', description_text: '每天 02:00(Asia/Shanghai)',
last_fire: null, version: 5,
}
/**
* 数据源维护的触发记录(触发记录的 inputs:建了哪些任务、哪几步没执行,app/data/source_maintenance):维护没有运行。
* fm1 探查、剖析各建一个;fm2 剖析没开,只建了探查,另两步没执行(服务端记的原因就是这两步的原因连成一句);fm3 上一次的
* 探查还没做完、跳过;fm4 数据源不在了、没有建任务
*/
const MAINT_JOBS = [
{ step: 'introspect', kind: 'introspect', id: 'ij1a2b3c4d5e', tables: 12 },
{ step: 'profile_needed+observe', kind: 'profile', id: 'pj9f8e7d6c5b', scope: 'maintenance', tables: 3 },
]
const STEPS3 = ['introspect', 'profile_needed', 'observe']
const NOT_PROFILED = '这个数据源没有开启数据剖析'
function maintFires() {
const m = { run_id: null, run_status: null }
return [
fire('fm1', { ...m, kind: 'manual', inputs: { steps: STEPS3, jobs: MAINT_JOBS, skipped: [] } }),
fire('fm2', { ...m, fire_at: iso(NOW - 2 * 24 * H), created_at: iso(NOW - 2 * 24 * H),
reason: `剖析需要的表没有执行:${NOT_PROFILED};取值观察没有执行:${NOT_PROFILED}`,
inputs: { steps: STEPS3, jobs: [MAINT_JOBS[0]], skipped: [{ step: 'profile_needed', reason: NOT_PROFILED }, { step: 'observe', reason: NOT_PROFILED }] } }),
fire('fm3', { ...m, status: 'skipped_overlap', fire_at: iso(NOW - 3 * 24 * H), created_at: iso(NOW - 3 * 24 * H),
reason: '数据源「门店库」上一次的维护(或别的探查、剖析)还没做完:正在探查结构,这次触发已跳过', inputs: { steps: STEPS3 } }),
fire('fm4', { ...m, status: 'failed_to_start', fire_at: iso(NOW - 4 * 24 * H), created_at: iso(NOW - 4 * 24 * H),
reason: '数据源不存在,可能已被删除', inputs: {} }),
]
}
/** 评测题集(新建评测题集的定时任务时选) */
const SUITES = [
{ id: 'suite-retail', name: '零售样例题集', description: '虚构零售库上的几道问法', archived: false },
{ id: 'suite-stock', name: '库存问法', description: '', archived: false },
]
// ---------------------------------------------------------------- 数据事件触发的夹具(orchestration-round §4)
/** 能选的对象(服务端 GET /api/schedules/trigger-options 的形状):不能选的带原因,界面照它画、不自己判 */
const ROSTER_REASON = '每次上传替换整张表,没有「新的一期」:要用这个事件请在导入设置里改为按期累积'
const TRIGGER_OPTIONS = {
events: [
{ event: 'table.version', label: '上传表格出了新的一期', subject_label: '上传表格', objects: [
{ id: 'ds-sales', name: '门店销售', ok: true, reason: null },
{ id: 'ds-roster', name: '门店名单', ok: false, reason: ROSTER_REASON },
] },
{ event: 'workflow.finished', label: '上游工作流运行成功结束', subject_label: '工作流', objects: [
{ id: 'wf-daily', name: '门店日报', ok: true, reason: null },
{ id: 'wf-stock', name: '库存日报', ok: false, reason: '还没有发布版本:只有正式运行成功结束才算事件' },
] },
{ event: 'source.changed', label: '数据源有要处理的变化', subject_label: '数据源', objects: [
{ id: 'ds-shop', name: '门店库', ok: true, reason: null },
] },
],
origins: [{ kind: 'launcher', label: '手动正式运行' }, { kind: 'schedule', label: '定时任务' },
{ kind: 'app_backfill', label: '应用补算' }, { kind: 'hook', label: '入站钩子' }],
apps: [{ id: 'app-board', name: '门店看板' }],
default_debounce_s: 60, max_debounce_s: 86400, max_depth: 5,
}
/** 事件触发的变量清单(服务端按事件种类给:去掉上一次计划触发时间,接上 event.*) */
const EVENT_VARIABLES = {
'table.version': [
VARIABLES[0],
{ name: 'event.table', label: '上传表格的名字', example: '门店销售', note: '' },
{ name: 'event.period', label: '这一期(整月写 YYYY-MM,整周写 YYYY-Www,单日写 YYYY-MM-DD)', example: '2026-09', note: '' },
{ name: 'event.period_end', label: '这一期的最后一天(YYYY-MM-DD)', example: '2026-09-30', note: '' },
],
'workflow.finished': [
VARIABLES[0],
{ name: 'event.run_id', label: '上游那次运行的编号', example: 'run…', note: '' },
{ name: 'event.outputs.<字段名>', label: '上游运行的出口字段(写 event.outputs.<字段名>)', example: '{…}', note: '' },
],
'source.changed': [VARIABLES[0], { name: 'event.counts.new', label: '变化的条数', example: '{…}', note: '' }],
}
const SALES_EVENT = '上传表格「门店销售」出了新的一期(2026-09)'
const SALES_TRIGGER = '上传表格「门店销售」出了新的一期时触发(只认新增的一期;去抖 5 分钟)'
const SHOP_TRIGGER = '数据源「门店库」出现要处理的变化时触发(去抖 1 分钟)'
const EVENT_BRIEF = { event: 'table.version', subject_id: 'ds-sales', subject_name: '门店销售', text: SALES_EVENT, merged: 2,
first_at: iso(NOW - 25 * H), last_at: iso(NOW - 24 * H - 10 * 60e3), depth: 1, sample: false, real: true }
/** 事件触发的定时任务:去抖中(2 次事件在等),触发历史有事件、立即运行的示例、上一次没结束跳过的 */
const S_EVENT = {
...S1, id: 's-event', name: '门店月报 · 新一期', cron: '', next_run_at: null, webhook: null,
trigger_kind: 'event', trigger: { event: 'table.version', source_id: 'ds-sales', period: 'new', debounce_s: 300, subject_name: '门店销售' },
trigger_text: SALES_TRIGGER, description_text: SALES_TRIGGER, input_template: { date: '{{ event.period_end }}' },
trigger_state: { events: 0, waiting: 2, due_at: iso(NOW + 4 * 60e3), scheduler_on: true },
last_fire: fire('fe1', { kind: 'event', event: EVENT_BRIEF }), version: 2,
}
/** 调度没开:3 条事件留着、不会派发 */
const S_EVENT_OFF = {
...S_EVENT, id: 's-event-off', name: '变化处理', trigger: { event: 'source.changed', source_id: 'ds-shop', debounce_s: 60, subject_name: '门店库' },
trigger_text: SHOP_TRIGGER, description_text: SHOP_TRIGGER, trigger_state: { events: 3, waiting: 0, due_at: null, scheduler_on: false },
last_fire: null, version: 1,
}
function eventFires() {
const none = { run_id: null, run_status: null }
return [
fire('fe1', { kind: 'event', event: EVENT_BRIEF }),
fire('fe2', { ...none, kind: 'manual', status: 'failed_to_start', fire_at: iso(NOW - 30 * H), created_at: iso(NOW - 30 * H),
reason: '还没有发生过这种事件,输入模板里的 {{ event.* }} 没有可用的值,这次立即运行没有发起',
event: { ...EVENT_BRIEF, merged: 0, sample: true, real: false } }),
fire('fe3', { ...none, kind: 'event', status: 'skipped_overlap', fire_at: iso(NOW - 48 * H), created_at: iso(NOW - 48 * H),
reason: '上一次运行 #a1b2c3 仍在进行中,本次触发已跳过', event: { ...EVENT_BRIEF, merged: 1, depth: 2 } }),
// 调度停过:三期的事件都过了补发宽限,合成一条跳过(几个窗口、合掉的期次照实写)
fire('fe4', { ...none, kind: 'event', status: 'skipped_misfire', fire_at: iso(NOW - 72 * H), created_at: iso(NOW - 70 * H),
reason: '2026-10-14 09:00 至 2026-10-14 09:20 的 3 次事件(3 个去抖窗口):服务当时没有派发(调度未运行或已停止),已超出补发宽限(1 小时),没有发起;积压的事件不补',
event: { ...EVENT_BRIEF, merged: 3, windows: 3, periods: ['2026-07', '2026-08', '2026-09'] } }),
]
}
/** 由事件触发的那次运行:来源里记着 trigger(服务端发起时写下的那一句) */
const RUN_EVENT = { ...RUNS[0], id: 'run-event-1', schedule_id: 's-event', started_by: '定时任务:门店月报 · 新一期',
origin_kind: 'schedule', origin: { kind: 'schedule', schedule_id: 's-event', fire_kind: 'event',
trigger: { kind: 'event', event: 'table.version', subject_id: 'ds-sales', subject_name: '门店销售', text: SALES_EVENT, depth: 1 } } }
/** 有人署名点了立即运行:按最近一次事件发起(sample),不是事件触发的 */
const RUN_SAMPLE = { ...RUN_EVENT, id: 'run-event-2', started_by: '检查员甲',
origin: { ...RUN_EVENT.origin, fire_kind: 'manual', trigger: { ...RUN_EVENT.origin.trigger, sample: true, real: true } } }
/** 事件触发的预览:按一次事件渲染输入,不列触发时间;不合用的对象回 422(field=trigger) */
function eventPreviewReply(body) {
const t = body?.trigger ?? {}
if (t.source_id === 'ds-roster') return { status: 422, json: { detail: '「门店名单」不是按期累积的上传表格', field: 'trigger' } }
const tpl = body?.input_template ?? {}
const inputs = Object.fromEntries(Object.entries(tpl).map(([k, v]) => [k, String(v)
.replace(/\{\{\s*event\.period\s*\}\}/g, '2026-09').replace(/\{\{\s*event\.period_end\s*\}\}/g, '2026-09-30')]))
const desc = `上传表格「门店销售」出了新的一期时触发(${t.period === 'new' ? '只认新增的一期;' : ''}去抖 ${t.debounce_s === 300 ? '5 分钟' : '1 分钟'})`
return { json: { description_text: desc, next: [], inputs, event: { text: SALES_EVENT, real: true } } }
}
function freshState(patch = {}) {
return {
schedules: [structuredClone(S1), structuredClone(S2)],
fires: makeFires(),
notify: { public_base_url: null },
runs: null,
...patch,
}
}
/**
* 预览:照真服务端那样按所选时区排出接下来几次(认不出的写法按每天一次凑数),非法的回 422 和出错的那一项
* (照约定的形状)。state.previewNoZone 时时间不带时区后缀(UTC),看页面是不是和别处一个口径
*/
function previewReply(body, state) {
const [mi] = String(body?.cron ?? '').split(/\s+/)
if (Number(mi) > 59) return { status: 422, json: { detail: '第 1 段(分钟)应在 0–59 之间,现为 61', field: 'cron' } }
if (body?.timezone === 'Mars/Base') return { status: 422, json: { detail: '不认识的时区:Mars/Base', field: 'timezone' } }
const tpl = body?.input_template ?? {}
if (Object.values(tpl).some((v) => String(v).includes('schedule.nope'))) {
return { status: 422, json: { detail: '输入「date」里有不认识的变量:schedule.nope', field: 'input_template' } }
}
const tz = body?.timezone ?? 'UTC'
const n = body?.n ?? 5
const times = occurrences(body?.cron, tz, n) ?? Array.from({ length: n }, (_, k) => NOW + (k + 1) * 24 * H)
const next = times.map((t) => (state?.previewNoZone ? iso(t).replace(/Z$/, '') : iso(t)))
const day = wall(times[0] - 24 * H, tz)
const yesterday = `${day.y}-${pad2(day.mo)}-${pad2(day.d)}`
const inputs = Object.fromEntries(Object.entries(tpl).map(([k, v]) => [k, String(v).replace(/\{\{\s*schedule\.yesterday\s*\}\}/g, yesterday)]))
const weekly = body?.cron === '0 9 * * 1'
return { json: { description_text: `${weekly ? '每周一 09:00' : '每天 09:00'}(${tz})`, next, inputs } }
}
/** 测试消息的答复照后端的写法:message 是状态码之外的补充说明 */
const WEBHOOK_TEST_OK = { ok: true, http_status: 200, message: '请到接收端确认已收到' }
/** 照约定改假状态:PATCH 核对版本、按地址省略 / 空串改 Webhook */
function applyPatch(s, body) {
const out = { ...s }
for (const k of ['name', 'workflow_id', 'cron', 'timezone', 'run_class', 'input_template', 'enabled', 'misfire_grace_s', 'trigger']) {
if (k in body) out[k] = body[k]
}
if ('webhook' in body) {
const w = body.webhook
if (w.url === '') out.webhook = null
else {
out.webhook = {
format: w.format, events: w.events,
url_hint: w.url ? urlHint(w.url) : s.webhook?.url_hint,
has_secret: w.secret === '' ? false : w.secret ? true : !!s.webhook?.has_secret,
}
}
}
out.version = s.version + 1
out.updated_at = iso(Date.now())
if (!out.enabled || s.trigger_kind === 'event') out.next_run_at = null
else if (!s.enabled) out.next_run_at = iso(NOW + 3 * H + 5 * 60e3)
return out
}
let realSettings = null
async function settingsOf(state) {
realSettings ??= await fetch(`${API}/settings`).then((r) => r.json()).catch(() => ({}))
// 对外访问地址只读的那一组:state.publicUrl 设了就按它(环境变量指定的情形),否则照真服务端
return { ...realSettings, ui: { ...(realSettings.ui ?? {}), theme: state.theme ?? 'dark' }, notify: state.notify,
public_url: state.publicUrl ?? realSettings.public_url }
}
/** 一个请求怎么答。返回 {status, json};返回 null 表示放行(GET)或拦下(写请求) */
async function reply(state, method, path, url, body) {
if (state.override) {
const out = await state.override(method, path, body, url)
if (out) return out
}
const m = path.match(/^\/schedules\/([^/]+)(\/.*)?$/)
if (method === 'GET' && path === '/schedules') return { json: { items: state.schedules } }
if (method === 'POST' && path === '/schedules/preview' && body?.trigger_kind === 'event') return eventPreviewReply(body)
if (method === 'POST' && path === '/schedules/preview') return previewReply(body, state)
if (method === 'GET' && path === '/schedules/trigger-options') return { json: TRIGGER_OPTIONS }
if (method === 'GET' && path === '/schedules/variables' && url.searchParams.get('trigger_kind') === 'event') {
return { json: EVENT_VARIABLES[url.searchParams.get('event')] ?? [] }
}
if (method === 'POST' && path === '/schedules' && body?.trigger_kind === 'event') {
const s = { ...S_EVENT, ...body, id: 's-new-event', workflow_name: WORKFLOWS.find((w) => w.id === body.workflow_id)?.name ?? '',
trigger: { ...body.trigger, subject_name: '门店销售' }, trigger_text: SALES_TRIGGER, description_text: SALES_TRIGGER,
trigger_state: { events: 0, waiting: 0, due_at: null, scheduler_on: true }, last_fire: null, version: 1, webhook: null }
state.schedules = [s, ...state.schedules]
return { status: 201, json: s }
}
if (method === 'GET' && path === '/schedules/variables') return { json: VARIABLES }
if (method === 'POST' && path === '/schedules' && body?.target_kind && body.target_kind !== 'workflow') {
const suite = SUITES.find((x) => x.id === body.target_id)
const made = { ...S_EVAL, ...body, id: 's-new-eval', target_label: '评测题集', target_name: suite?.name ?? '', target_exists: true,
workflow_id: '', workflow_name: suite?.name ?? '', run_class: 'exploratory', input_template: {}, webhook: null, last_fire: null,
next_run_at: body.enabled ? iso(nextMonday9()) : null, description_text: '每周一 09:00(Asia/Shanghai)', version: 1 }
state.schedules = [made, ...state.schedules]
return { status: 201, json: made }
}
if (method === 'POST' && path === '/schedules') {
const id = 's-new'
const s = {
...body, id, workflow_name: WORKFLOWS.find((w) => w.id === body.workflow_id)?.name ?? '', version: 1,
webhook: body.webhook ? { format: body.webhook.format, url_hint: urlHint(body.webhook.url), has_secret: !!body.webhook.secret, events: body.webhook.events } : null,
next_run_at: body.enabled ? iso(nextMonday9()) : null, last_fire: null, description_text: '每周一 09:00(Asia/Shanghai)',
created_by: '检查脚本', created_at: iso(Date.now()), updated_at: iso(Date.now()),
}
state.schedules = [s, ...state.schedules]
return { status: 201, json: s }
}
if (m) {
const id = decodeURIComponent(m[1])
const rest = m[2] ?? ''
const s = state.schedules.find((x) => x.id === id)
if (!s) return { status: 404, json: { detail: '定时任务不存在或已被删除' } }
if (method === 'GET' && !rest) return { json: s }
if (method === 'PATCH' && !rest) {
if (body?.version !== s.version) {
return { status: 409, json: { detail: `这个定时任务已被修改(当前是第 ${s.version} 版,提交的是第 ${body?.version} 版),请刷新后再保存` } }
}
const next = applyPatch(s, body)
state.schedules = state.schedules.map((x) => (x.id === id ? next : x))
return { json: next }
}
if (method === 'DELETE' && !rest) {
state.schedules = state.schedules.filter((x) => x.id !== id)
return { status: 204 }
}
if (method === 'POST' && rest === '/run') {
const f = fire('f-manual', { fire_at: iso(Date.now()), kind: 'manual', run_id: 'run-new-1', run_status: 'queued' })
state.schedules = state.schedules.map((x) => (x.id === id ? { ...x, last_fire: f } : x))
return { json: { fire: f, run_id: 'run-new-1' } }
}
if (method === 'GET' && rest === '/fires') {
// 游标照服务端的写法:最后一条的记录时间,UTC 写成 Z 结尾;legacyCursor 时写成老服务端的 +00:00(「+」不编码
// 放进查询串会被解成空格,页面得照样翻得过去)
const cursor = (f) => (state.legacyCursor ? f.created_at.replace(/Z$/, '+00:00') : f.created_at)
const limit = Number(url.searchParams.get('limit') ?? 20)
const before = url.searchParams.get('before')
const start = before ? state.fires.findIndex((f) => cursor(f) === before) + 1 : 0
const items = state.fires.slice(start, start + limit)
const more = start + limit < state.fires.length
return { json: { items, next_before: more ? cursor(items.at(-1)) : null } }
}
if (method === 'POST' && rest === '/webhook/test') return { json: state.webhookTest ?? WEBHOOK_TEST_OK }
}
if (method === 'GET' && path === '/evals/suites') return { json: { suites: SUITES } }
if (method === 'GET' && path === '/workflows') return { json: WORKFLOWS }
if (method === 'GET' && path === '/workflows/wf-weekly/versions/3') return { json: VERSION3 }
if (method === 'GET' && path === '/settings') return { json: await settingsOf(state) }
if (method === 'PUT' && path === '/settings') {
if (body?.values?.notify) state.notify = body.values.notify
return { json: await settingsOf(state) }
}
if (state.runs && method === 'GET' && path === '/runs') return { json: state.runs }
if (state.runs && method === 'GET' && path.startsWith('/runs/')) {
const run = state.runs.find((r) => path === `/runs/${r.id}`)
if (run) return { json: run }
const m = path.match(/^\/runs\/([^/]+)\/(events|graph|artifacts)$/)
const of = m && state.runs.find((r) => r.id === m[1])
if (of && m[2] === 'events') return { json: runEvents(of) }
if (of && m[2] === 'graph') return { json: { graph: RUN_GRAPH, workflow_id: of.workflow_id, version: of.version } }
if (of) return { json: [] }
}
if (state.runs && method === 'GET' && path === '/approvals' && url.searchParams.get('run_id')) return { json: [] }
return null
}
// ---------------------------------------------------------------- 页面
const browser = await chromium.launch({ executablePath: CHROME })
/** 浏览器的本机时区:和夹具里定时任务的时区都不同,见开头的说明 */
const LOCAL_TZ = 'America/Los_Angeles'
/** 开一页:接口按 reply 答,记下每个请求;所有原生对话框、页面报错都记下,最后核对 */
async function open(path, { state = freshState(), width = 1280, height = 900, theme = 'dark' } = {}) {
state.theme = theme
const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: LOCAL_TZ })
opened.add(ctx)
ctx.setDefaultTimeout(6000)
ctx.setDefaultNavigationTimeout(30000)
await ctx.addInitScript((t) => {
try {
localStorage.setItem('agentlab.theme', t)
localStorage.setItem('agentlab_actor', '检查脚本')
} catch { /* noop */ }
}, theme)
const page = await ctx.newPage()
const sent = []
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('dialog', (d) => { errors.push(`原生对话框:${d.message()}`); void d.dismiss() })
await page.route((u) => new URL(u).pathname.startsWith('/api/'), async (route) => {
const r = route.request()
const url = new URL(r.url())
const method = r.method()
const p = decodeURIComponent(url.pathname.replace(/^\/api/, ''))
let body = null
try { body = r.postDataJSON() } catch { body = null }
sent.push({ key: `${method} ${p}`, body, search: url.search, at: Date.now() })
const out = await reply(state, method, p, url, body)
if (out) {
return route.fulfill({ status: out.status ?? 200, contentType: 'application/json',
body: out.json === undefined ? '' : JSON.stringify(out.json) })
}
if (method === 'GET') return route.continue()
return route.fulfill({ status: 503, body: '' })
})
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
return { ctx, page, sent, errors, state }
}
const until = async (fn, ms = 6000, step = 100) => {
const end = Date.now() + ms
let v = await fn()
while (!v && Date.now() < end) {
await new Promise((r) => setTimeout(r, step))
v = await fn()
}
return v
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const shot = async (page, name) => { if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }) }
const writes = (sent, re) => sent.filter((s) => !s.key.startsWith('GET ') && re.test(s.key))
/** 几段页面文字压成一行,失败时的说明不被换行截断 */
const oneLine = (texts) => texts.map((t) => t.replace(/\s+/g, ' ').trim()).join(' | ')
const text = (page, sel) => page.locator(sel).first().innerText()
/**
* 本页自己的横向溢出:页面根和页里会横向滚动的容器(overflow-x 为 auto / scroll)。不量整页:390 宽时外壳里收起的
* 导航提示本来就超出视口,与本页无关;也不量 overflow 为 hidden 的(截断加省略号是有意的)
*/
const overflow = (page) => page.evaluate(() => {
const root = document.querySelector('[data-schedules-page]')
if (!root) return 99
const boxes = [root, ...[...root.querySelectorAll('*')].filter((el) => ['auto', 'scroll'].includes(getComputedStyle(el).overflowX))]
return Math.max(0, ...boxes.map((el) => el.scrollWidth - el.clientWidth))
})
/** 等表单保存完(按钮回到「保存」且不可点):只等请求发出去就往下走,回来的答复会落在下一步中间 */
const saveDone = (page) => until(async () => {
const b = page.locator('[data-schedule-save]')
return (await b.isDisabled()) && !(await b.innerText()).includes('正在保存')
})
/** 等预览算完、说的是这个频率 */
const previewSays = (page, words) => until(async () => (await page.locator('[data-schedule-preview][data-status="ok"]').count()) === 1
&& (await page.locator('[data-schedule-preview]').innerText()).includes(words))
/**
* 只按 Tab 键把焦点移到 sel 上:从页面开头往后按,最多按 max 次。按到了返回按了几次,按不到返回 0。键盘可达就是
* 这个意思——鼠标能点、程序能 focus() 的不算
*/
const tabTo = async (page, sel, max = 160) => {
await page.evaluate(() => document.activeElement?.blur?.())
for (let i = 1; i <= max; i++) {
await page.keyboard.press('Tab')
if (await page.evaluate((s) => !!document.activeElement?.matches?.(s), sel)) return i
}
return 0
}
/** 元素整个在视口的横向范围里 */
const inViewX = (page, sel) => page.evaluate((s) => {
const el = document.querySelector(s)
if (!el) return false
const b = el.getBoundingClientRect()
return b.left >= 0 && b.right <= innerWidth + 0.5
}, sel)
/**
* 元素的字和它实际落在上面的底色的对比度(WCAG 算法),{ratio, fg, bg},找不到元素为 null。底色顺着祖先往上找,
* 半透明的一层层叠上去,直到不透明的那层(都透明就按白底)。只比「字色和某个容器的底色不相等」不够:页头本身是
* 透明的,那样比永远成立
*/
const readable = (page, sel) => page.evaluate((s) => {
const el = document.querySelector(s)
if (!el) return null
const parse = (c) => {
const m = c.match(/color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)/)
if (m) return [m[1] * 255, m[2] * 255, m[3] * 255, m[4] == null ? 1 : Number(m[4])]
const n = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/)
return n ? [Number(n[1]), Number(n[2]), Number(n[3]), n[4] == null ? 1 : Number(n[4])] : null
}
const over = (top, under) => top.slice(0, 3).map((v, i) => v * top[3] + under[i] * (1 - top[3]))
const layers = []
for (let n = el; n; n = n.parentElement) {
const c = parse(getComputedStyle(n).backgroundColor)
if (c && c[3] > 0) layers.push(c)
if (c && c[3] >= 1) break
}
let bg = [255, 255, 255]
for (const layer of layers.reverse()) bg = over(layer, bg)
const color = parse(getComputedStyle(el).color)
if (!color) return null
const fg = over(color, bg)
const lum = (rgb) => {
const [r, g, b] = rgb.map((v) => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4 })
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a)
return { ratio: Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100, fg: getComputedStyle(el).color,
bg: `rgb(${bg.map(Math.round).join(', ')})` }
}, sel)
// ---------------------------------------------------------------- 列表
await section('列表:渲染', async () => {
const { page, sent, errors } = await open('/schedules')
await page.locator('[data-schedule-row]').first().waitFor()
const rows = await page.locator('[data-schedule-row]').count()
check('两行定时任务', rows === 2, String(rows))
const r1 = page.locator('[data-schedule-row="s-weekly"]')
const t1 = await r1.innerText()
check('名字、工作流、频率的中文说法', t1.includes('门店周报 · 每周') && t1.includes('门店周报') && t1.includes('每周一 09:00(Asia/Shanghai)'),
t1.replace(/\s+/g, ' ').slice(0, 120))
check('名字链到详情', await r1.locator('[data-schedule-name]').getAttribute('href') === '/schedules/s-weekly')
const next = r1.locator('[data-schedule-next] time')
const nextText = await next.innerText()
const wantAbs = zoned(Date.parse(S1.next_run_at), 'Asia/Shanghai')
check(`下次运行:相对时间 + 按定时任务时区(Asia/Shanghai,不是本机的 ${LOCAL_TZ})的绝对时间`,
nextText.includes('3 小时后') && nextText.includes(wantAbs), `${nextText} | 应含 ${wantAbs}`)
const title = await next.getAttribute('title')
check('下次运行悬停看时区', !!title && title.includes('Asia/Shanghai'), title ?? '')
check('下次运行的 datetime 是 ISO', (await next.getAttribute('datetime')) === S1.next_run_at)
const last = r1.locator('[data-schedule-last] a')
check('上次触发链到那次运行', await last.getAttribute('href') === '/runs/run-sched-1', await last.getAttribute('href') ?? '无链接')
check('上次触发写运行的状态', (await last.innerText()).includes('已完成'), await last.innerText())
const r2 = page.locator('[data-schedule-row="s-stock"]')
const t2 = await r2.innerText()
check('暂停的:标「已暂停」,下次运行写暂停中', t2.includes('已暂停') && t2.includes('暂停中'), t2.replace(/\s+/g, ' ').slice(0, 160))
check('跳过的上次触发写明原因', t2.includes('上次未结束已跳过')
&& ((await r2.locator('[data-schedule-last] [title]').first().getAttribute('title')) ?? '').includes('尚未结束'))
const lastAt = zoned(Date.parse(S2.last_fire.fire_at), 'America/New_York')
const lastText = await r2.locator('[data-schedule-last] time').innerText()
check('上次触发的时间按这一行自己的时区(America/New_York)写', lastText === lastAt, `${lastText} | 应为 ${lastAt}`)
check('启用开关是 switch,状态对得上', await r1.locator('[data-schedule-toggle]').getAttribute('role') === 'switch'
&& await r1.locator('[data-schedule-toggle]').getAttribute('aria-checked') === 'true'
&& await r2.locator('[data-schedule-toggle]').getAttribute('aria-checked') === 'false')
check('每行有立即运行、编辑、删除', await r1.locator('[data-schedule-run]').count() === 1
&& await r1.locator('[data-schedule-edit]').getAttribute('href') === '/schedules/s-weekly'
&& await r1.locator('[data-schedule-delete]').count() === 1)
check('顶部有「新建定时任务」', (await page.locator('[data-schedule-create]').innerText()).includes('新建定时任务'))
const listWrites = writes(sent, /./)
check('只读:列表渲染不发写请求', listWrites.length === 0, listWrites.map((w) => w.key).join('、'))
await shot(page, 'list-dark')
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('列表:目标不是工作流的定时任务', async () => {
const state = freshState({ schedules: [structuredClone(S1), structuredClone(S_EVAL)],
fires: [fire('f-eval', { run_id: null, run_status: null, eval_run_id: 'ev1a2b3c4d5e' })] })
state.override = (method, path) => (method === 'POST' && path === '/schedules/s-eval/run'
? { json: { fire: fire('f-eval2', { kind: 'manual', run_id: null, run_status: null, eval_run_id: 'ev9f8e7d6c5b' }),
run_id: null } }
: null)
const { page, errors } = await open('/schedules', { state })
const row = page.locator('[data-schedule-row="s-eval"]')
await row.waitFor()
const t = await row.innerText()
check('写种类「评测题集」和题集名、频率', t.includes('评测题集') && t.includes('零售样例题集') && t.includes('每周一 09:00'),
t.replace(/\s+/g, ' ').slice(0, 160))
check('不标运行类别(评测题集没有正式 / 探索之分)', !t.includes('正式运行') && !t.includes('探索运行'))
check('评测题集的定时在这里改得了:写「编辑」', (await row.locator('[data-schedule-edit]').innerText()).includes('编辑'))
check('工作流的那一行照旧', (await page.locator('[data-schedule-row="s-weekly"]').innerText()).includes('正式运行'))
await row.locator('[data-schedule-run]').click()
const ok = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((x) => x.includes('已发起一次评测')) ?? null)
check('立即运行:说发起了一次评测(没有运行可看,不说「未能发起」)', !!ok && !ok.includes('查看运行'), ok ?? '')
await page.goto(`${WEB}/schedules/s-eval`, { waitUntil: 'networkidle' })
const settings = page.locator('[data-schedule-target-form="eval_suite"]')
await settings.waitFor()
check('详情的设置:名称、频率可改,题集链到评测页', await page.locator('[data-field="name"]').inputValue() === '零售题集 · 每周回归'
&& await page.locator('[data-schedule-suite="suite-retail"] a').getAttribute('href') === '/evals/suites/suite-retail')
check('不出工作流那几样(选工作流、运行类别、输入模板、Webhook)', await page.locator('[data-webhook-section]').count() === 0
&& await page.locator('#schedule-workflow').count() === 0)
check('页头的启用、立即运行、删除照常', await page.locator('[data-schedule-toggle]').count() === 1
&& await page.locator('[data-schedules-page] [data-schedule-run]').count() === 1)
await page.goto(`${WEB}/schedules/s-eval?tab=history`, { waitUntil: 'networkidle' })
const evalFire = page.locator('[data-fire-eval="ev1a2b3c4d5e"]')
await evalFire.waitFor()
check('触发历史写评测的编号,不写「未发起运行」', (await evalFire.innerText()).includes('评测 #ev1a2b')
&& !(await page.locator('[data-fire-id="f-eval"]').innerText()).includes('未发起运行'))
check('评测的编号链到那一次评测的详情', (await evalFire.getAttribute('href')) === '/evals/runs/ev1a2b3c4d5e')
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('目标种类:新建评测题集的定时、改名称和频率、数据源维护只读', async () => {
const state = freshState({ schedules: [structuredClone(S1), structuredClone(S_EVAL), structuredClone(S_MAINT)] })
const { page, sent, errors } = await open('/schedules/new', { state })
const kinds = page.locator('[data-schedule-target-kinds] [data-schedule-target-kind-pick]')
await kinds.first().waitFor()
check('新建时先选定时什么:只有工作流和评测题集(数据源维护在数据源页建)',
(await kinds.evaluateAll((els) => els.map((el) => el.getAttribute('data-schedule-target-kind-pick')))).join(',') === 'workflow,eval_suite')
check('缺省是工作流:照旧是工作流的表单', await page.locator('#schedule-workflow').count() === 1)
await page.locator('[data-schedule-target-kind-pick="eval_suite"]').click()
await page.locator('[data-schedule-target-form="eval_suite"]').waitFor()
check('换成评测题集:没有工作流、运行类别、输入、Webhook,写明通知走通知渠道', await page.locator('#schedule-workflow').count() === 0
&& await page.locator('[data-webhook-section]').count() === 0 && (await text(page, '[data-schedule-eval-note]')).includes('不推 Webhook'))
await page.locator('[data-schedule-suite-pick]').selectOption('suite-stock')
check('选了题集:名称替人填上题集名', await page.locator('[data-field="name"]').inputValue() === '库存问法')
await page.locator('[data-freq-preset="weekly"]').click()
await until(async () => await page.locator('[data-schedule-preview][data-status="ok"]').count() === 1)
await page.locator('[data-schedule-save]').click()
const created = await until(() => writes(sent, /^POST \/schedules$/)[0] ?? null)
check('建评测题集的定时:交目标种类和题集,不带工作流、输入模板、运行类别、Webhook', created?.body?.target_kind === 'eval_suite'
&& created.body.target_id === 'suite-stock' && created.body.name === '库存问法' && !('workflow_id' in created.body)
&& !('webhook' in created.body) && !('input_template' in created.body) && !('run_class' in created.body), JSON.stringify(created?.body))
check('建好进它的详情页', await until(() => new URL(page.url()).pathname === '/schedules/s-new-eval'))
await page.goto(`${WEB}/schedules/new?target_kind=eval_suite&target_id=suite-retail`, { waitUntil: 'networkidle' })
await page.locator('[data-schedule-target-form="eval_suite"]').waitFor()
check('评测页「设定时」带过来的题集:直接落到评测题集、题集选好、名称填好',
await page.locator('[data-schedule-suite-pick]').inputValue() === 'suite-retail' && await until(async () => (await page.locator('[data-field="name"]').inputValue()) === '零售样例题集'))
await page.goto(`${WEB}/schedules/s-eval`, { waitUntil: 'networkidle' })
await page.locator('[data-schedule-target-form="eval_suite"]').waitFor()
await page.locator('[data-field="name"]').fill('零售题集 · 每天回归')
await page.locator('[data-freq-preset="daily"]').click()
await until(async () => !(await page.locator('[data-schedule-save]').isDisabled()))
await page.locator('[data-schedule-save]').click()
const patched = await until(() => writes(sent, /^PATCH \/schedules\/s-eval$/)[0] ?? null)
check('改评测题集的定时:只交名称、频率和读到的版本', JSON.stringify(patched?.body) === JSON.stringify({ name: '零售题集 · 每天回归', cron: '0 9 * * *', version: 2 }),
JSON.stringify(patched?.body))
await page.goto(`${WEB}/schedules`, { waitUntil: 'networkidle' })
const maint = page.locator('[data-schedule-row="s-maint"]')
await maint.waitFor()
check('列表:数据源维护标种类,写「查看」', (await maint.innerText()).includes('数据源维护')
&& (await maint.locator('[data-schedule-edit]').innerText()).includes('查看'))
await page.goto(`${WEB}/schedules/s-maint`, { waitUntil: 'networkidle' })
const ro = page.locator('[data-schedule-target-settings="source_maintenance"]')
await ro.waitFor()
check('数据源维护的详情只读,写明在数据源页设置,链到数据源页', (await ro.innerText()).includes('在数据源页')
&& (await ro.locator('[data-schedule-to-source]').getAttribute('href')) === '/data/databases#source-ds-shop'
&& await page.locator('[data-schedule-save]').count() === 0)
check('页头的启用、立即运行照常', await page.locator('[data-schedule-toggle]').count() === 1
&& await page.locator('[data-schedules-page] [data-schedule-run]').count() === 1)
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('数据源维护的触发历史:列出建了的任务和没执行的步骤,不写「未发起运行」', async () => {
const state = freshState({ schedules: [structuredClone(S1), { ...structuredClone(S_MAINT), last_fire: maintFires()[0] }], fires: maintFires() })
const { page, errors } = await open('/schedules/s-maint?tab=history', { state })
await page.locator('[data-fire-id="fm4"]').waitFor()
const row = (id) => page.locator(`[data-fire-id="${id}"]`)
const all = await page.locator('[data-fire-list]').innerText()
check('没有一行写「未发起运行」(维护本来就不发起运行)', !all.includes('未发起运行'), all.replace(/\s+/g, ' ').slice(0, 200))
const jobs = row('fm1').locator('[data-fire-job]')
check('建了两个任务就列两个:步骤、任务种类、编号、表数', await jobs.count() === 2
&& (await jobs.nth(0).innerText()).includes('探查结构:探查任务 #ij1a2b(12 张表)')
&& (await jobs.nth(1).innerText()).includes('剖析需要的表、取值观察:剖析任务 #pj9f8e(3 张表)'),
(await jobs.allInnerTexts()).join(' | '))
check('探查任务链到数据源页那张卡片(#source-<id>,同数据源页认的约定)',
await row('fm1').locator('[data-fire-job-kind="introspect"]').getAttribute('href') === '/data/databases#source-ds-shop')
check('剖析任务链到数据目录里那个任务的进度(?pjob=,同「定时维护」的「最近一次」)',
await row('fm1').locator('[data-fire-job-kind="profile"]').getAttribute('href') === '/data/catalog/ds-shop?pjob=pj9f8e7d6c5b')
check('建了任务的那一行不另写「没有建任务」', await row('fm1').locator('[data-fire-maintenance-none]').count() === 0)
const skipped = row('fm2').locator('[data-fire-skipped]')
check('没执行的步骤逐条写原因', await skipped.count() === 2
&& (await skipped.nth(0).innerText()).includes(`剖析需要的表没有执行:${NOT_PROFILED}`)
&& (await skipped.nth(1).innerText()).includes(`取值观察没有执行:${NOT_PROFILED}`), (await skipped.allInnerTexts()).join(' | '))
check('……服务端连成一句的原因不再重复写一遍', !(await row('fm2').innerText()).includes('原因:'))
check('只建了探查的那一次照样列出探查任务', await row('fm2').locator('[data-fire-job]').count() === 1)
check('跳过的那一次:写「没有建任务」和原因', (await row('fm3').locator('[data-fire-maintenance-none]').innerText()).includes('没有建任务')
&& (await row('fm3').innerText()).includes('还没做完:正在探查结构'))
check('数据源不在了的那一次:写「没有建任务」和原因', (await row('fm4').locator('[data-fire-maintenance-none]').innerText()).includes('没有建任务')
&& (await row('fm4').innerText()).includes('数据源不存在'))
await shot(page, 'maintenance-history')
// 立即运行:建的是任务,说开始维护、详情写建了什么;不说「未能发起」
state.override = (method, path) => (method === 'POST' && path === '/schedules/s-maint/run'
? { json: { fire: fire('fm-now', { kind: 'manual', run_id: null, run_status: null, inputs: { steps: STEPS3, jobs: MAINT_JOBS, skipped: [] } }), run_id: null } }
: null)
await page.locator('[data-schedules-page] [data-schedule-run]').click()
const started = page.locator('[data-toast]', { hasText: '已开始维护' })
await started.first().waitFor()
const detail = (await started.first().locator('details').textContent().catch(() => '')) ?? ''
check('立即运行一次维护:说已开始维护,详情写建了哪几个任务', detail.includes('探查结构:探查任务 #ij1a2b') && detail.includes('剖析任务 #pj9f8e'), detail)
check('……不说「未能发起」', !(await page.locator('[data-toast]').allInnerTexts()).some((t) => t.includes('未能发起')))
state.override = (method, path) => (method === 'POST' && path === '/schedules/s-maint/run'
? { json: { fire: fire('fm-busy', { kind: 'manual', status: 'skipped_overlap', run_id: null, run_status: null,
reason: '数据源「门店库」上一次的维护(或别的探查、剖析)还没做完:正在剖析,这次触发已跳过', inputs: { steps: STEPS3 } }), run_id: null } }
: null)
await page.locator('[data-schedules-page] [data-schedule-run]').click()
const busy = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((t) => t.includes('上一次维护')) ?? null)
check('上一次维护还没做完:照实说跳过了,不说「上一次运行」', !!busy && !busy.includes('上一次运行'), busy ?? '')
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('列表:空态与老服务端', async () => {
const { page, errors } = await open('/schedules', { state: freshState({ schedules: [] }) })
await page.locator('[data-schedules-empty]').waitFor()
const t = await text(page, '[data-schedules-empty]')
check('空态说明定时任务是什么、怎么建', t.includes('还没有定时任务') && t.includes('自动发起工作流运行') && t.includes('选择工作流'), t.replace(/\s+/g, ' '))
check('空态里有新建按钮', await page.locator('[data-schedules-empty] [data-schedule-create]').count() === 1)
await page.locator('[data-schedules-empty] [data-schedule-create]').click()
check('点新建去 /schedules/new', await until(() => new URL(page.url()).pathname === '/schedules/new'))
const old = freshState()
old.override = (method, path) => (path.startsWith('/schedules') ? { status: 404, json: { detail: 'Not Found' } } : null)
const o = await open('/schedules', { state: old })
await o.page.locator('[data-schedules-unsupported]').waitFor()
check('老服务端(404):说服务端暂不支持,不露英文原文', (await text(o.page, '[data-schedules-unsupported]')).includes('服务端暂不支持定时任务')
&& !(await o.page.locator('[data-schedules-page]').innerText()).includes('Not Found'))
check('没有页面报错', !errors.length && !o.errors.length, [...errors, ...o.errors].join(' | '))
})
await section('列表:启用开关', async () => {
const { page, sent, errors, state } = await open('/schedules')
const toggle = page.locator('[data-schedule-row="s-weekly"] [data-schedule-toggle]')
await toggle.waitFor()
await toggle.click()
await until(async () => (await toggle.getAttribute('aria-checked')) === 'false')
const patches = writes(sent, /^PATCH \/schedules\/s-weekly$/)
check('点开关发一次 PATCH,带 enabled 和读到的版本', patches.length === 1
&& JSON.stringify(patches[0].body) === JSON.stringify({ enabled: false, version: 3 }), JSON.stringify(patches.map((p) => p.body)))
check('开关跟着变成关、行里标已暂停', (await page.locator('[data-schedule-row="s-weekly"]').innerText()).includes('已暂停'))
await toggle.click()
await until(async () => (await toggle.getAttribute('aria-checked')) === 'true')
const again = writes(sent, /^PATCH \/schedules\/s-weekly$/)
check('再点一次带上新版本(4)', again.length === 2 && again[1].body?.version === 4 && again[1].body?.enabled === true,
JSON.stringify(again.map((p) => p.body)))
// 别处改过:409 时说清是别处改的、已重新载入。服务端那句是写给表单保存的(「请刷新后再保存」),开关不照搬,放进详情
state.schedules = state.schedules.map((s) => (s.id === 's-stock' ? { ...s, version: 7 } : s))
const t2 = page.locator('[data-schedule-row="s-stock"] [data-schedule-toggle]')
await t2.click()
const conflictToast = page.locator('[data-toast]', { hasText: '别处被修改' })
const toastText = await until(async () => (await conflictToast.count()) ? conflictToast.first().innerText() : null)
check('版本不符(409):说清是别处改的、已重新载入,不叫人去刷新、也不提保存',
!!toastText && toastText.includes('库存日报') && toastText.includes('已重新载入') && !toastText.includes('刷新') && !toastText.includes('保存'),
toastText ?? (await page.locator('[data-toast]').allInnerTexts()).join(' | '))
check('……服务端的原话放在详情里', ((await conflictToast.first().locator('details').textContent().catch(() => '')) ?? '').includes('当前是第 7 版'))
const reloaded = await until(() => sent.filter((s) => s.key === 'GET /schedules').length >= 2)
check('……重新拉了一次列表,开关回到服务端的状态', !!reloaded && await t2.getAttribute('aria-checked') === 'false')
check('键盘:开关能聚焦、空格切换', await (async () => {
await toggle.focus()
await page.keyboard.press('Space')
return until(async () => (await toggle.getAttribute('aria-checked')) === 'false')
})())
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('列表:立即运行与删除', async () => {
const { page, sent, errors, state } = await open('/schedules')
const run = page.locator('[data-schedule-row="s-weekly"] [data-schedule-run]')
await run.waitFor()
await run.click()
const ok = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((x) => x.includes('已发起运行')) ?? null)
check('立即运行发 POST', writes(sent, /^POST \/schedules\/s-weekly\/run$/).length === 1)
check('结果:已发起运行,可直达那次运行', !!ok && ok.includes('查看运行'), ok ?? '')
check('上次触发换成这一次(链到新运行)', await until(async () =>
(await page.locator('[data-schedule-row="s-weekly"] [data-schedule-last] a').getAttribute('href')) === '/runs/run-new-1'))
await page.locator('[data-toast]', { hasText: '已发起运行' }).getByRole('button', { name: '查看运行' }).click()
check('点「查看运行」去 /runs/run-new-1', await until(() => new URL(page.url()).pathname === '/runs/run-new-1'))
// 上一次还没结束:照样记一条手动触发,但跳过了
state.override = (method, path) => (method === 'POST' && path === '/schedules/s-weekly/run'
? { json: { fire: fire('f-skip2', { kind: 'manual', status: 'skipped_overlap', run_id: null, run_status: null, reason: '上一次运行(#run-new)尚未结束' }), run_id: null } }
: null)
await page.goto(`${WEB}/schedules`, { waitUntil: 'networkidle' })
await page.locator('[data-schedule-row="s-weekly"] [data-schedule-run]').click()
const skipped = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((x) => x.includes('尚未结束')) ?? null)
check('上一次没结束:说本次已跳过', !!skipped, skipped ?? '')
state.override = (method, path) => (method === 'POST' && path === '/schedules/s-weekly/run'
? { json: { fire: fire('f-bad', { kind: 'manual', status: 'failed_to_start', run_id: null, run_status: null, reason: '正式运行需要已发布的版本' }), run_id: null } }
: null)
await page.locator('[data-schedule-row="s-weekly"] [data-schedule-run]').click()
const refused = await until(async () => (await page.locator('[data-toast]').allInnerTexts()).find((x) => x.includes('未能发起运行')) ?? null)
check('发起被拒:写出服务端给的原因', !!refused && refused.includes('正式运行需要已发布的版本'), refused ?? '')
state.override = null
await page.locator('[data-schedule-row="s-stock"] [data-schedule-delete]').click()
const dialog = page.locator('[role="dialog"]')
await dialog.waitFor()
const dt = await dialog.innerText()
check('删除先确认(站内对话框),写清后果', dt.includes('删除定时任务「库存日报」') && dt.includes('已发起的运行'), dt.replace(/\s+/g, ' '))
check('……确认前没发 DELETE', writes(sent, /^DELETE /).length === 0)
await dialog.getByRole('button', { name: '删除定时任务' }).click()
await until(async () => (await page.locator('[data-schedule-row="s-stock"]').count()) === 0)
check('确认后发 DELETE、行消失', writes(sent, /^DELETE \/schedules\/s-stock$/).length === 1
&& await page.locator('[data-schedule-row="s-stock"]').count() === 0)
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
/** 某个 GET 的答复按请求到达那一刻的快照、晚 ms 才回:模拟一次在写操作之前读到旧值、之后才回来的刷新 */
const slowGet = (state, path, pick, ms = 1500) => async (method, p) => {
if (method !== 'GET' || p !== path) return null
const snap = structuredClone(pick(state))
await sleep(ms)
return { json: snap }
}
await section('列表:晚到的刷新不盖掉刚切换的开关', async () => {
const state = freshState()
const { page, sent, errors } = await open('/schedules', { state })
const row = page.locator('[data-schedule-row="s-weekly"]')
const toggle = row.locator('[data-schedule-toggle]')
await toggle.waitFor()
state.override = slowGet(state, '/schedules', (st) => ({ items: st.schedules }))
const lists = () => sent.filter((s) => s.key === 'GET /schedules').length
const before = lists()
// 立即运行之后静默刷新一次:这次读到的是开关之前的快照,晚 1.5 秒才回
await row.locator('[data-schedule-run]').click()
await until(() => lists() > before)
await until(async () => !(await row.locator('[data-schedule-run]').isDisabled()))
await toggle.click()
check('开关先变成关', await until(async () => (await toggle.getAttribute('aria-checked')) === 'false'))
await sleep(1900)
check('晚到的列表答复不把开关盖回去', await toggle.getAttribute('aria-checked') === 'false' && await row.getAttribute('data-enabled') === 'false',
`aria-checked=${await toggle.getAttribute('aria-checked')}`)
state.override = null
await toggle.click()
await until(() => writes(sent, /^PATCH \/schedules\/s-weekly$/).length === 2)
const p = writes(sent, /^PATCH \/schedules\/s-weekly$/).map((x) => x.body)
check('再点一次带开关之后的版本(4),不撞 409', p[1]?.version === 4 && p[1]?.enabled === true, JSON.stringify(p))
await sleep(300)
check('……没有冲突提示', !(await page.locator('[data-toast]').allInnerTexts()).some((t) => t.includes('别处被修改')))
check('没有页面报错', !errors.length, errors.join(' | '))
})
// ---------------------------------------------------------------- 新建
/** 在新建页上填好「门店周报,每周一 09:00,飞书 Webhook」 */
async function fillWeekly(page) {
const picker = page.locator('[data-workflow-picker] input')
await picker.click()
await picker.fill('周报')
await page.locator('[data-workflow-option="wf-weekly"]').waitFor()
check('工作流可搜索:输入「周报」只剩门店周报', await page.locator('[data-workflow-option]').count() === 1)
await page.locator('[data-workflow-option="wf-weekly"]').click()
check('选了工作流、名字空着时用工作流名填上', await page.locator('[data-field="name"]').inputValue() === '门店周报')
await page.locator('[data-field="name"]').fill('门店周报 · 每周')
await page.locator('[data-freq-preset="weekly"]').click()
check('选「每周」默认勾周一', await page.locator('[data-weekday="1"]').getAttribute('aria-pressed') === 'true'
&& await page.locator('[data-weekday="3"]').getAttribute('aria-pressed') === 'false')
await page.locator('[data-freq-time]').fill('09:00')
}
await section('新建:每周一 09:00 的请求体', async () => {
const { page, sent, errors } = await open('/schedules/new')
await page.locator('[data-schedule-form]').waitFor()
check('页头写「新建定时任务」', (await page.locator('[data-schedules-page] header h1').innerText()).includes('新建定时任务'))
check(`时区默认取本机(${LOCAL_TZ})`, await page.locator('[data-timezone]').inputValue() === LOCAL_TZ,
await page.locator('[data-timezone]').inputValue())
await fillWeekly(page)
await page.locator('[data-timezone]').selectOption('Asia/Shanghai')
// 正式运行:字段取已发布的 v3(没有画布上的 draft_only)
await page.locator('[data-input-field="date"]').waitFor()
check('正式运行的输入字段取已发布的 v3', await page.locator('[data-input-field]').count() === 2
&& await page.locator('[data-input-field="draft_only"]').count() === 0
&& (await text(page, '[data-inputs-source]')).includes('v3'))
check('必填字段标出来(和发起运行的表单一样是红色星号,读屏念「必填」)',
await page.locator('[data-input-field="date"] [data-required][aria-label="必填"]').count() === 1
&& await page.locator('[data-input-field="region"] [data-required]').count() === 0)
await page.locator('[data-input-vars="date"]').click()
const varLabels = await page.locator('[data-input-field="date"] [data-var]').allInnerTexts()
check('变量清单有 ISO 周、月份(按周、按月统计的报告跟着触发日走)',
varLabels.includes('本周(ISO 周,YYYY-Www)') && varLabels.includes('上周(ISO 周,YYYY-Www)') && varLabels.includes('上月(YYYY-MM)'),
oneLine(varLabels))
check('ISO 周的悬停写变量名和示例', (await page.locator('[data-input-field="date"] [data-var="schedule.last_week"]').getAttribute('title'))
=== 'schedule.last_week,例如 2026-W40')
const notes = await page.locator('[data-input-field="date"] [data-var-note]').allInnerTexts()
check('ISO 周的跨年规则写在变量下面,几个周变量同一句只写一遍(触屏没有悬停也看得到)',
notes.length === 1 && notes[0] === ISO_NOTE, oneLine(notes))
await page.locator('[data-input-field="date"] [data-var="schedule.yesterday"]').click()
check('点变量插入模板', await page.locator('[data-input-field="date"] input').inputValue() === '{{ schedule.yesterday }}',
await page.locator('[data-input-field="date"] input').inputValue())
await previewSays(page, '每周一 09:00(Asia/Shanghai)')
const times = await page.locator('[data-preview-time]').allInnerTexts()
const want = occurrences('0 9 * * 1', 'Asia/Shanghai', 5).map((t) => zoned(t, 'Asia/Shanghai', 'full'))
check(`预览接下来 5 次触发:按所选时区(不是本机的 ${LOCAL_TZ})写,逐条对得上`,
times.length === 5 && times.every((t, i) => t.includes(want[i])) && want.every((w) => w.endsWith('周一 09:00')),
`${oneLine(times)} | 应为 ${want.join(' | ')}`)
check('预览写频率的中文说法', (await text(page, '[data-schedule-preview]')).includes('每周一 09:00'))
const rendered = await until(async () => {
const t = await page.locator('[data-input-rendered="date"]').innerText().catch(() => '')
return /\d{4}-\d{2}-\d{2}/.test(t) ? t : null
})
check('输入旁边写渲染结果', !!rendered && rendered.includes('下次触发时为'), rendered ?? '')
// 预览防抖:连续改三次只发一两次
const before = writes(sent, /^POST \/schedules\/preview$/).length
for (const t of ['09:01', '09:02', '09:00']) await page.locator('[data-freq-time]').fill(t)
await sleep(1200)
const burst = writes(sent, /^POST \/schedules\/preview$/).length - before
check('预览防抖:连着改三次不发三次', burst >= 1 && burst <= 2, `${burst} 次`)
// Webhook:飞书、地址、密钥,取消「等待审批」
await page.locator('[data-webhook-on]').check()
await page.locator('[data-webhook-format="feishu"]').click()
await page.locator('[data-webhook-url]').fill(FEISHU_URL)
await page.locator('[data-webhook-secret]').fill('s3cr3t-demo')
await page.locator('[data-webhook-event="awaiting_approval"]').uncheck()
check('测试消息要先保存', await page.locator('[data-webhook-test]').isDisabled())
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^POST \/schedules$/).length === 1)
const body = writes(sent, /^POST \/schedules$/)[0]?.body
check('POST 请求体:cron 0 9 * * 1', body?.cron === '0 9 * * 1', body?.cron)
check('POST 请求体:时区 Asia/Shanghai', body?.timezone === 'Asia/Shanghai', body?.timezone)
check('POST 请求体:工作流、名称、运行类别', body?.workflow_id === 'wf-weekly' && body?.name === '门店周报 · 每周' && body?.run_class === 'formal')
check('POST 请求体:输入模板只带填了的字段', JSON.stringify(body?.input_template) === JSON.stringify({ date: '{{ schedule.yesterday }}' }),
JSON.stringify(body?.input_template))
check('POST 请求体:Webhook 格式、地址、密钥、事件', JSON.stringify(body?.webhook) === JSON.stringify({
format: 'feishu', url: FEISHU_URL, secret: 's3cr3t-demo', events: ['finished', 'failed'] }), JSON.stringify(body?.webhook))
check('POST 请求体:启用、重叠跳过、补发宽限', body?.enabled === true && body?.overlap === 'skip' && body?.misfire_grace_s === 3600,
JSON.stringify({ enabled: body?.enabled, overlap: body?.overlap, misfire_grace_s: body?.misfire_grace_s }))
check('创建后进详情页', await until(() => new URL(page.url()).pathname === '/schedules/s-new'))
await page.locator('[data-webhook-url-hint]').waitFor()
check('保存后地址只显示 url_hint', (await text(page, '[data-webhook-url-hint]')).includes('open.feishu.cn/…ab12'))
check('……页面上和输入框里都没有完整地址', !(await page.locator('[data-schedules-page]').innerText()).includes('0f3c9a7e')
&& await page.locator('[data-webhook-url]').count() === 0
&& !(await page.locator('[data-schedules-page] input').evaluateAll((els) => els.map((e) => e.value))).some((v) => v.includes('0f3c9a7e')))
check('……密钥显示「已设置」,不回填', (await text(page, '[data-webhook-secret-set]')).includes('已设置')
&& await page.locator('[data-webhook-secret]').count() === 0)
await shot(page, 'detail-after-create')
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('新建:预览出错与保存前的问题', async () => {
const { page, sent, errors } = await open('/schedules/new')
await page.locator('[data-schedule-form]').waitFor()
check('没填完不能保存,按钮上说还缺什么', await page.locator('[data-schedule-save]').isDisabled()
&& ((await page.locator('[data-schedule-save]').getAttribute('title')) ?? '').includes('名称'))
await fillWeekly(page)
await page.locator('[data-freq-preset="custom"]').click()
check('切到自定义:带上当前预设生成的 cron', await page.locator('[data-freq-cron]').inputValue() === '0 9 * * 1',
await page.locator('[data-freq-cron]').inputValue())
await page.locator('[data-freq-cron]').fill('61 9 * * 1')
const err = await until(async () => {
const t = await page.locator('[data-freq-error]').innerText().catch(() => '')
return t.includes('0–59') ? t : null
})
check('非法 cron:频率下面写服务端的说明', !!err, err ?? '')
check('……频率输入框标成出错,说明接到 aria-describedby', await page.locator('[data-freq-cron]').getAttribute('aria-invalid') === 'true'
&& !!(await page.locator('[data-freq-cron]').getAttribute('aria-describedby')))
check('……预览区不再摆上一次的时间', await page.locator('[data-preview-time]').count() === 0)
check('……不能保存', await page.locator('[data-schedule-save]').isDisabled())
await page.locator('[data-freq-cron]').fill('0 9 * * 1')
await page.locator('[data-preview-time]').nth(4).waitFor()
check('改对之后说明消失、预览回来', await page.locator('[data-freq-error]').count() === 0)
await page.locator('[data-timezone]').selectOption('America/New_York')
await previewSays(page, '(America/New_York)')
const ny = occurrences('0 9 * * 1', 'America/New_York', 5).map((t) => zoned(t, 'America/New_York', 'full'))
const nyTimes = await page.locator('[data-preview-time]').allInnerTexts()
check('换时区之后预览按新时区写:5 个时刻逐条对得上', nyTimes.length === 5 && nyTimes.every((t, i) => t.includes(ny[i])),
`${oneLine(nyTimes)} | 应为 ${ny.join(' | ')}`)
await page.locator('[data-input-field="date"] input').fill('{{ schedule.nope }}')
const inErr = await until(async () => {
const t = await page.locator('[data-inputs-error]').innerText().catch(() => '')
return t.includes('schedule.nope') ? t : null
})
check('模板里写错变量:输入下面写服务端的说明', !!inErr, inErr ?? '')
check('没有提交过 POST', writes(sent, /^POST \/schedules$/).length === 0)
// 键盘:频率预设是单选组,方向键切换
await page.locator('[data-freq-preset="custom"]').focus()
await page.keyboard.press('ArrowLeft')
check('键盘:频率预设方向键切换', await page.locator('[data-freq-preset="hourly"]').getAttribute('aria-checked') === 'true')
check('表单字段都有 label', await page.locator('[data-schedule-form] input:not([type=hidden]), [data-schedule-form] select, [data-schedule-form] textarea')
.evaluateAll((els) => els.every((el) => el.labels?.length || el.getAttribute('aria-label') || el.getAttribute('aria-labelledby'))))
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('新建:离开前确认', async () => {
const { page, errors } = await open('/schedules/new')
await page.locator('[data-schedule-form]').waitFor()
await page.locator('[data-field="name"]').fill('半截')
await page.locator('nav[aria-label="主导航"] a[href="/runs"]').click()
const dialog = page.locator('[role="dialog"]')
check('改了一半离开:先问一句', await dialog.waitFor({ timeout: 3000 }).then(() => true, () => false)
&& (await dialog.innerText()).includes('未保存的修改'))
await dialog.getByRole('button', { name: '取消' }).click()
check('……选留下:还在新建页', new URL(page.url()).pathname === '/schedules/new')
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('新建:换工作流、未发布与输入字段', async () => {
const { page, errors, state } = await open('/schedules/new')
await page.locator('[data-schedule-form]').waitFor()
const picker = page.locator('[data-workflow-picker] input')
const pick = async (id) => {
await picker.click()
await page.locator(`[data-workflow-option="${id}"]`).click()
}
const name = page.locator('[data-field="name"]')
const save = page.locator('[data-schedule-save]')
const saveTitle = async () => (await save.getAttribute('title')) ?? ''
await pick('wf-weekly')
check('选了门店周报:名称自动填成门店周报', await name.inputValue() === '门店周报')
await pick('wf-stock')
check('改选库存日报:自动填的名称跟着换', await name.inputValue() === '库存日报', await name.inputValue())
check('库存日报没有发布:运行类别替人选探索运行', await page.locator('[data-run-class="exploratory"]').getAttribute('aria-checked') === 'true')
// 服务端对「正式运行 + 没发布」在保存时就回 422:前端当场拦下,不说「到点才失败」
await page.locator('[data-run-class="formal"]').click()
const problem = page.locator('[data-run-class-problem]')
const pt = await problem.innerText().catch(() => '')
check('没发布又选正式运行:运行类别下面写明要先发布或改用探索运行', pt.includes('发布') && pt.includes('探索运行') && !pt.includes('发起失败'), pt)
check('……不能保存,按钮上说还要处理运行类别', await save.isDisabled() && (await saveTitle()).includes('运行类别'), await saveTitle())
await shot(page, 'new-not-published')
await page.locator('[data-run-class="exploratory"]').click()
check('改回探索运行:问题收起', await problem.count() === 0)
const wh = page.locator('[data-input-field="warehouse"]')
const wt = await wh.innerText()
check('必填且没有默认值:说要填写后才能保存,不说到点才失败', wt.includes('请填写') && !wt.includes('到点'), wt.replace(/\s+/g, ' '))
check('……不能保存,按钮上说还要处理输入', await save.isDisabled() && (await saveTitle()).includes('输入'), await saveTitle())
await wh.locator('input').fill('北仓')
check('填了之后可以保存', await until(async () => !(await save.isDisabled())), await saveTitle())
await name.fill('我的日报')
await pick('wf-weekly')
check('人改过的名称换工作流时不覆盖', await name.inputValue() === '我的日报', await name.inputValue())
// 探索运行用画布上的字段(有 draft_only),正式运行用已发布的 v3(没有):交上去会被拒,写清是哪一版里没有
await page.locator('[data-input-field="draft_only"] input').fill('只在画布上')
await page.locator('[data-input-field="date"] input').fill('2026-10-01')
await page.locator('[data-run-class="formal"]').click()
const stale = page.locator('[data-input-stale="draft_only"]')
await stale.waitFor()
const st = await stale.innerText()
check('切到正式运行:画布独有的字段写明已发布的 v3 中没有,不说工作流里没有', st.includes('已发布的 v3 中没有这个字段') && !st.includes('工作流中已没有'),
st.replace(/\s+/g, ' '))
check('……交上去会被拒:不能保存,按钮上说还要处理输入', await save.isDisabled() && (await saveTitle()).includes('输入'), await saveTitle())
if (SHOTS) {
// 等运行类别的颜色过渡播完、把这一行挪到保存栏上面再截
await sleep(300)
await stale.evaluate((el) => el.scrollIntoView({ block: 'center' }))
}
await shot(page, 'new-stale-field')
await stale.getByRole('button', { name: '移除' }).click()
check('移除之后可以保存', await until(async () => !(await save.isDisabled())), await saveTitle())
// 保存被拒、指明运行类别:说明写在运行类别下面,不只在底部
state.override = (method, path) => (method === 'POST' && path === '/schedules'
? { status: 422, json: { detail: '「门店周报」还没有发布版本,正式运行只能从发布版本发起。请先在编排页发布,或改为探索运行', field: 'run_class' } }
: null)
await save.click()
const rcErr = await until(async () => (await page.locator('[data-run-class-error]').innerText().catch(() => '')) || null)
check('保存被拒、指明运行类别:说明写在运行类别下面', !!rcErr && rcErr.includes('还没有发布版本'), rcErr ?? '')
await shot(page, 'new-run-class-error')
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('新建:预览时间不带时区后缀', async () => {
const { page, errors } = await open('/schedules/new', { state: freshState({ previewNoZone: true }) })
await page.locator('[data-schedule-form]').waitFor()
await fillWeekly(page)
await page.locator('[data-timezone]').selectOption('Asia/Shanghai')
await previewSays(page, '每周一 09:00(Asia/Shanghai)')
const times = await page.locator('[data-preview-time]').allInnerTexts()
const want = occurrences('0 9 * * 1', 'Asia/Shanghai', 5).map((t) => zoned(t, 'Asia/Shanghai', 'full'))
check('不带时区后缀的时间按 UTC 读(和列表、触发历史一个口径),不按本机时间读', times.length === 5 && times.every((t, i) => t.includes(want[i])),
`${oneLine(times)} | 应为 ${want.join(' | ')}`)
check('没有页面报错', !errors.length, errors.join(' | '))
})
// ---------------------------------------------------------------- 详情
await section('详情:Webhook 与运行链接地址', async () => {
const { page, sent, errors, state } = await open('/schedules/s-weekly')
await page.locator('[data-schedule-form]').waitFor()
check('页头写名字和频率', (await page.locator('[data-schedules-page] header').innerText()).includes('门店周报 · 每周'))
check('读出来的频率选中每周、勾周一', await page.locator('[data-freq-preset="weekly"]').getAttribute('aria-checked') === 'true'
&& await page.locator('[data-weekday="1"]').getAttribute('aria-pressed') === 'true')
check('已保存的地址只显示 url_hint', (await text(page, '[data-webhook-url-hint]')).includes('open.feishu.cn/…ab12')
&& await page.locator('[data-webhook-url]').count() === 0)
check('没改动时保存按钮不可点', await page.locator('[data-schedule-save]').isDisabled())
// 测试结果:服务端的 message 是补充说明时接在结局后面;是整句(老写法)时不再套一层。状态码只写一次
const testResult = async (reply, want) => {
state.webhookTest = reply
const before = writes(sent, /^POST \/schedules\/s-weekly\/webhook\/test$/).length
await page.locator('[data-webhook-test]').click()
await until(() => writes(sent, /^POST \/schedules\/s-weekly\/webhook\/test$/).length > before)
const got = await until(async () => {
const t = (await page.locator('[data-webhook-test-result]').innerText().catch(() => '')).trim()
return t === want ? t : null
}, 3000)
return got ?? (await page.locator('[data-webhook-test-result]').innerText().catch(() => '')).trim()
}
const times = (str, word) => str.split(word).length - 1
const okText = await testResult(null, '测试消息已送达(HTTP 200):请到接收端确认已收到')
check('发送测试消息并显示结果:结局、状态码、补充说明各一遍', okText === '测试消息已送达(HTTP 200):请到接收端确认已收到'
&& await page.locator('[data-webhook-test-result="ok"]').count() === 1, okText)
const whole = await testResult({ ok: true, http_status: 200, message: '测试消息已送达(HTTP 200)' }, '测试消息已送达(HTTP 200)')
check('……服务端给的是整句:不再套一层', whole === '测试消息已送达(HTTP 200)' && times(whole, 'HTTP 200') === 1, whole)
const fail = await testResult({ ok: false, http_status: 404, message: 'open.feishu.cn 返回 HTTP 404:not found' },
'测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found')
check('……发送失败:状态码只写一遍', fail === '测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found' && times(fail, 'HTTP 404') === 1
&& await page.locator('[data-webhook-test-result="failed"]').count() === 1, fail)
const failOld = await testResult({ ok: false, http_status: 404, message: '推送失败:open.feishu.cn 返回 HTTP 404:not found' },
'测试消息发送失败:open.feishu.cn 返回 HTTP 404:not found')
check('……「发送失败」「推送失败」不叠在一起', !failOld.includes('推送失败') && times(failOld, 'HTTP 404') === 1, failOld)
await page.locator('[data-webhook-test-result]').scrollIntoViewIfNeeded()
await shot(page, 'detail-webhook-test-failed')
state.webhookTest = null
// 只改事件:PATCH 不带地址
await page.locator('[data-webhook-event="awaiting_approval"]').check()
check('改了 Webhook 设置后,测试要先保存', await page.locator('[data-webhook-test]').isDisabled())
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^PATCH \/schedules\/s-weekly$/).length === 1)
await saveDone(page)
const p1 = writes(sent, /^PATCH \/schedules\/s-weekly$/)[0]?.body
check('只改事件:PATCH 带版本、格式和事件,不带地址和密钥', JSON.stringify(p1) === JSON.stringify({
webhook: { format: 'feishu', events: ['finished', 'failed', 'awaiting_approval'] }, version: 3 }), JSON.stringify(p1))
check('保存后按钮回到不可点、地址提示还在', await page.locator('[data-schedule-save]').isDisabled()
&& (await text(page, '[data-webhook-url-hint]')).includes('…ab12'))
// 替换地址
await page.locator('[data-webhook-replace]').click()
await page.locator('[data-webhook-url]').fill('https://open.feishu.cn/open-apis/bot/v2/hook/new-demo-cd34')
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^PATCH \/schedules\/s-weekly$/).length === 2)
await saveDone(page)
const p2 = writes(sent, /^PATCH \/schedules\/s-weekly$/)[1]?.body
check('替换地址:带新地址和新版本', p2?.webhook?.url === 'https://open.feishu.cn/open-apis/bot/v2/hook/new-demo-cd34' && p2?.version === 4
&& !('secret' in (p2?.webhook ?? {})), JSON.stringify(p2))
check('……保存后又只显示 url_hint', await until(async () => (await text(page, '[data-webhook-url-hint]').catch(() => '')).includes('…cd34'))
&& await page.locator('[data-webhook-url]').count() === 0)
// 运行链接地址:读写设置的 notify 组。对比页链接也按它拼,说明里一并说到
const unset = await text(page, '[data-link-base]')
check('写出运行链接地址还没设置,没有它时运行和对比页链接都不附', unset.includes('未设置') && unset.includes('运行和对比页链接'),
oneLine([unset]))
await page.locator('[data-link-base-edit]').click()
await page.locator('[data-link-base-input]').fill('ftp://bad')
check('地址不对时不让存', await page.locator('[data-link-base-save]').isDisabled())
await page.locator('[data-link-base-input]').fill('https://agentlab.example.com/')
await page.locator('[data-link-base-save]').click()
await until(() => writes(sent, /^PUT \/settings$/).length === 1)
const put = writes(sent, /^PUT \/settings$/)[0]?.body
check('PUT /api/settings 只写 notify 一组(去掉末尾的斜杠)', JSON.stringify(put) === JSON.stringify({
values: { notify: { public_base_url: 'https://agentlab.example.com' } } }), JSON.stringify(put))
check('……页面上写出当前值', await until(async () => (await text(page, '[data-link-base]')).includes('https://agentlab.example.com')))
check('假状态里存上了', state.notify.public_base_url === 'https://agentlab.example.com')
// 删除 Webhook
await page.locator('[data-webhook-remove]').click()
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^PATCH \/schedules\/s-weekly$/).length === 3)
await saveDone(page)
const p3 = writes(sent, /^PATCH \/schedules\/s-weekly$/)[2]?.body
check('删除 Webhook:地址交空串', p3?.webhook?.url === '' && p3?.version === 5, JSON.stringify(p3))
check('……之后 Webhook 是关着的', await until(async () => !(await page.locator('[data-webhook-on]').isChecked())))
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('详情:对外访问地址由环境变量指定', async () => {
// 全站只认一个对外访问地址(服务端 core/public_url.py):环境变量设了就以它为准,这里只显示、写明来源,不给修改;
// notify 里存着的旧值不生效,也不显示
const env = 'https://agentlab.example.internal'
const { page, sent, errors } = await open('/schedules/s-weekly', {
state: freshState({ notify: { public_base_url: 'https://stale.example.com' }, publicUrl: { value: env, env_locked: true } }),
})
await page.locator('[data-link-base]').waitFor()
const shown = await until(async () => {
const t = await text(page, '[data-link-base]')
return t.includes(env) ? t : null
})
check('显示环境变量给的地址,不显示设置里存着的旧值', !!shown && !shown.includes('stale.example.com'), oneLine([shown ?? '']))
check('……写明由环境变量 AGENTLAB_PUBLIC_URL 指定', (await text(page, '[data-link-base-env]').catch(() => '')).includes('AGENTLAB_PUBLIC_URL'))
check('……没有修改按钮,也没发 PUT', await page.locator('[data-link-base-edit]').count() === 0
&& writes(sent, /^PUT \/settings$/).length === 0)
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('详情:保存冲突与页头操作', async () => {
const { page, sent, errors, state } = await open('/schedules/s-weekly')
await page.locator('[data-schedule-form]').waitFor()
// 页头的开关:带版本,表单之后的保存用新版本
await page.locator('header [data-schedule-toggle]').click()
await until(async () => (await page.locator('header [data-schedule-toggle]').getAttribute('aria-checked')) === 'false')
check('页头开关发 PATCH 带版本', JSON.stringify(writes(sent, /^PATCH /)[0]?.body) === JSON.stringify({ enabled: false, version: 3 }))
await page.locator('[data-field="name"]').fill('门店周报 · 每周(新)')
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^PATCH /).length === 2)
await saveDone(page)
check('之后保存表单用开关之后的版本(4),不把启用状态改回去', JSON.stringify(writes(sent, /^PATCH /)[1]?.body) === JSON.stringify({
name: '门店周报 · 每周(新)', version: 4 }), JSON.stringify(writes(sent, /^PATCH /)[1]?.body))
// 别处改过
state.schedules = state.schedules.map((s) => (s.id === 's-weekly' ? { ...s, version: 9, name: '别处改过的名字' } : s))
await page.locator('[data-field="name"]').fill('又改一次')
await page.locator('[data-schedule-save]').click()
const conflict = await until(async () => (await page.locator('[data-schedule-conflict]').innerText().catch(() => '')) || null)
check('版本不符(409):明确说已在别处修改,并给重新载入', !!conflict && conflict.includes('别处被修改') && conflict.includes('当前是第 9 版')
&& await page.locator('[data-schedule-reload]').count() === 1, conflict ?? '')
check('……没改页面上填的内容', await page.locator('[data-field="name"]').inputValue() === '又改一次')
await page.locator('[data-schedule-reload]').click()
const dialog = page.locator('[role="dialog"]')
await dialog.waitFor()
await dialog.getByRole('button', { name: '放弃修改并重新载入' }).click()
check('重新载入后换成服务端的设置', await until(async () => (await page.locator('[data-field="name"]').inputValue()) === '别处改过的名字'))
check('……冲突提示收起', await page.locator('[data-schedule-conflict]').count() === 0)
// 立即运行:结果、触发历史跟着刷新
await page.locator('header [data-schedule-run]').click()
check('页头立即运行发 POST 并说结果', await until(async () => (await page.locator('[data-toast]').allInnerTexts()).some((x) => x.includes('已发起运行'))))
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('详情:保存被拒后的字段说明', async () => {
const { page, errors, state } = await open('/schedules/s-weekly')
await page.locator('[data-schedule-form]').waitFor()
const reject = (field, detail, when = () => true) => {
state.override = (method, path, body) => (method === 'PATCH' && path === '/schedules/s-weekly' && when(body)
? { status: 422, json: { detail, field } } : null)
}
const name = page.locator('[data-field="name"]')
const save = page.locator('[data-schedule-save]')
reject('name', '名称不能是单个字母', (b) => b?.name === 'X')
await name.fill('X')
await save.click()
const said = await until(async () => (await page.locator('#schedule-name-error').innerText().catch(() => '')) || null)
check('保存被拒、指明名称:名称下面写服务端的说明', !!said && said.includes('名称不能是单个字母'), said ?? '')
check('……名称框标成出错、说明接到 aria-describedby', await name.getAttribute('aria-invalid') === 'true'
&& await name.getAttribute('aria-describedby') === 'schedule-name-error',
`${await name.getAttribute('aria-invalid')} | ${await name.getAttribute('aria-describedby')}`)
check('……底部写保存失败', await page.locator('[data-schedule-save-error]').count() === 1)
await shot(page, 'detail-name-rejected')
await name.fill('XY')
check('改了名称:名称下的说明和底部的保存失败都收起', await until(async () => await page.locator('#schedule-name-error').count() === 0
&& await page.locator('[data-schedule-save-error]').count() === 0))
check('……名称框不再标成出错', await name.getAttribute('aria-invalid') === null)
await name.fill('X')
check('改回被拒的那个值:说明又出来(服务端说的就是这个值)', await until(async () => await page.locator('#schedule-name-error').count() === 1))
// 运行类别、补发宽限被拒:说明写在各自下面
await name.fill('门店周报 · 每周')
reject('run_class', '运行类别需要选择正式运行或探索运行')
await page.locator('[data-run-class="exploratory"]').click()
await save.click()
const rc = await until(async () => (await page.locator('[data-run-class-error]').innerText().catch(() => '')) || null)
check('运行类别被拒:说明写在运行类别下面', !!rc && rc.includes('运行类别需要选择'), rc ?? '')
await page.locator('[data-run-class="formal"]').click()
check('……改了运行类别,说明收起', await until(async () => await page.locator('[data-run-class-error]').count() === 0))
reject('misfire_grace_s', '补发宽限需要是 0 到 604800 之间的整数秒数(最长 7 天)')
await page.locator('[data-misfire]').selectOption('86400')
await save.click()
const ms = await until(async () => (await page.locator('#schedule-misfire-error').innerText().catch(() => '')) || null)
check('补发宽限被拒:说明写在补发宽限下面,选择框标成出错', !!ms && ms.includes('补发宽限')
&& await page.locator('[data-misfire]').getAttribute('aria-invalid') === 'true', ms ?? '')
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('详情:保存途中又改了别的', async () => {
const { page, sent, errors, state } = await open('/schedules/s-weekly')
await page.locator('[data-schedule-form]').waitFor()
// PATCH 晚 1.2 秒才回,期间改名字
state.override = async (method, path) => {
if (method === 'PATCH' && path === '/schedules/s-weekly') await sleep(1200)
return null
}
const SECRET_URL = 'https://open.feishu.cn/open-apis/bot/v2/hook/secret-token-zz99'
await page.locator('[data-webhook-replace]').click()
await page.locator('[data-webhook-url]').fill(SECRET_URL)
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^PATCH /).length === 1)
await page.locator('[data-field="name"]').fill('途中改名')
await until(async () => !(await page.locator('[data-schedule-save]').innerText()).includes('正在保存'), 5000)
await sleep(200)
const values = await page.locator('[data-schedules-page] input').evaluateAll((els) => els.map((e) => e.value))
check('保存后地址只显示 url_hint,输入框里不留含令牌的完整地址', (await text(page, '[data-webhook-url-hint]').catch(() => '')).includes('…zz99')
&& !values.some((v) => v.includes('secret-token')), values.filter((v) => v.includes('http')).join(' | '))
check('途中改的名字还在、仍算未保存', await page.locator('[data-field="name"]').inputValue() === '途中改名'
&& !(await page.locator('[data-schedule-save]').isDisabled()))
check('……Webhook 这一块不算未保存:可以发测试消息', !(await page.locator('[data-webhook-test]').isDisabled()))
state.override = null
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^PATCH /).length === 2)
await saveDone(page)
const p2 = writes(sent, /^PATCH /)[1]?.body
check('下一次保存只交名字,不再带地址', JSON.stringify(p2) === JSON.stringify({ name: '途中改名', version: 4 }), JSON.stringify(p2))
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('详情:晚到的刷新与先开触发历史', async () => {
const state = freshState()
const { page, sent, errors } = await open('/schedules/s-weekly', { state })
await page.locator('[data-schedule-form]').waitFor()
const toggle = page.locator('header [data-schedule-toggle]')
state.override = slowGet(state, '/schedules/s-weekly', (st) => st.schedules.find((s) => s.id === 's-weekly'))
const gets = () => sent.filter((s) => s.key === 'GET /schedules/s-weekly').length
const before = gets()
// 立即运行之后刷新一次:读到开关之前的快照,晚 1.5 秒才回
await page.locator('header [data-schedule-run]').click()
await until(() => gets() > before)
await until(async () => !(await page.locator('header [data-schedule-run]').isDisabled()))
await toggle.click()
check('页头开关先变成关', await until(async () => (await toggle.getAttribute('aria-checked')) === 'false'))
await sleep(1900)
check('晚到的刷新不把页头开关盖回去', await toggle.getAttribute('aria-checked') === 'false', `aria-checked=${await toggle.getAttribute('aria-checked')}`)
state.override = null
await toggle.click()
await until(() => writes(sent, /^PATCH /).length === 2)
const p = writes(sent, /^PATCH /).map((x) => x.body)
check('再点页头开关带开关之后的版本(4)', JSON.stringify(p[1]) === JSON.stringify({ enabled: true, version: 4 }), JSON.stringify(p))
check('没有页面报错', !errors.length, errors.join(' | '))
// 先开触发历史,期间别处改过、页头刷新过;再切到设置:表单是刷新后的内容,保存就该带这一版
const st2 = freshState()
const h = await open('/schedules/s-weekly?tab=history', { state: st2 })
await h.page.locator('[data-fire-id]').first().waitFor()
st2.schedules = st2.schedules.map((s) => (s.id === 's-weekly' ? { ...s, version: 9, name: '别处改过的名字' } : s))
await h.page.locator('header [data-schedule-run]').click()
await until(async () => (await h.page.locator('[data-schedules-page] header').innerText()).includes('别处改过的名字'))
await h.page.locator('[role=tab]', { hasText: '设置' }).click()
await h.page.locator('[data-schedule-form]').waitFor()
check('切到设置:表单是刷新后的内容', await h.page.locator('[data-field="name"]').inputValue() === '别处改过的名字')
await h.page.locator('[data-misfire]').selectOption('86400')
await h.page.locator('[data-schedule-save]').click()
await until(() => writes(h.sent, /^PATCH /).length === 1)
await saveDone(h.page)
const hp = writes(h.sent, /^PATCH /)[0]?.body
check('保存带的是表单所基于的那一版(9),不撞假的 409', JSON.stringify(hp) === JSON.stringify({ misfire_grace_s: 86400, version: 9 }), JSON.stringify(hp))
check('……没有冲突提示', await h.page.locator('[data-schedule-conflict]').count() === 0)
check('没有页面报错', !h.errors.length, h.errors.join(' | '))
})
await section('详情:重新载入碰上原地刷新,照样换成服务端的设置', async () => {
const state = freshState()
const { page, sent, errors } = await open('/schedules/s-weekly', { state })
await page.locator('[data-schedule-form]').waitFor()
state.schedules = state.schedules.map((s) => (s.id === 's-weekly' ? { ...s, version: 9, name: '别处改过的名字' } : s))
await page.locator('[data-field="name"]').fill('又改一次')
await page.locator('[data-schedule-save]').click()
await page.locator('[data-schedule-reload]').waitFor()
// 读晚 1.5 秒才回:「重新载入」的读还在路上,立即运行之后的原地刷新又发出去一次
state.override = slowGet(state, '/schedules/s-weekly', (st) => st.schedules.find((s) => s.id === 's-weekly'))
const gets = () => sent.filter((s) => s.key === 'GET /schedules/s-weekly').length
const before = gets()
await page.locator('[data-schedule-reload]').click()
await page.locator('[role="dialog"]').getByRole('button', { name: '放弃修改并重新载入' }).click()
await until(() => gets() > before)
await page.locator('header [data-schedule-run]').click()
check('重新载入的读在路上时又原地刷新了一次', await until(() => gets() >= before + 2), `${gets() - before} 次`)
check('……重新载入不被刷新作废:表单换成服务端的设置、冲突提示收起', await until(async () =>
(await page.locator('[data-field="name"]').inputValue()) === '别处改过的名字'
&& await page.locator('[data-schedule-conflict]').count() === 0, 5000),
await page.locator('[data-field="name"]').inputValue())
state.override = null
await page.locator('[data-misfire]').selectOption('86400')
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^PATCH /).length === 2)
await saveDone(page)
const p = writes(sent, /^PATCH /)[1]?.body
check('……再保存带重新载入的那一版(9),不再撞 409', JSON.stringify(p) === JSON.stringify({ misfire_grace_s: 86400, version: 9 }),
JSON.stringify(p))
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('详情:运行结束后页头的「上次触发」跟着变', async () => {
// 上次触发发起的运行还在跑:页头和触发历史第一条是同一次触发(服务端同一个口径)
const running = (st) => {
const f = { ...st.fires[0], run_status: 'running', compare: null }
st.fires = [f, ...st.fires.slice(1)]
st.schedules = st.schedules.map((x) => (x.id === 's-weekly' ? { ...x, last_fire: structuredClone(f) } : x))
}
const finish = (st) => {
st.fires = [{ ...st.fires[0], run_status: 'succeeded' }, ...st.fires.slice(1)]
st.schedules = st.schedules.map((x) => (x.id === 's-weekly' ? { ...x, last_fire: structuredClone(st.fires[0]) } : x))
}
const head = (page) => page.locator('[data-schedule-summary] [data-status]').first().getAttribute('data-status').catch(() => null)
const row = (page) => page.locator('[data-fire-id="f00"] [data-status]').first().getAttribute('data-status').catch(() => null)
// 点触发历史的「刷新」:页头一起变,不等下一次原地刷新
const st = freshState()
running(st)
const a = await open('/schedules/s-weekly?tab=history', { state: st })
await a.page.locator('[data-fire-id="f00"]').waitFor()
check('开始时页头和触发历史都是「运行中」', await until(async () => await head(a.page) === 'running' && await row(a.page) === 'running'),
`${await head(a.page)} | ${await row(a.page)}`)
finish(st)
await a.page.locator('[data-fire-refresh]').click()
check('点触发历史的刷新:页头的上次触发跟着变成已完成', await until(async () => await head(a.page) === 'succeeded', 3000),
`${await head(a.page)}`)
check('……触发历史那一行也是已完成', await row(a.page) === 'succeeded', `${await row(a.page)}`)
check('没有页面报错', !a.errors.length, a.errors.join(' | '))
// 什么都不点:运行还在跑时页头原地刷新得勤,运行一结束页头和触发历史都跟着变
const st2 = freshState()
running(st2)
const b = await open('/schedules/s-weekly?tab=history', { state: st2 })
await b.page.locator('[data-fire-id="f00"]').waitFor()
await until(async () => await head(b.page) === 'running')
const fires = () => b.sent.filter((x) => x.key === 'GET /schedules/s-weekly/fires').length
const firesBefore = fires()
finish(st2)
check('运行结束:不点刷新,几秒内页头的上次触发变成已完成', await until(async () => await head(b.page) === 'succeeded', 8000, 250),
`${await head(b.page)}`)
check('……触发历史那一行原地换成已完成,不从头重取(翻过的页不丢)', await until(async () => await row(b.page) === 'succeeded', 2000)
&& fires() === firesBefore, `${await row(b.page)} | 多取了 ${fires() - firesBefore} 次`)
check('没有页面报错', !b.errors.length, b.errors.join(' | '))
})
await section('详情:触发历史分页', async () => {
const { page, sent, errors } = await open('/schedules/s-weekly?tab=history')
await page.locator('[data-fire-id]').first().waitFor()
check('第一页 20 条', await page.locator('[data-fire-id]').count() === 20)
const first = sent.find((s) => s.key === 'GET /schedules/s-weekly/fires')
check('请求带 limit', !!first && new URLSearchParams(first.search).get('limit') === '20', first?.search ?? '')
const row = (id) => page.locator(`[data-fire-id="${id}"]`)
const t0 = await row('f00').innerText()
check('手动触发写「手动」、通知结果逐条写', t0.includes('手动') && t0.includes('运行结束') && t0.includes('已发送') && t0.includes('发送失败'),
t0.replace(/\s+/g, ' '))
check('发起了的链到运行', await row('f00').locator('a[href="/runs/run-f00"]').count() === 1)
const t1 = await row('f01').innerText()
check('上次未结束已跳过:写原因,没有运行链接', t1.includes('上次未结束已跳过') && t1.includes('#9f8e7d') && await row('f01').locator('a[href^="/runs/"]').count() === 0,
t1.replace(/\s+/g, ' '))
check('补发写「补发」,运行状态照实写', (await row('f02').innerText()).includes('补发') && (await row('f02').innerText()).includes('失败'))
// 一行里给人看的字:正文连同各处悬停。错过、没有补发的原因只能是本条记的那一种,页面不能另替它认定一种
const said = async (id) => [await row(id).innerText(),
...await row(id).locator('[title]').evaluateAll((els) => els.map((e) => e.getAttribute('title')))].join(' | ').replace(/\s+/g, ' ')
for (const [id, label, own, other] of [['f03', '宽限内积压的', '积压的触发不补', '超出补发宽限'], ['f05', '超出宽限的', '超出补发宽限', '积压']]) {
const k = row(id).locator('[data-fire-kind-chip]')
const kt = (await k.getAttribute('title')) ?? ''
const t = await row(id).innerText()
check(`错过已跳过(${label}):写本条的原因`, t.includes('错过已跳过') && t.includes(own), t.replace(/\s+/g, ' '))
check(`错过已跳过(${label}):类型写「定时」,悬停说没有补发、指向本条的原因,不说补发了一次`, (await k.innerText()).trim() === '定时'
&& !kt.includes('补发的一次') && kt.includes('没有补发') && kt.includes('原因见本条记录'), `${await k.innerText()} | ${kt}`)
const all = await said(id)
check(`错过已跳过(${label}):整行连同悬停都不说另一种原因(${other})`, !all.includes(other), all)
}
const s6 = await said('f06')
check('错过已跳过、没有记原因:不写原因行,悬停不替它认定原因、不指向不存在的原因', s6.includes('错过已跳过')
&& !s6.includes('原因:') && !s6.includes('超出补发宽限') && !s6.includes('积压') && !s6.includes('原因见'), s6)
const st6 = (await row('f06').locator('[data-fire-status-chip]').getAttribute('title')) ?? ''
check('……结局的悬停写这种结局的一般说明', st6.includes('错过') && st6.includes('没有发起'), st6)
const s7 = await said('f07')
const st7 = (await row('f07').locator('[data-fire-status-chip]').getAttribute('title')) ?? ''
check('发起失败、没有记原因:悬停说清没有原因,不指向不存在的原因', s7.includes('发起失败') && !s7.includes('原因:')
&& !s7.includes('原因见') && st7.includes('未能发起运行') && st7.includes('未提供原因'), s7)
check('发起失败写原因', (await row('f04').innerText()).includes('发起失败') && (await row('f04').innerText()).includes('还没有发布'))
const when = row('f05').locator('time')
check('计划时间悬停看时区', ((await when.getAttribute('title')) ?? '').includes('Asia/Shanghai'), (await when.getAttribute('title')) ?? '')
const shownAt = await page.locator('[data-fire-id] time').allInnerTexts()
const wantAt = makeFires().slice(0, 20).map((f) => zoned(Date.parse(f.fire_at), 'Asia/Shanghai'))
check(`计划时间按定时任务的时区(Asia/Shanghai,不是本机的 ${LOCAL_TZ})写:20 行逐条对得上、都是周一 09:00`,
shownAt.length === 20 && shownAt.every((t, i) => t === wantAt[i]) && wantAt.every((w) => w.endsWith('周一 09:00')),
shownAt.map((t, i) => (t === wantAt[i] ? null : `${t} ≠ ${wantAt[i]}`)).filter(Boolean).slice(0, 3).join(';'))
await page.locator('[data-fire-more]').click()
await until(async () => (await page.locator('[data-fire-id]').count()) === 25)
const more = sent.filter((s) => s.key === 'GET /schedules/s-weekly/fires').at(-1)
check('加载更多:带上一页给的 before', new URLSearchParams(more?.search ?? '').get('before') === makeFires()[19].fire_at, more?.search ?? '')
check('……接在后面,共 25 条、写已全部加载', await page.locator('[data-fire-id]').count() === 25
&& await page.locator('[data-fire-more]').count() === 0 && (await text(page, '[data-fire-list]')).includes('已全部加载'))
check('没有重复的行', new Set(await page.locator('[data-fire-id]').evaluateAll((els) => els.map((e) => e.getAttribute('data-fire-id')))).size === 25)
await page.locator('[role=tab]', { hasText: '设置' }).click()
check('切回设置:地址去掉 tab', await until(() => !new URL(page.url()).searchParams.has('tab')))
const real = writes(sent, /./).filter((s) => s.key !== 'POST /schedules/preview')
check('只读:没有写请求', real.length === 0, real.map((s) => s.key).join('、'))
check('直接打开触发历史:不先把表单的预览请求一遍', !sent.slice(0, sent.findIndex((x) => x.key === 'GET /schedules/s-weekly/fires') + 1)
.some((x) => x.key === 'POST /schedules/preview'))
check('没有页面报错', !errors.length, errors.join(' | '))
// 老服务端给的游标带 +00:00:页面照样编码成 %2B 传回去,第二页接得上、不重复
const legacy = await open('/schedules/s-weekly?tab=history', { state: freshState({ legacyCursor: true }) })
await legacy.page.locator('[data-fire-id]').first().waitFor()
await legacy.page.locator('[data-fire-more]').click()
await until(async () => (await legacy.page.locator('[data-fire-id]').count()) === 25)
const old = legacy.sent.filter((s) => s.key === 'GET /schedules/s-weekly/fires').at(-1)
const want = makeFires()[19].created_at.replace(/Z$/, '+00:00')
check('老服务端的 +00:00 游标:编码后原样传回(「+」写成 %2B)', (old?.search ?? '').includes('%2B')
&& new URLSearchParams(old?.search ?? '').get('before') === want, old?.search ?? '')
check('……接在后面,共 25 条、没有重复的行', new Set(await legacy.page.locator('[data-fire-id]')
.evaluateAll((els) => els.map((e) => e.getAttribute('data-fire-id')))).size === 25)
check('……没有页面报错', !legacy.errors.length, legacy.errors.join(' | '))
const none = freshState({ fires: [] })
const e = await open('/schedules/s-stock?tab=history', { state: none })
await e.page.locator('[data-fire-empty]').waitFor()
check('没有触发记录:说清会记什么', (await text(e.page, '[data-fire-empty]')).includes('还没有触发记录'))
const gone = await open('/schedules/s-nope')
await gone.page.locator('[data-schedule-missing]').waitFor()
check('不存在的定时任务:说清并给返回列表', (await text(gone.page, '[data-schedule-missing]')).includes('不存在')
&& await gone.page.locator('[data-schedule-missing] a[href="/schedules"]').count() === 1)
})
await section('详情:触发历史里的「与上一次对比」', async () => {
const { page, sent, errors } = await open('/schedules/s-weekly?tab=history')
await page.locator('[data-fire-id]').first().waitFor()
const row = (id) => page.locator(`[data-fire-id="${id}"]`)
const link = row('f00').locator('[data-fire-compare]')
const label = await link.locator('[data-fire-compare-label]').innerText().catch(() => '没有链接')
check('有对比的那次:给「与上一次对比」(和运行详情「更多」菜单同一个叫法)', (await link.count()) === 1
&& label.trim() === '与上一次对比', label)
check('……链到对比页,本次是这次的运行、基准是服务端比过的那一次',
await link.getAttribute('href') === '/runs/compare?target=run-f00&base=run-f02', (await link.getAttribute('href')) ?? '')
const count = (await link.locator('[data-fire-compare-count]').innerText().catch(() => '')).trim()
check('……旁边看得见有几个指标有变化(触屏、键盘看不到悬停)', count === '6 个指标有变化', count)
const title = (await link.getAttribute('title')) ?? ''
check('……悬停写各类指标的个数:三类总写,其余有才写,没有数值', title === COMPARE_F00_TITLE, title)
const f08 = row('f08').locator('[data-fire-compare]')
check('两边都没有指标:悬停不写一串 0 个,旁边也不写计数', ((await f08.getAttribute('title')) ?? '')
=== '与上一次相比:两次运行都没有指标' && await f08.locator('[data-fire-compare-count]').count() === 0)
const f10 = row('f10').locator('[data-fire-compare]')
const t10 = (await f10.getAttribute('title').catch(() => '')) ?? ''
check('上一次失败了、基准是再往前成功的那次:悬停写明是哪一次', t10.startsWith('与上一次成功的运行(#run-f1)相比:只数据变 1 个'), t10)
// 触屏没有悬停、键盘聚焦也不显示 title:比的不是上一次这件事得写在看得见的字里
const l10 = f10.locator('[data-fire-compare-label]')
check('……链接上看得见的字写「与上一次成功的运行对比」,不只靠悬停', (await l10.innerText().catch(() => '')).trim() === '与上一次成功的运行对比'
&& await l10.isVisible(), (await l10.innerText().catch(() => '没有')).trim())
check('……紧挨着的上一次那一行照旧写「与上一次对比」', (await row('f00').locator('[data-fire-compare-label]').innerText()).trim() === '与上一次对比')
const linked = await page.locator('[data-fire-compare]').evaluateAll((els) => els.map((e) => e.closest('[data-fire-id]')?.getAttribute('data-fire-id')))
check('没有对比的(compare 为 null、老服务端不给、没有运行)不给链接', JSON.stringify(linked) === '["f00","f08","f10"]', JSON.stringify(linked))
check('运行链接照旧在', await row('f00').locator('a[href="/runs/run-f00"]').count() === 1)
const presses = await tabTo(page, '[data-fire-id="f00"] [data-fire-compare]')
check('键盘:Tab 到得了「与上一次对比」', presses > 0, `按了 ${presses} 次`)
await shot(page, 'history-compare-focus-dark')
await page.keyboard.press('Enter')
const url = await until(() => {
const u = new URL(page.url())
return u.pathname === '/runs/compare' ? u : null
})
check('……回车打开对比页,带着本次和基准', !!url && url.searchParams.get('target') === 'run-f00'
&& url.searchParams.get('base') === 'run-f02', page.url())
// 来历跟着这一次跳转走(路由的 state),对比页据此写「默认基准:同一定时任务上一次成功的运行」
const via = await page.evaluate(() => history.state?.usr ?? null)
check('……紧挨着的上一次:带上「同一定时任务」的来历', via?.reason === 'same_schedule' && via?.base === 'run-f02', JSON.stringify(via))
// 基准不是紧挨着的上一次:来历照样带上。对比页写的是「上一次成功的运行」,比的正是这一次,和从运行详情进去一字不差
const second = await open('/schedules/s-weekly?tab=history')
await second.page.locator('[data-fire-id="f10"] [data-fire-compare]').focus()
const focusedLabel = await second.page.evaluate(() => document.activeElement?.querySelector('[data-fire-compare-label]')?.textContent?.trim() ?? '')
check('……键盘聚焦在那一行的链接上时,看得见的字就是「与上一次成功的运行对比」', focusedLabel === '与上一次成功的运行对比', focusedLabel)
await second.page.keyboard.press('Enter')
await until(() => new URL(second.page.url()).pathname === '/runs/compare')
const via10 = await second.page.evaluate(() => history.state?.usr ?? null)
const reason10 = await until(async () => (await second.page.locator('[data-compare-reason]').innerText().catch(() => '')).trim())
check('……基准不是紧挨着的上一次:带上来历,对比页写「同一定时任务上一次成功的运行」', new URL(second.page.url()).searchParams.get('base') === 'run-f12'
&& via10?.reason === 'same_schedule' && via10?.base === 'run-f12' && reason10 === REASON_SCHEDULE, `${JSON.stringify(via10)} ${reason10}`)
const real = [...writes(sent, /./), ...writes(second.sent, /./)].filter((x) => x.key !== 'POST /schedules/preview')
check('只读:没有写请求', real.length === 0, real.map((x) => x.key).join('、'))
check('没有页面报错', ![...errors, ...second.errors].length, [...errors, ...second.errors].join(' | '))
})
await section('详情:触发历史和运行详情的「与上一次对比」拿同一个基准', async () => {
// f10 的上一次(f11)失败了,服务端两处都挑再往前成功的 f12:自动对比和运行详情的默认基准用的是同一个函数
// (api/compare.py 的 pick_base),基准事后变了的旧对比触发历史不再给,这些由 backend/tests/test_scheduler_compare.py
// 守。这里守前端:两个入口都原样用服务端给的基准,打开同一对运行、写同一句来历
const runOf = (id, at) => ({ ...RUNS[0], id, started_by: '定时任务:门店周报 · 每周', created_at: iso(at), started_at: iso(at),
finished_at: iso(at + 4200), manifest_hash: 'ab'.repeat(32), manifest_seq: 2 })
const f10 = runOf('run-f10', nextMonday9(-11))
const f12 = runOf('run-f12', nextMonday9(-13))
const ref = (r) => ({ id: r.id, workflow_name: r.workflow_name, version: r.version, run_class: r.run_class,
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
// 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: [] } } }
}
return null
}
const stateOf = () => ({ ...freshState({ runs: [...RUNS, f10, f12] }), override: compareApi })
const landed = (page) => until(() => {
const u = new URL(page.url())
return u.pathname === '/runs/compare' ? u : null
})
const reasonOf = (page) => until(async () => (await page.locator('[data-compare-reason]').innerText().catch(() => '')).trim())
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()
// 菜单项等预先问到默认基准才可点,叫法照它的 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]')
await link.waitFor()
const label = (await link.locator('[data-fire-compare-label]').innerText()).trim()
await link.click()
const b = await landed(hist.page)
check('运行详情「更多」和触发历史两个入口:打开同一对运行(本次 run-f10,基准是跳过失败那次的 run-f12)',
!!a && !!b && a.searchParams.get('target') === 'run-f10' && b.searchParams.get('target') === 'run-f10'
&& a.searchParams.get('base') === 'run-f12' && b.searchParams.get('base') === 'run-f12', `${a?.search} | ${b?.search}`)
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)]
check('……两边的变化摘要一致', !!ta && ta === tb, `${ta} | ${tb}`)
const real = [...writes(menu.sent, /./), ...writes(hist.sent, /./)]
check('只读:没有写请求', real.length === 0, real.map((x) => x.key).join('、'))
check('没有页面报错', ![...menu.errors, ...hist.errors].length, [...menu.errors, ...hist.errors].join(' | '))
})
// ---------------------------------------------------------------- 运行详情
/**
* 运行详情页头元信息那一行:定时任务的链接(或删除后的纯文本)可见的宽度够不够写下图标加「由定时任务」五个字(fits),
* 「N 条事件」是不是还在一行里(eventsFlat,以前被挤成一字一行),页头有没有横向溢出,链接在不在视口里,署名在不在
*/
const headRow = (page) => page.evaluate(() => {
const head = document.querySelector('[data-run-detail] header')
const link = head?.querySelector('[data-run-schedule-link], [data-run-schedule-gone]')
if (!head || !link) return null
const font = parseFloat(getComputedStyle(link).fontSize)
const icon = link.querySelector('svg')?.getBoundingClientRect().width ?? 0
const l = link.getBoundingClientRect()
const events = [...head.querySelectorAll('span')].find((el) => /^[\d,]+ 条事件$/.test(el.textContent.trim()))
const eh = events?.getBoundingClientRect().height ?? 0
return { width: Math.round(l.width), need: Math.round(icon + 4 + font * 5), fits: l.width >= icon + 4 + font * 5 - 0.5,
eventsFlat: !!events && eh > 0 && eh < font * 2.2, scroll: head.scrollWidth - head.clientWidth,
inView: l.left >= 0 && l.right <= innerWidth + 0.5 && l.width > 0, starter: head.innerText.includes('检查员甲 发起') }
})
await section('运行详情:页头的定时任务链接', async () => {
const writesSeenExtra = []
const errorsExtra = []
const state = freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] })
const { page, sent, errors } = await open('/runs/run-sched-1', { state })
await page.locator('[data-run-detail="run-sched-1"]').waitFor({ timeout: 10000 })
const head = page.locator('[data-run-detail] header').first()
const link = head.locator('[data-run-schedule-link]')
// 署名里记的是旧名字:取到定时任务列表之后写现在的名字
const said = await until(async () => {
const t = (await link.innerText().catch(() => '')).trim()
return t === '由定时任务「门店周报 · 每周」发起' ? t : null
})
check('页头写「由定时任务「X」发起」,X 是定时任务现在的名字', !!said, (await link.innerText().catch(() => '没有链接')).trim())
check('……是到那个定时任务的真链接', (await link.evaluate((el) => el.tagName)) === 'A'
&& await link.getAttribute('href') === '/schedules/s-weekly', (await link.getAttribute('href')) ?? '')
const headText = await head.innerText()
check('……不再另写「定时任务:X 发起」', !headText.includes('定时任务:') && !headText.includes('旧名'), oneLine([headText]).slice(0, 160))
check('……悬停写全名和点开去哪', ((await link.getAttribute('title')) ?? '').includes('门店周报 · 每周'))
check('……前面带和「定时」标记一样的日历图标,平时看得出能点', await link.locator('svg').count() === 1)
const presses = await tabTo(page, '[data-run-detail] header [data-run-schedule-link]')
check('键盘:Tab 到得了页头的定时任务链接', presses > 0, `按了 ${presses} 次`)
const ring = await page.evaluate(() => getComputedStyle(document.activeElement).boxShadow)
check('……聚焦时有焦点环', !!ring && ring !== 'none', ring)
const darkTone = await readable(page, '[data-run-detail] header [data-run-schedule-link]')
check('深色:页头的定时任务链接对比度 ≥ 4.5', !!darkTone && darkTone.ratio >= 4.5, JSON.stringify(darkTone))
await shot(page, 'run-detail-schedule-link-dark')
await page.keyboard.press('Enter')
check('……回车去那个定时任务', await until(() => new URL(page.url()).pathname === '/schedules/s-weekly'), page.url())
await page.locator('[data-schedule-form], [data-schedules-page] header').first().waitFor()
const manual = await open('/runs/run-sched-2', { state: freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] }) })
await manual.page.locator('[data-run-detail="run-sched-2"]').waitFor({ timeout: 10000 })
const mHead = manual.page.locator('[data-run-detail] header').first()
const mLink = mHead.locator('[data-run-schedule-link]')
await until(async () => (await mLink.innerText().catch(() => '')).includes('门店周报 · 每周'))
const mText = oneLine([await mHead.innerText()])
check('有人署名点了立即运行:署名照写,后面接「来自定时任务「X」」的链接', mText.includes('检查员甲 发起')
&& (await mLink.innerText()).trim() === '来自定时任务「门店周报 · 每周」' && await mLink.getAttribute('href') === '/schedules/s-weekly',
mText.slice(0, 160))
await shot(manual.page, 'run-detail-manual-fire-dark')
const light = await open('/runs/run-sched-1', { state: freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] }), theme: 'light' })
await light.page.locator('[data-run-detail="run-sched-1"] header [data-run-schedule-link]').waitFor()
const lightTheme = await light.page.evaluate(() => document.documentElement.dataset.theme)
const lightTone = await readable(light.page, '[data-run-detail] header [data-run-schedule-link]')
check('浅色:页头的定时任务链接对比度 ≥ 4.5', lightTheme === 'light' && !!lightTone && lightTone.ratio >= 4.5,
`${lightTheme} ${JSON.stringify(lightTone)}`)
await shot(light.page, 'run-detail-schedule-link-light')
const plain = await open('/runs/run-manual-1', { state: freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] }) })
await plain.page.locator('[data-run-detail="run-manual-1"]').waitFor({ timeout: 10000 })
check('手动发起的运行:页头没有定时任务链接', await plain.page.locator('[data-run-detail] header [data-run-schedule-link]').count() === 0)
// 名字很长、详情区不宽时链接截断,不把页头撑宽(全名在悬停里)
const longName = freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] })
longName.schedules = longName.schedules.map((x) => (x.id === 's-weekly' ? { ...x, name: '华东区全部门店按品类与渠道拆分的周度销售与退款复盘' } : x))
const narrow = await open('/runs/run-sched-1', { state: longName, width: 900 })
await narrow.page.locator('[data-run-detail="run-sched-1"]').waitFor({ timeout: 10000 })
const nLink = narrow.page.locator('[data-run-detail] header [data-run-schedule-link]')
await until(async () => (await nLink.innerText().catch(() => '')).includes('复盘'))
const fit = await narrow.page.evaluate(() => {
const head = document.querySelector('[data-run-detail] header')
const link = head?.querySelector('[data-run-schedule-link]')
const label = link?.querySelector('span')
if (!head || !link || !label) return null
const h = head.getBoundingClientRect()
const l = link.getBoundingClientRect()
return { headRight: Math.round(h.right), linkRight: Math.round(l.right), scroll: head.scrollWidth - head.clientWidth,
clipped: label.scrollWidth > label.clientWidth }
})
check('长名字:链接截断,不超出页头、页头不横向溢出', !!fit && fit.linkRight <= fit.headRight && fit.scroll <= 1 && fit.clipped,
JSON.stringify(fit))
// 署名的立即运行(署名加链接,这一行最挤)配长名字:链接照样截断,署名不被挤掉
const crowdedState = freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] })
crowdedState.schedules = longName.schedules
const crowded = await open('/runs/run-sched-2', { state: crowdedState, width: 900 })
await crowded.page.locator('[data-run-detail="run-sched-2"]').waitFor({ timeout: 10000 })
const cLink = crowded.page.locator('[data-run-detail] header [data-run-schedule-link]')
await until(async () => (await cLink.innerText().catch(() => '')).includes('复盘'))
const crowd = await headRow(crowded.page)
check('……署名的立即运行配长名字:署名照写,链接截断、看得到,页头不横向溢出', !!crowd && crowd.starter
&& crowd.fits && crowd.scroll <= 1 && crowd.inView, JSON.stringify(crowd))
// 分屏常见的窄窗口:探索运行的页头多一个「提取为草稿」,这一行最挤。以前这一行不换行、只有链接能缩,820 宽时链接
// 只剩「由…」,768 宽时 0 宽,Tab 停在一个看不见的链接上,「2 条事件」被压成竖排
const tight = []
for (const [width, run, extract] of [[820, RUN_EXPLORE, true], [820, RUN_EXPLORE_MANUAL, true],
[768, RUN_EXPLORE, true], [768, RUN_EXPLORE_MANUAL, true], [768, RUN_MANUAL_FIRE, false]]) {
const one = await open(`/runs/${run.id}`, { state: freshState({ runs: [...RUNS, RUN_MANUAL_FIRE, RUN_EXPLORE, RUN_EXPLORE_MANUAL] }), width })
await one.page.locator(`[data-run-detail="${run.id}"] header [data-run-schedule-link]`).waitFor({ timeout: 10000 })
await until(async () => (await one.page.locator('[data-run-detail] header [data-run-schedule-link]').innerText()).includes('门店周报 · 每周'))
const got = await headRow(one.page)
const hasExtract = await one.page.locator('[data-run-detail] header [data-action="extract"]').count() === 1
const manual = run.started_by === '检查员甲'
const ok = !!got && got.fits && got.eventsFlat && got.scroll <= 1 && got.inView && hasExtract === extract
&& (!manual || got.starter)
tight.push(ok)
check(`${width} 宽、${run.run_class === 'exploratory' ? '探索' : '正式'}运行(${manual ? '署名点了立即运行' : '到点发起'}):链接至少写得下「由定时任务」,「条事件」不被压成竖排`,
ok, JSON.stringify({ ...got, hasExtract }))
const presses = await tabTo(one.page, '[data-run-detail] header [data-run-schedule-link]')
if (width === 768 && manual && extract) {
check('……最挤的那种 Tab 到链接时看得见(有宽度、在视口里)', presses > 0 && !!got && got.width > 0 && got.inView, `按了 ${presses} 次`)
await shot(one.page, 'run-detail-schedule-link-768')
}
writesSeenExtra.push(...writes(one.sent, /./))
errorsExtra.push(...one.errors)
}
// 定时任务已经删了:取回的列表里没有它,不再链过去(只会落到「不存在」页),写成纯文本,和「工作流已删除」一样
const goneState = freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] })
goneState.schedules = goneState.schedules.filter((x) => x.id !== 's-weekly')
const gone = await open('/runs/run-sched-1', { state: goneState })
await gone.page.locator('[data-run-detail="run-sched-1"]').waitFor({ timeout: 10000 })
const goneEl = gone.page.locator('[data-run-detail] header [data-run-schedule-gone]')
await goneEl.waitFor({ timeout: 6000 }).catch(() => {})
check('定时任务已删除:写「由定时任务「X」发起(已删除)」,不是链接', (await goneEl.count()) === 1
&& (await goneEl.innerText()).trim() === '由定时任务「门店周报(旧名)」发起(已删除)'
&& await gone.page.locator('[data-run-detail] header [data-run-schedule-link]').count() === 0
&& (await goneEl.evaluate((el) => el.tagName)) !== 'A', (await goneEl.innerText().catch(() => '没有')).trim())
check('……悬停说已删除、运行记录仍然保留', ((await goneEl.getAttribute('title')) ?? '').includes('已删除'))
const goneManual = await open('/runs/run-sched-2', { state: goneState })
await goneManual.page.locator('[data-run-detail="run-sched-2"]').waitFor({ timeout: 10000 })
const gmEl = goneManual.page.locator('[data-run-detail] header [data-run-schedule-gone]')
await gmEl.waitFor({ timeout: 6000 }).catch(() => {})
check('……署名点了立即运行的:署名照写,后面是「来自已删除的定时任务」', oneLine([await goneManual.page.locator('[data-run-detail] header').first().innerText()]).includes('检查员甲 发起')
&& (await gmEl.innerText().catch(() => '')).trim() === '来自已删除的定时任务', (await gmEl.innerText().catch(() => '没有')).trim())
// 列表取不到(老服务端、暂时连不上)不算删除:照样是链接,用署名里记的名字
const downState = freshState({ runs: [...RUNS, RUN_MANUAL_FIRE] })
downState.override = (method, path) => (method === 'GET' && path === '/schedules' ? { status: 503, json: { detail: '暂时不可用' } } : null)
const down = await open('/runs/run-sched-1', { state: downState })
const downLink = down.page.locator('[data-run-detail="run-sched-1"] header [data-run-schedule-link]')
await downLink.waitFor({ timeout: 10000 })
await sleep(300)
check('……定时任务列表取不到时不算删除,照样是链接', await down.page.locator('[data-run-detail] header [data-run-schedule-gone]').count() === 0
&& (await downLink.innerText()).trim() === '由定时任务「门店周报(旧名)」发起')
writesSeenExtra.push(...writes(gone.sent, /./), ...writes(goneManual.sent, /./), ...writes(down.sent, /./))
errorsExtra.push(...gone.errors, ...goneManual.errors, ...down.errors)
// 回车打开的定时任务页会请求预览(POST,但只读),不算写
const writesSeen = [...writes(sent, /./), ...writes(manual.sent, /./), ...writes(light.sent, /./), ...writes(plain.sent, /./),
...writes(narrow.sent, /./), ...writes(crowded.sent, /./), ...writesSeenExtra]
.filter((x) => x.key !== 'POST /schedules/preview')
check('只读:没有写请求', writesSeen.length === 0, writesSeen.map((x) => x.key).join('、'))
const all = [...errors, ...manual.errors, ...light.errors, ...plain.errors, ...narrow.errors, ...crowded.errors, ...errorsExtra]
check('没有页面报错', !all.length, all.join(' | '))
})
// ---------------------------------------------------------------- 记录页
await section('记录页:定时标记', async () => {
const { page, errors } = await open('/runs', { state: freshState({ runs: RUNS }) })
await page.locator('[data-runs-list] [data-run-id="run-sched-1"]').waitFor({ timeout: 10000 })
const tag = page.locator('[data-run-id="run-sched-1"] [data-run-schedule]')
check('定时任务发起的运行名字旁标「定时」', (await tag.innerText()).includes('定时'))
check('手动发起的不标', await page.locator('[data-run-id="run-manual-1"] [data-run-schedule]').count() === 0)
check('悬停写是哪个定时任务', await until(async () => ((await tag.getAttribute('title')) ?? '').includes('门店周报 · 每周')),
(await tag.getAttribute('title')) ?? '')
// 整行是打开运行的按钮,按钮里放不了可聚焦的链接:至少让读屏在这一行的名字里听到来源
const rowName = page.getByRole('button', { name: /由定时任务「门店周报 · 每周」发起/ })
check('读屏:整行按钮的名字里带上是哪个定时任务发起的', await until(async () => (await rowName.count()) === 1)
&& (await rowName.getAttribute('data-run-id')) === 'run-sched-1')
await tag.click()
check('点标记去那个定时任务(不打开运行)', await until(() => new URL(page.url()).pathname === '/schedules/s-weekly'), page.url())
check('没有页面报错', !errors.length, errors.join(' | '))
})
// ---------------------------------------------------------------- 数据事件触发(orchestration-round §4)
await section('事件触发:新建的请求体', async () => {
const { page, sent, errors } = await open('/schedules/new')
await page.locator('[data-schedule-form]').waitFor()
const picker = page.locator('[data-workflow-picker] input')
await picker.click()
await picker.fill('周报')
await page.locator('[data-workflow-option="wf-weekly"]').click()
await page.locator('[data-field="name"]').fill('门店月报 · 新一期')
check('新建时有「定时 / 事件触发」两种,缺省定时', await page.locator('[data-trigger-mode]').count() === 2
&& await page.locator('[data-trigger-mode="cron"]').getAttribute('aria-checked') === 'true'
&& await page.locator('[data-freq-preset]').count() > 0)
await page.locator('[data-trigger-mode="event"]').click()
await page.locator('[data-trigger-section]').waitFor()
check('选了事件触发:频率那一节换成事件,三种事件、缺省上传表格', await page.locator('[data-freq-preset]').count() === 0
&& await page.locator('[data-trigger-event]').count() === 3
&& await page.locator('[data-trigger-event="table.version"]').getAttribute('aria-checked') === 'true')
const subject = page.locator('[data-trigger-subject="table.version"]')
await subject.waitFor()
const roster = subject.locator('option[value="ds-roster"]')
check('不能选的对象照服务端的原因标出来、选不了', await roster.isDisabled()
&& (await roster.innerText()).includes('没有「新的一期」'), await roster.innerText())
check('还没选对象:保存按钮禁用,说还需处理触发事件', await page.locator('[data-schedule-save]').isDisabled()
&& (await text(page, '[data-schedule-hint]')).includes('触发事件'), await text(page, '[data-schedule-hint]').catch(() => ''))
await subject.selectOption('ds-sales')
// 换事件种类:对象清掉,换成上游工作流的对象和过滤
await page.locator('[data-trigger-event="workflow.finished"]').click()
await page.locator('[data-trigger-subject="workflow.finished"]').waitFor()
check('换成上游工作流:对象清掉,多出只认应用、只认来源和触发链的说明',
await page.locator('[data-trigger-subject="workflow.finished"]').inputValue() === ''
&& await page.locator('[data-trigger-app]').count() === 1 && await page.locator('[data-trigger-origin]').count() === 1
&& (await text(page, '[data-trigger-chain]')).includes('最多 5 层'), await text(page, '[data-trigger-chain]').catch(() => ''))
await page.locator('[data-trigger-event="table.version"]').click()
await page.locator('[data-trigger-subject="table.version"]').selectOption('ds-sales')
await page.locator('[data-trigger-period]').selectOption('new')
await page.locator('[data-trigger-debounce]').selectOption('300')
await page.locator('[data-timezone]').selectOption('Asia/Shanghai')
check('事件触发时「错过触发时间」说的是派发晚了', (await text(page, '[data-schedule-form]')).includes('事件在宽限内派发照常发起'))
// 变量按事件种类取:event.period 在、上一次计划触发时间不在
await page.locator('[data-input-field="date"]').waitFor()
await page.locator('[data-input-vars="date"]').click()
await page.locator('[data-input-field="date"] [data-var="event.period"]').waitFor()
check('变量清单按事件种类取:有 event.period', await page.locator('[data-input-field="date"] [data-var="event.table"]').count() === 1)
check('……请求带 trigger_kind=event、event=table.version', sent.some((x) => x.key === 'GET /schedules/variables'
&& x.search.includes('trigger_kind=event') && x.search.includes('event=table.version')), sent.filter((x) => x.key.includes('variables'))
.map((x) => x.search).join(' | '))
await page.locator('[data-input-field="date"] [data-var="event.period"]').click()
check('点变量插入 {{ event.period }}', await page.locator('[data-input-field="date"] input').inputValue() === '{{ event.period }}')
const preview = await until(async () => (await page.locator('[data-schedule-preview][data-status="ok"][data-preview-mode="event"]').count())
&& (await page.locator('[data-preview-input="date"]').innerText().catch(() => '')).includes('2026-09'))
check('预览按一次事件渲染输入,不列触发时间', !!preview && await page.locator('[data-preview-time]').count() === 0
&& (await text(page, '[data-preview-event="real"]')).includes(SALES_EVENT)
&& (await text(page, '[data-preview-desc]')).includes('只认新增的一期'), await text(page, '[data-schedule-preview]').catch(() => ''))
const pv = writes(sent, /^POST \/schedules\/preview$/).at(-1)?.body
check('预览的请求体:trigger_kind、触发条件、cron 空串', pv?.trigger_kind === 'event' && pv?.cron === '' && pv?.trigger?.source_id === 'ds-sales'
&& !('n' in (pv ?? {})), JSON.stringify(pv))
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^POST \/schedules$/).length === 1)
const body = writes(sent, /^POST \/schedules$/)[0]?.body
check('POST 请求体:trigger_kind=event、触发条件、cron 空串', body?.trigger_kind === 'event' && body?.cron === ''
&& JSON.stringify(body?.trigger) === JSON.stringify({ event: 'table.version', debounce_s: 300, source_id: 'ds-sales', period: 'new' }),
JSON.stringify(body))
check('POST 请求体:工作流、名称、时区、输入模板', body?.workflow_id === 'wf-weekly' && body?.name === '门店月报 · 新一期'
&& body?.timezone === 'Asia/Shanghai' && JSON.stringify(body?.input_template) === '{"date":"{{ event.period }}"}', JSON.stringify(body))
check('创建后进详情页', await until(() => new URL(page.url()).pathname === '/schedules/s-new-event'))
await page.locator('[data-trigger-mode-fixed="event"]').waitFor()
check('详情:触发方式只读,写明建好之后不能改', await page.locator('[data-trigger-modes]').count() === 0
&& (await text(page, '[data-trigger-mode-fixed]')).includes('建好之后不能改'))
await shot(page, 'event-new-dark')
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('事件触发:上游工作流的请求体(途经驳回、应用补算)', async () => {
const { page, sent, errors } = await open('/schedules/new')
await page.locator('[data-schedule-form]').waitFor()
await page.locator('[data-workflow-picker] input').click()
await page.locator('[data-workflow-option="wf-weekly"]').click()
await page.locator('[data-trigger-mode="event"]').click()
await page.locator('[data-trigger-event="workflow.finished"]').click()
await page.locator('[data-trigger-subject="workflow.finished"]').selectOption('wf-daily')
const rej = page.locator('[data-trigger-include-rejected]')
const back = page.locator('[data-trigger-include-backfill]')
check('上游工作流:有「也认途经驳回的」「也认应用补算的」,缺省都不勾', await rej.count() === 1 && await back.count() === 1
&& !(await rej.isChecked()) && !(await back.isChecked()))
check('……说明照实写不勾时这类运行不触发', (await text(page, '[data-trigger-section]')).includes('不勾时这类运行不触发'))
await page.locator('[data-trigger-origin]').selectOption('app_backfill')
check('……只认应用补算时「也认应用补算的」不再出现', await back.count() === 0)
await page.locator('[data-trigger-origin]').selectOption('')
await rej.check()
await page.locator('[data-input-field="date"] input').fill('{{ event.rejected }}')
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^POST \/schedules$/).length === 1)
const body = writes(sent, /^POST \/schedules$/)[0]?.body
check('POST 请求体:勾了的「也认途经驳回的」交 true,没勾的不交', JSON.stringify(body?.trigger) === JSON.stringify({
event: 'workflow.finished', debounce_s: 60, workflow_id: 'wf-daily', include_rejected: true }), JSON.stringify(body?.trigger))
check('没有页面报错', !errors.length, errors.join(' | '))
})
await section('事件触发:列表、详情与触发历史', async () => {
const state = freshState({ schedules: [structuredClone(S1), structuredClone(S_EVENT), structuredClone(S_EVENT_OFF)], fires: eventFires() })
const list = await open('/schedules', { state })
await list.page.locator('[data-schedule-row="s-event"]').waitFor()
const row = list.page.locator('[data-schedule-row="s-event"]')
check('列表:事件触发的标「事件触发」,cron 的不标', await row.locator('[data-schedule-trigger-kind="event"]').count() === 1
&& await list.page.locator('[data-schedule-row="s-weekly"] [data-schedule-trigger-kind="event"]').count() === 0)
check('列表:频率那里写触发说明', (await row.locator('[data-schedule-desc]').innerText()).includes(SALES_TRIGGER))
const wait = await row.locator('[data-schedule-event-wait="waiting"]').innerText()
check('列表:去抖中写收到几次、什么时候合成一次触发', wait.includes('已收到 2 次事件') && wait.includes('合成一次触发'), wait)
const off = await list.page.locator('[data-schedule-row="s-event-off"] [data-schedule-event-wait="pending"]').innerText()
check('列表:调度没开时照实说事件在等、不会派发', off.includes('3 条事件') && off.includes('调度未开启'), off)
check('列表:上次触发链到那次运行', await row.locator('[data-schedule-last] a[href="/runs/run-fe1"]').count() === 1)
const { page, sent, errors } = await open('/schedules/s-event', { state })
await page.locator('[data-trigger-section]').waitFor()
check('详情:页头副标题是触发说明', (await page.locator('[data-schedules-page] header').innerText()).includes(SALES_TRIGGER))
check('详情:读出来的对象、期次、去抖', await page.locator('[data-trigger-subject="table.version"]').inputValue() === 'ds-sales'
&& await page.locator('[data-trigger-period]').inputValue() === 'new' && await page.locator('[data-trigger-debounce]').inputValue() === '300')
check('详情:没有频率、只读的触发方式', await page.locator('[data-freq-preset]').count() === 0
&& await page.locator('[data-trigger-mode-fixed="event"]').count() === 1)
await page.locator('[data-trigger-debounce]').selectOption('900')
await page.locator('[data-schedule-save]').click()
await until(() => writes(sent, /^PATCH \/schedules\/s-event$/).length === 1)
const patch = writes(sent, /^PATCH \/schedules\/s-event$/)[0]?.body
check('只改去抖:PATCH 只带触发条件和版本,不带 cron', JSON.stringify(patch) === JSON.stringify({
trigger: { event: 'table.version', debounce_s: 900, source_id: 'ds-sales', period: 'new' }, version: 2 }), JSON.stringify(patch))
await saveDone(page)
await page.locator('#schedule-tab-history').click()
await page.locator('[data-fire-id="fe1"]').waitFor()
const fe1 = await text(page, '[data-fire-id="fe1"] [data-fire-event]')
check('触发历史:写「由事件触发:…」和合并了几次', fe1.includes(`由事件触发:${SALES_EVENT}`) && fe1.includes('合并了 2 次事件'), fe1)
check('……类型标「事件」', (await text(page, '[data-fire-id="fe1"] [data-fire-kind-chip]')).includes('事件'))
const fe2 = await text(page, '[data-fire-id="fe2"] [data-fire-event]')
check('……立即运行按示例值、没发起的照实写:写「按事件」,不说成由事件触发', fe2.includes(`按事件:${SALES_EVENT}`)
&& fe2.includes('立即运行,事件为示例值') && !fe2.includes('由事件触发')
&& (await text(page, '[data-fire-id="fe2"]')).includes('还没有发生过这种事件'), fe2)
check('……上一次没结束跳过的写第几层', (await text(page, '[data-fire-id="fe3"] [data-fire-event]')).includes('触发链第 2 层'))
const fe4 = await text(page, '[data-fire-id="fe4"] [data-fire-event]')
check('……过了宽限合成一条跳过的:写合并了几次、几个窗口、合掉的期次', fe4.includes('合并了 3 次事件') && fe4.includes('3 个去抖窗口')
&& fe4.includes('期次 2026-07、2026-08、2026-09'), fe4)
await shot(page, 'event-history-dark')
// 390px 窄屏、浅色:事件那一节(三种事件的按钮、对象下拉里带原因的长名字)不撑出横向滚动
const narrow = await open('/schedules/s-event', { state, width: 390, height: 844, theme: 'light' })
await narrow.page.locator('[data-trigger-section]').waitFor()
await narrow.page.locator('[data-trigger-event="workflow.finished"]').click()
await narrow.page.locator('[data-trigger-app]').waitFor()
await sleep(300)
check('390px 浅色:事件那一节不横向溢出,对象、去抖、保存在屏幕里', await overflow(narrow.page) <= 1
&& await inViewX(narrow.page, '[data-trigger-subject]') && await inViewX(narrow.page, '[data-trigger-debounce]')
&& await inViewX(narrow.page, '[data-schedule-save]'), `${await overflow(narrow.page)}px`)
await shot(narrow.page, 'event-390-light')
const all = [...errors, ...list.errors, ...narrow.errors]
check('没有页面报错', !all.length, all.join(' | '))
})
await section('事件触发:运行详情页头写「由事件触发」', async () => {
const state = freshState({ schedules: [structuredClone(S1), structuredClone(S_EVENT)], runs: [...RUNS, RUN_EVENT, RUN_SAMPLE] })
const { page, errors } = await open('/runs/run-event-1', { state })
await page.locator('[data-run-detail="run-event-1"]').waitFor({ timeout: 10000 })
const link = page.locator('[data-run-detail] header [data-run-schedule-link]')
const said = await until(async () => {
const t = (await link.innerText().catch(() => '')).trim()
return t.includes('门店月报 · 新一期') ? t : null
})
check('页头写「由事件触发:<事件>(定时任务「X」)」,是到那个定时任务的链接',
said === `由事件触发:${SALES_EVENT}(定时任务「门店月报 · 新一期」)` && await link.getAttribute('href') === '/schedules/s-event'
&& await link.getAttribute('data-run-schedule-event') === '', said ?? (await link.innerText().catch(() => '没有链接')))
const manual = await open('/runs/run-event-2', { state })
await manual.page.locator('[data-run-detail="run-event-2"]').waitFor({ timeout: 10000 })
const mHead = manual.page.locator('[data-run-detail] header').first()
await until(async () => (await mHead.locator('[data-run-schedule-link]').innerText().catch(() => '')).includes('门店月报 · 新一期'))
const mText = oneLine([await mHead.innerText()])
check('立即运行按最近一次事件发起的:署名照写,写「立即运行…按事件」,不说成由事件触发', mText.includes('检查员甲 发起')
&& mText.includes(`立即运行定时任务「门店月报 · 新一期」,按事件:${SALES_EVENT}`) && !mText.includes('由事件触发'), mText.slice(0, 200))
check('没有页面报错', ![...errors, ...manual.errors].length, [...errors, ...manual.errors].join(' | '))
})
// ---------------------------------------------------------------- 窄屏、浅色
await section('窄屏 390px 与浅色', async () => {
// 每一步另开一页:新建表单填了一半时 page.goto 会被离开前确认(beforeunload)拦下
const narrow = { width: 390, height: 844 }
for (const theme of ['dark', 'light']) {
const list = await open('/schedules', { ...narrow, theme })
await list.page.locator('[data-schedule-row]').first().waitFor()
// 外壳按设置换主题、开关的颜色过渡都要一点时间
await sleep(400)
check(`${theme}:列表不横向溢出`, await overflow(list.page) <= 1, `${await overflow(list.page)}px`)
check(`${theme}:每行的立即运行、删除在屏幕里`, await inViewX(list.page, '[data-schedule-row="s-weekly"] [data-schedule-run]')
&& await inViewX(list.page, '[data-schedule-row="s-weekly"] [data-schedule-delete]'))
const colors = await list.page.evaluate(() => {
const row = document.querySelector('[data-schedule-row]')
const track = document.querySelector('[data-schedule-toggle][aria-checked="true"] [data-switch-track]')
const probe = document.createElement('div')
probe.style.background = 'var(--accent-solid)'
document.body.appendChild(probe)
const accent = getComputedStyle(probe).backgroundColor
probe.remove()
return {
theme: document.documentElement.dataset.theme, fg: getComputedStyle(row).color, bg: getComputedStyle(row).backgroundColor,
track: track && getComputedStyle(track).backgroundColor, accent,
}
})
check(`${theme}:页面是${theme === 'dark' ? '深色' : '浅色'},开着的开关是强调色,行里字和底色分得开`,
colors.theme === theme && colors.track === colors.accent && colors.fg !== colors.bg, JSON.stringify(colors))
await shot(list.page, `list-390-${theme}`)
const form = await open('/schedules/new', { ...narrow, theme })
await form.page.locator('[data-schedule-form]').waitFor()
await fillWeekly(form.page)
await form.page.locator('[data-webhook-on]').check()
await previewSays(form.page, '每周一 09:00')
check(`${theme}:新建表单不横向溢出`, await overflow(form.page) <= 1, `${await overflow(form.page)}px`)
check(`${theme}:保存按钮、预览、Webhook 地址在屏幕里`, await inViewX(form.page, '[data-schedule-save]')
&& await inViewX(form.page, '[data-schedule-preview]') && await inViewX(form.page, '[data-webhook-url]'))
const order = await form.page.evaluate(() => {
const y = (s) => document.querySelector(s)?.getBoundingClientRect().top ?? -1
return { freq: y('[data-freq-preset]'), preview: y('[data-schedule-preview]'), inputs: y('[data-input-field]') }
})
check(`${theme}:窄屏上预览排在频率下面、输入上面`, order.freq < order.preview && order.preview < order.inputs, JSON.stringify(order))
await shot(form.page, `new-390-${theme}`)
// 换成没发布的库存日报、选正式运行:运行类别下面的说明、必填说明在窄屏上也排得开
await form.page.locator('[data-workflow-picker] input').click()
await form.page.locator('[data-workflow-option="wf-stock"]').click()
await form.page.locator('[data-run-class="formal"]').click()
await form.page.locator('[data-run-class-problem]').waitFor()
check(`${theme}:运行类别的问题说明不横向溢出、在屏幕里`, await overflow(form.page) <= 1
&& await inViewX(form.page, '[data-run-class-problem]'), `${await overflow(form.page)}px`)
const tone = await form.page.evaluate(() => {
const el = document.querySelector('[data-run-class-problem]')
return { fg: getComputedStyle(el).color, bg: getComputedStyle(document.querySelector('[data-schedule-form] section')).backgroundColor }
})
check(`${theme}:问题说明的字和底色分得开`, tone.fg !== tone.bg, JSON.stringify(tone))
await form.page.locator('[data-run-class-problem]').scrollIntoViewIfNeeded()
await shot(form.page, `new-390-problem-${theme}`)
const hist = await open('/schedules/s-weekly?tab=history', { ...narrow, theme })
await hist.page.locator('[data-fire-id]').first().waitFor()
check(`${theme}:触发历史不横向溢出`, await overflow(hist.page) <= 1, `${await overflow(hist.page)}px`)
check(`${theme}:「与上一次对比」和旁边的计数在屏幕里`, await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare]')
&& await inViewX(hist.page, '[data-fire-id="f00"] [data-fire-compare-count]'))
check(`${theme}:更长的「与上一次成功的运行对比」连同计数也在屏幕里`, await inViewX(hist.page, '[data-fire-id="f10"] [data-fire-compare]')
&& await inViewX(hist.page, '[data-fire-id="f10"] [data-fire-compare-count]'))
const cmp = await readable(hist.page, '[data-fire-id="f00"] [data-fire-compare]')
check(`${theme}:「与上一次对比」对比度 ≥ 4.5`, !!cmp && cmp.ratio >= 4.5, JSON.stringify(cmp))
const cnt = await readable(hist.page, '[data-fire-id="f00"] [data-fire-compare-count]')
check(`${theme}:旁边的计数对比度 ≥ 4.5`, !!cnt && cnt.ratio >= 4.5, JSON.stringify(cnt))
check(`${theme}:页头的开关、立即运行、删除在屏幕里`, await inViewX(hist.page, 'header [data-schedule-run]')
&& await inViewX(hist.page, 'header [data-schedule-toggle]') && await inViewX(hist.page, 'header [data-schedule-delete]'))
await shot(hist.page, `history-390-${theme}`)
const errors = [...list.errors, ...form.errors, ...hist.errors]
check(`${theme}:没有页面报错`, !errors.length, errors.join(' | '))
}
})
await browser.close()
console.log(failed ? `\n✗ 定时任务:${failed} 项未通过` : '\n✓ 定时任务:全部通过')
process.exit(failed ? 1 : 0)