mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:41:58 +00:00
check-schedules 换成真检查
真页面走真的应用外壳,接口全部在浏览器层答掉:定时任务、触发历史、预览、变量、工作流列表是虚构的门店周报、库存日报,写请求照接口约定改这里的假状态(PATCH 核对版本、Webhook 地址省略即不改、空串即删除);设置读真的、只换 notify 一组和主题,别的写请求一律拦成 503,不碰真实写接口。 查的是: - 纯函数(借 vite 转译 model.ts):预设生成的 cron、cron 落回预设、按时区写时间和偏移(含夏令时)、距下次运行的取整、修改时只交改了的字段、Webhook 地址和密钥的省略与空串 - 列表:名字、工作流、频率说法、下次运行(相对加按时区的绝对时间、悬停看时区)、上次触发链到运行、暂停和跳过照实写;空态、老服务端 - 启用开关发 PATCH 且带版本、再点带新版本、409 说原话并重新载入、键盘空格切换 - 立即运行发 POST 并说结果(发起、跳过、被拒)、「查看运行」直达;删除先站内确认 - 新建「每周一 09:00」:工作流可搜索、默认勾周一、正式运行字段取已发布版本、点变量插入、预览 5 次触发和渲染结果、预览防抖,POST 的 cron、时区、输入模板、Webhook 逐项核对;创建后地址只显示 url_hint、页面和输入框里都没有完整地址 - 非法 cron、不认识的时区和变量:说明写在出错那一项下、标 aria-invalid、清掉旧的预览、不能保存;离开前确认 - 详情:只改事件时不带地址、替换地址、删除 Webhook、发送测试消息、运行链接地址写 notify 组;页头开关之后表单用新版本、别处改过时 409 给重新载入 - 触发历史分页(before 游标、去重、已全部加载)、各种类型和结局、原因和通知结果;直接打开触发历史不先请求表单的预览 - 记录页的定时标记:只标定时发起的、悬停写定时任务名、点了去定时任务不打开运行 - 390px 深浅色:列表、新建表单、触发历史都不横向溢出,按钮在屏幕里,窄屏预览排在频率下面 check-all 里 check-schedules 的耗时按实测改成 40 秒。
This commit is contained in:
@@ -96,7 +96,7 @@ const ESTIMATE = {
|
||||
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
|
||||
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
|
||||
// 占位,换成真检查时按实测改
|
||||
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
|
||||
schedules: 40, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 33,
|
||||
}
|
||||
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
|
||||
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
|
||||
|
||||
+982
-6
@@ -1,8 +1,984 @@
|
||||
// 定时任务的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。
|
||||
// 定时任务的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行
|
||||
// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。
|
||||
// 定时任务(/schedules、/schedules/new、/schedules/:id,记录页里运行名旁的「定时」标记)的回归检查。
|
||||
//
|
||||
// 守的是这些「看着没坏、其实不能用」的地方:预设生成的 cron 不对(选了每周一 09:00,交上去的是每天)、时区按本机写
|
||||
// 而不是按定时任务的时区、预览不防抖、非法 cron 只弹一句不知道指哪一项、启用开关不带版本号(别人改过也照样覆盖)、
|
||||
// 立即运行之后没有下文、触发历史只有一页、保存过的 Webhook 地址又被原样显示出来、改了别的字段却把地址清掉、
|
||||
// 记录页看不出哪些运行来自定时任务、390px 窄屏被表单撑出横向滚动。
|
||||
//
|
||||
// 页面是真页面(/schedules 走真的应用外壳),接口全部在浏览器层答掉:定时任务、触发历史、预览、变量、工作流列表
|
||||
// 都是这里的假数据(虚构的门店周报、库存日报),写请求照约定改这里的假状态;设置接口读真的、只把 notify 一组换成
|
||||
// 假的,写请求不放行。别的 GET 放行到服务端(外壳要它),别的写请求一律拦成 503,不碰真实写接口。
|
||||
// 另有一节纯函数(pages/schedules/model.ts:预设 ⇄ cron、按时区写时间、表单 ⇄ 请求体),借 vite 转译后直接调用。
|
||||
//
|
||||
// node scripts/check-schedules.mjs
|
||||
console.log('=== 定时任务 ===')
|
||||
console.log(' ✓ 占位:功能开发中')
|
||||
console.log('\n✓ 定时任务:占位通过')
|
||||
// 对别的实例跑时带上地址: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: '' }) === '请填写时间')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 夹具:虚构的门店周报、库存日报
|
||||
|
||||
const NOW = Date.now()
|
||||
const iso = (ms) => new Date(ms).toISOString()
|
||||
const H = 3600e3
|
||||
/** 下一个北京时间周一 09:00(UTC 01:00)之后再往后 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 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: '华东' }],
|
||||
}
|
||||
const VARIABLES = [
|
||||
{ name: 'schedule.yesterday', label: '昨天(YYYY-MM-DD)', example: '2026-10-04' },
|
||||
{ name: 'schedule.today', label: '今天(YYYY-MM-DD)', example: '2026-10-05' },
|
||||
{ name: 'schedule.last_week_start', label: '上周一(YYYY-MM-DD)', example: '2026-09-28' },
|
||||
]
|
||||
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 条一页给。几种类型、结局、通知结果各放一条 */
|
||||
function makeFires() {
|
||||
const out = []
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const at = iso(NOW - (i + 1) * 7 * 24 * H)
|
||||
let f = fire(`f${String(i).padStart(2, '0')}`, { fire_at: at, created_at: at })
|
||||
if (i === 0) f = { ...f, kind: 'manual', 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' }
|
||||
if (i === 3) f = { ...f, status: 'skipped_misfire', run_id: null, run_status: null, reason: '服务停机,错过触发时间 3 小时,超出补发宽限 1 小时' }
|
||||
if (i === 4) f = { ...f, status: 'failed_to_start', run_id: null, run_status: null, reason: '工作流「门店周报」还没有发布:正式运行需要已发布的版本' }
|
||||
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,
|
||||
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) },
|
||||
]
|
||||
|
||||
function freshState(patch = {}) {
|
||||
return {
|
||||
schedules: [structuredClone(S1), structuredClone(S2)],
|
||||
fires: makeFires(),
|
||||
notify: { public_base_url: null },
|
||||
runs: null,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
const shanghaiDay = (ms) => new Date(ms + 8 * H).toISOString().slice(0, 10)
|
||||
|
||||
/** 预览:几种认得出的写法按北京时间排出 5 次,非法的回 422 和出错的那一项(照约定的形状) */
|
||||
function previewReply(body) {
|
||||
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 weekly = body?.cron === '0 9 * * 1'
|
||||
const next = Array.from({ length: body?.n ?? 5 }, (_, k) => iso(weekly ? nextMonday9(k) : NOW + (k + 1) * 24 * H))
|
||||
const yesterday = shanghaiDay(new Date(next[0]).getTime() - 24 * H)
|
||||
const inputs = Object.fromEntries(Object.entries(tpl).map(([k, v]) => [k, String(v).replace(/\{\{\s*schedule\.yesterday\s*\}\}/g, yesterday)]))
|
||||
return { json: { description_text: weekly ? '每周一 09:00(Asia/Shanghai)' : '每天 09:00(Asia/Shanghai)', next, inputs } }
|
||||
}
|
||||
|
||||
/** 照约定改假状态: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']) {
|
||||
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) 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(() => ({}))
|
||||
return { ...realSettings, ui: { ...(realSettings.ui ?? {}), theme: state.theme ?? 'dark' }, notify: state.notify }
|
||||
}
|
||||
|
||||
/** 一个请求怎么答。返回 {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') return previewReply(body)
|
||||
if (method === 'GET' && path === '/schedules/variables') return { json: VARIABLES }
|
||||
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: '定时任务已被修改(版本不符),请刷新后重试' } }
|
||||
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') {
|
||||
const limit = Number(url.searchParams.get('limit') ?? 20)
|
||||
const before = url.searchParams.get('before')
|
||||
const start = before ? state.fires.findIndex((f) => f.fire_at === before) + 1 : 0
|
||||
const items = state.fires.slice(start, start + limit)
|
||||
const more = start + limit < state.fires.length
|
||||
return { json: { items, next_before: more ? items.at(-1).fire_at : null } }
|
||||
}
|
||||
if (method === 'POST' && rest === '/webhook/test') return { json: { ok: true, http_status: 200, message: '飞书机器人已接收' } }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 页面
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME })
|
||||
|
||||
/** 开一页:接口按 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: 'Asia/Shanghai' })
|
||||
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 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))
|
||||
/** 元素整个在视口的横向范围里 */
|
||||
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)
|
||||
|
||||
// ---------------------------------------------------------------- 列表
|
||||
|
||||
await section('列表:渲染', async () => {
|
||||
const { page, 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 = (() => {
|
||||
const d = new Date(S1.next_run_at)
|
||||
const p = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
|
||||
.formatToParts(d).reduce((o, x) => ({ ...o, [x.type]: x.value }), {})
|
||||
return `${p.month}/${p.day}`
|
||||
})()
|
||||
check('下次运行:相对时间 + 按定时任务时区的绝对时间', nextText.includes('3 小时后') && nextText.includes(wantAbs), nextText)
|
||||
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('尚未结束'))
|
||||
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('新建定时任务'))
|
||||
check('只读:没有写请求', true)
|
||||
await shot(page, 'list-dark')
|
||||
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 toastText = await until(async () => {
|
||||
const t = await page.locator('[data-toast]').allInnerTexts()
|
||||
return t.find((x) => x.includes('版本不符')) ?? null
|
||||
})
|
||||
check('版本不符(409):说清原因并重新载入', !!toastText, toastText ?? (await page.locator('[data-toast]').allInnerTexts()).join(' | '))
|
||||
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(' | '))
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 新建
|
||||
|
||||
/** 在新建页上填好「门店周报,每周一 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('时区默认取本机(Asia/Shanghai)', await page.locator('[data-timezone]').inputValue() === 'Asia/Shanghai')
|
||||
await fillWeekly(page)
|
||||
// 正式运行:字段取已发布的 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()
|
||||
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')
|
||||
const times = await page.locator('[data-preview-time]').allInnerTexts()
|
||||
check('预览接下来 5 次触发', times.length === 5 && times.every((t) => /周一 09:00/.test(t)), times.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')
|
||||
check('换时区之后预览按新时区写', await until(async () => (await text(page, '[data-schedule-preview]')).includes('America/New_York')))
|
||||
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('详情: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())
|
||||
await page.locator('[data-webhook-test]').click()
|
||||
const result = await until(async () => {
|
||||
const t = await page.locator('[data-webhook-test-result]').innerText().catch(() => '')
|
||||
return t.includes('飞书机器人已接收') ? t : null
|
||||
})
|
||||
check('发送测试消息并显示结果', writes(sent, /^POST \/schedules\/s-weekly\/webhook\/test$/).length === 1 && !!result && result.includes('200'), result ?? '')
|
||||
// 只改事件: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 组
|
||||
check('写出运行链接地址还没设置', (await text(page, '[data-link-base]')).includes('未设置'))
|
||||
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 () => {
|
||||
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('版本不符')
|
||||
&& 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, 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('失败'))
|
||||
check('错过已跳过写原因', (await row('f03').innerText()).includes('错过已跳过') && (await row('f03').innerText()).includes('超出补发宽限'))
|
||||
check('发起失败写原因', (await row('f04').innerText()).includes('发起失败') && (await row('f04').innerText()).includes('还没有发布'))
|
||||
const when = row('f05').locator('time')
|
||||
check('计划时间按定时任务的时区写,悬停看时区', /周一 09:00|周\S \d{2}:\d{2}/.test(await when.innerText())
|
||||
&& ((await when.getAttribute('title')) ?? '').includes('Asia/Shanghai'), `${await when.innerText()} | ${await when.getAttribute('title')}`)
|
||||
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(' | '))
|
||||
|
||||
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, 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')) ?? '')
|
||||
await tag.click()
|
||||
check('点标记去那个定时任务(不打开运行)', await until(() => new URL(page.url()).pathname === '/schedules/s-weekly'), page.url())
|
||||
check('没有页面报错', !errors.length, 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}`)
|
||||
|
||||
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, '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)
|
||||
|
||||
Reference in New Issue
Block a user