@mzzsfy/dsh-cron-board 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/api.mjs +4 -2
- package/src/client.js +117 -21
- package/src/cron.mjs +45 -19
- package/src/index.js +55 -8
- package/src/schedule-builder.mjs +48 -5
- package/test/cron.test.mjs +98 -0
- package/test/executor.test.mjs +22 -19
- package/test/index.test.mjs +215 -3
- package/test/routes.test.mjs +32 -4
- package/test/schedule-builder.test.mjs +49 -1
- package/test/switch-guard.test.mjs +5 -0
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
- **环境变量**:集中管理,值打码展示,多值(同名展开笛卡尔积,组合数超出 20 截断并告警),dotenv 文本导入(预览/追加/覆盖)与导出
|
|
11
11
|
- **会话任务**:投递即终态(crontab 语义,执行状态归会话本身);fresh 每次新建会话;pinned 固定会话(忙时跳过,会话被删除或归档自动重建并回写,首跑自动绑定);按 workdir 自动挂载宿主分组,界面分组内可见;执行预设可选(Agent Preset,缺省跟随宿主默认,dsh-im 同构)
|
|
12
12
|
- **调度**:croner 解析,停机期间错过不补跑(misfire 即跳过),孤儿运行恢复,全局与任务级并发闸门;shell 任务超时收尾(会话任务无超时语义);单次运行开关(runOnce,任意一次真实运行入队后自动停用任务)
|
|
13
|
+
- **时区**:cron 表达式支持内联时区后缀 `T±N`(整数小时,如 `0 9 * * *T+8` 表示东八区 9 点,范围 -12 至 +14,`T+0` 即 UTC);构建器提供时区行写入后缀,新建任务默认固化设备时区偏移;无后缀表达式按宿主进程系统时区调度(crontab 惯例),摘要与运行记录按任务所用时区展示;固定偏移不随夏令时变化,半时区设备的设备偏移就近取整
|
|
13
14
|
- **日志**:每任务每运行独立日志文件,面板查看与清空,按任务保留条数裁剪(按文件修改时间)
|
|
14
15
|
|
|
15
16
|
## 路由
|
|
@@ -51,3 +52,7 @@ node --test "test/*.test.mjs" # 测试
|
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
注意:插件新增进入 profile bundles(`dsh plugin add`)后需 dsh 重启一次完成首装载;此后工作副本改动经 dev-link HMR 热重载。
|
|
55
|
+
|
|
56
|
+
## dsh 版本兼容
|
|
57
|
+
|
|
58
|
+
三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)兼容通过:cron 看板渲染与编辑器、侧栏入口、激活 live。0.1.6 新侧栏标签页系统下联动开关双态均验证通过;0.1.7-rc.1 修复 ui-settings 写失败打崩宿主(await 化 + 路由级 catch 降级),内置插件页改版后设置开关卡锚缺失(宿主演进),看板面实测正常。
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -281,6 +281,8 @@ export function createApi({ store, logger, executor, scheduler, periodic, sessio
|
|
|
281
281
|
session: sessionState
|
|
282
282
|
? { ready: Boolean(sessionState.ready), disabled: Boolean(sessionState.disabled) }
|
|
283
283
|
: { ready: false, disabled: false },
|
|
284
|
+
// 宿主进程时区偏移小时数:无后缀任务的展示时区(调度与展示同源,与浏览器无关)
|
|
285
|
+
serverTzOffset: -new Date().getTimezoneOffset() / 60,
|
|
284
286
|
ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false },
|
|
285
287
|
})
|
|
286
288
|
},
|
|
@@ -295,8 +297,8 @@ export function createApi({ store, logger, executor, scheduler, periodic, sessio
|
|
|
295
297
|
if (typeof body.sidebarTab === 'boolean') patch.sidebarTab = body.sidebarTab
|
|
296
298
|
if (!Object.keys(patch).length) return sendJson(res, 400, { error: '无有效字段' })
|
|
297
299
|
if (typeof updateUiSettings !== 'function') return sendJson(res, 200, { ok: false, error: '设置服务不可用' })
|
|
298
|
-
updateUiSettings(patch)
|
|
299
|
-
sendJson(res, 200, { ok:
|
|
300
|
+
const persisted = await updateUiSettings(patch)
|
|
301
|
+
sendJson(res, 200, { ok: persisted, ui: { sidebarTab: readSidebarTab ? readSidebarTab() === true : false } })
|
|
300
302
|
},
|
|
301
303
|
},
|
|
302
304
|
{
|
package/src/client.js
CHANGED
|
@@ -34,10 +34,18 @@ function relativeTime(timestamp, now) {
|
|
|
34
34
|
return Math.round(abs / DAY_MS) + ' 天' + suffix
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function formatDateTime(timestamp) {
|
|
37
|
+
function formatDateTime(timestamp, timezoneOffset) {
|
|
38
38
|
if (typeof timestamp !== 'number' || !(timestamp > 0)) return '-'
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// 按调度所用时区(任务显式后缀或宿主默认)平移后取 UTC 分量,与浏览器时区无关;
|
|
40
|
+
// 偏移缺失(状态未加载)瞬态回退浏览器时区
|
|
41
|
+
const offset = typeof timezoneOffset === 'number' ? timezoneOffset : -new Date().getTimezoneOffset() / 60
|
|
42
|
+
const shifted = new Date(timestamp + offset * HOUR_MS)
|
|
43
|
+
return shifted.getUTCFullYear() + '-' + PAD2(shifted.getUTCMonth() + 1) + '-' + PAD2(shifted.getUTCDate()) + ' ' + PAD2(shifted.getUTCHours()) + ':' + PAD2(shifted.getUTCMinutes())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 浏览器所在设备的时区偏移(整小时就近取整):仅用于新建任务时固化初始时区后缀
|
|
47
|
+
function deviceTzOffset() {
|
|
48
|
+
return Math.round(-new Date().getTimezoneOffset() / 60)
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
function formatDuration(ms) {
|
|
@@ -61,6 +69,9 @@ const SB_WEEKDAY_LABELS = { '0': '日', '1': '一', '2': '二', '3': '三', '4':
|
|
|
61
69
|
const SB_MINUTE_MAX = 59
|
|
62
70
|
const SB_HOUR_MAX = 23
|
|
63
71
|
const SB_INTERVAL_MIN_MINUTES = 1
|
|
72
|
+
const SB_TZ_SUFFIX_MIN = -12
|
|
73
|
+
const SB_TZ_SUFFIX_MAX = 14
|
|
74
|
+
const SB_TZ_SUFFIX_PATTERN = /\s*T([+-])(\d{1,2})$/i
|
|
64
75
|
|
|
65
76
|
function createScheduleState(expression) {
|
|
66
77
|
return {
|
|
@@ -70,6 +81,7 @@ function createScheduleState(expression) {
|
|
|
70
81
|
weekdays: ['1'],
|
|
71
82
|
intervalMinutes: 30,
|
|
72
83
|
expression,
|
|
84
|
+
timezone: null,
|
|
73
85
|
}
|
|
74
86
|
}
|
|
75
87
|
|
|
@@ -79,11 +91,21 @@ const sbClampInt = (raw, min, max, fallback) => {
|
|
|
79
91
|
return value
|
|
80
92
|
}
|
|
81
93
|
|
|
82
|
-
// cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom
|
|
94
|
+
// cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜;
|
|
95
|
+
// 非法时区后缀同样 custom 兜底(原样保留,合法性由校验通道业务报错)
|
|
83
96
|
function parseSchedule(expression) {
|
|
84
97
|
const raw = String(expression).trim()
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
let split
|
|
99
|
+
try {
|
|
100
|
+
split = sbSplitScheduleTz(raw)
|
|
101
|
+
} catch {
|
|
102
|
+
const state = createScheduleState(raw)
|
|
103
|
+
state.freq = 'custom'
|
|
104
|
+
return state
|
|
105
|
+
}
|
|
106
|
+
const state = createScheduleState(split.base)
|
|
107
|
+
state.timezone = split.timezoneOffset
|
|
108
|
+
const parts = split.base.split(/\s+/)
|
|
87
109
|
if (parts.length !== 5) { state.freq = 'custom'; return state }
|
|
88
110
|
const [minute, hour, day, month, weekday] = parts
|
|
89
111
|
if (day !== '*' || month !== '*') { state.freq = 'custom'; return state }
|
|
@@ -111,8 +133,19 @@ function parseSchedule(expression) {
|
|
|
111
133
|
return state
|
|
112
134
|
}
|
|
113
135
|
|
|
114
|
-
//
|
|
136
|
+
// 偏移小时数 → 后缀符号值:+8 / -5 / +0
|
|
137
|
+
function sbFormatTzOffset(offset) {
|
|
138
|
+
return (offset >= 0 ? '+' : '') + offset
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 构建器状态 → cron;custom 透传剥离后手工表达式;显式时区统一附加 T±N 后缀
|
|
115
142
|
function buildSchedule(state) {
|
|
143
|
+
const base = sbBuildBase(state)
|
|
144
|
+
if (state.timezone == null) return base
|
|
145
|
+
return base + 'T' + sbFormatTzOffset(state.timezone)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sbBuildBase(state) {
|
|
116
149
|
if (state.freq === 'custom') return String(state.expression).trim()
|
|
117
150
|
const minute = String(state.minute)
|
|
118
151
|
const hour = String(state.hour)
|
|
@@ -122,8 +155,30 @@ function buildSchedule(state) {
|
|
|
122
155
|
if (state.freq === 'interval') return '*/' + state.intervalMinutes + ' * * * *'
|
|
123
156
|
return String(state.expression).trim()
|
|
124
157
|
}
|
|
158
|
+
|
|
159
|
+
// 剥离时区后缀:返回 { base, timezoneOffset },无后缀时 timezoneOffset 为 null;
|
|
160
|
+
// 后缀超出支持范围抛业务错误(校验与桥接共用,脏数据须暴露)
|
|
161
|
+
function sbSplitScheduleTz(schedule) {
|
|
162
|
+
const text = String(schedule).trim()
|
|
163
|
+
const match = text.match(SB_TZ_SUFFIX_PATTERN)
|
|
164
|
+
if (!match) return { base: text, timezoneOffset: null }
|
|
165
|
+
const offset = Number(match[1] + match[2])
|
|
166
|
+
if (offset < SB_TZ_SUFFIX_MIN || offset > SB_TZ_SUFFIX_MAX) {
|
|
167
|
+
throw new Error('时区后缀不合法: T' + match[1] + match[2] + '(支持 T±N 整数小时 ' + SB_TZ_SUFFIX_MIN + ' 至 ' + SB_TZ_SUFFIX_MAX + ')')
|
|
168
|
+
}
|
|
169
|
+
return { base: text.slice(0, match.index).trim(), timezoneOffset: offset }
|
|
170
|
+
}
|
|
125
171
|
/* SBUILD-END */
|
|
126
172
|
|
|
173
|
+
// 任务表达式的时区偏移(小时);无后缀返回 null;脏表达式(后缀非法)兜底 null 不炸渲染
|
|
174
|
+
function scheduleTzOffset(schedule) {
|
|
175
|
+
try {
|
|
176
|
+
return sbSplitScheduleTz(schedule).timezoneOffset
|
|
177
|
+
} catch {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
127
182
|
const API_PREFIX = '/api/cron-board/'
|
|
128
183
|
const REFRESH_INTERVAL_MS = 30 * SECOND_MS
|
|
129
184
|
const TABS = [
|
|
@@ -435,8 +490,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
435
490
|
{ value: 'custom', label: '自定义' },
|
|
436
491
|
]
|
|
437
492
|
|
|
438
|
-
// 调度构建器面板:频率分段 + 上下文选择器 + 表达式芯片 + 即将执行 chips
|
|
439
|
-
function ScheduleBuilder({ scheduleState, onStateChange, preview }) {
|
|
493
|
+
// 调度构建器面板:频率分段 + 上下文选择器 + 时区行 + 表达式芯片 + 即将执行 chips
|
|
494
|
+
function ScheduleBuilder({ scheduleState, onStateChange, preview, previewError, serverTzOffset }) {
|
|
440
495
|
const state = scheduleState
|
|
441
496
|
const setFreq = (freq) => onStateChange({ ...state, freq })
|
|
442
497
|
const patchClock = (field, raw) => {
|
|
@@ -450,6 +505,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
450
505
|
if (days.length === 0) return
|
|
451
506
|
onStateChange({ ...state, weekdays: days })
|
|
452
507
|
}
|
|
508
|
+
const setTz = (raw) => onStateChange({ ...state, timezone: raw === 'host' ? null : Number(raw) })
|
|
509
|
+
const displayTzOffset = state.timezone == null ? serverTzOffset : state.timezone
|
|
453
510
|
const clockRow = (h('div', { className: 'cb-bline' },
|
|
454
511
|
h('span', { className: 'cb-blabel' }, '执行时间'),
|
|
455
512
|
h('input', { type: 'number', className: 'cb-time', min: 0, max: SB_HOUR_MAX, value: state.hour, onChange: (e) => patchClock('hour', e.target.value) }),
|
|
@@ -486,14 +543,20 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
486
543
|
? null
|
|
487
544
|
: h('button', { type: 'button', className: 'cb-linkbtn', onClick: () => setFreq('custom') }, '自定义'),
|
|
488
545
|
state.freq === 'custom' ? null : h('span', { className: 'cb-hint' }, '由上方选择自动生成')),
|
|
546
|
+
h('div', { className: 'cb-bline' },
|
|
547
|
+
h('span', { className: 'cb-blabel' }, '时区'),
|
|
548
|
+
h('select', { className: 'cb-select', value: state.timezone == null ? 'host' : String(state.timezone), onChange: (e) => setTz(e.target.value) },
|
|
549
|
+
h('option', { value: 'host' }, '宿主默认' + (typeof serverTzOffset === 'number' ? '(UTC' + sbFormatTzOffset(serverTzOffset) + ')' : '')),
|
|
550
|
+
Array.from({ length: SB_TZ_SUFFIX_MAX - SB_TZ_SUFFIX_MIN + 1 }, (_, i) => SB_TZ_SUFFIX_MIN + i).map((offset) => h('option', { key: offset, value: String(offset) }, 'UTC' + sbFormatTzOffset(offset)))),
|
|
551
|
+
h('span', { className: 'cb-hint' }, '固定偏移不随夏令时变化;写入表达式后缀 T±N')),
|
|
489
552
|
h('div', { className: 'cb-bline' },
|
|
490
553
|
h('span', { className: 'cb-blabel' }, '即将执行'),
|
|
491
554
|
preview
|
|
492
|
-
? h('span', { className: 'cb-runs' }, preview.nextAt.map((at) => h('span', { key: at, className: 'cb-run' }, formatDateTime(at))))
|
|
493
|
-
: h('span', { className: 'cb-hint' }, '表达式非法或计算中')))
|
|
555
|
+
? h('span', { className: 'cb-runs' }, preview.nextAt.map((at) => h('span', { key: at, className: 'cb-run' }, formatDateTime(at, displayTzOffset))))
|
|
556
|
+
: h('span', { className: 'cb-hint' }, previewError || '表达式非法或计算中')))
|
|
494
557
|
}
|
|
495
558
|
|
|
496
|
-
function JobForm({ job, onDone, wsModel }) {
|
|
559
|
+
function JobForm({ job, onDone, wsModel, serverTzOffset }) {
|
|
497
560
|
// 工作区清单:订阅 model 快照(服务缺失或形态不符即空表,仅保留手输)
|
|
498
561
|
const [wsItems, setWsItems] = useState(() => (wsModel ? wsModel.getSnapshot().items || [] : []))
|
|
499
562
|
useEffect(() => {
|
|
@@ -503,7 +566,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
503
566
|
return wsModel.subscribe(update)
|
|
504
567
|
}, [wsModel])
|
|
505
568
|
const editing = Boolean(job && job.id)
|
|
506
|
-
|
|
569
|
+
// 构建器初始状态:新建任务把设备时区偏移固化为显式后缀,
|
|
570
|
+
// 宿主环境时区异常(如 UTC)时任务仍按设备预期时刻触发;编辑存量保持原表达式语义
|
|
571
|
+
const [scheduleState, setScheduleState] = useState(() => {
|
|
572
|
+
const parsed = parseSchedule(job ? job.schedule : '0 9 * * *')
|
|
573
|
+
if (!editing) parsed.timezone = deviceTzOffset()
|
|
574
|
+
return parsed
|
|
575
|
+
})
|
|
576
|
+
const initialSchedule = editing ? job.schedule : buildSchedule(scheduleState)
|
|
507
577
|
const [form, setForm] = useState(() => ({
|
|
508
578
|
name: job ? job.name : '',
|
|
509
579
|
kind: job ? job.kind : 'shell',
|
|
@@ -521,15 +591,24 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
521
591
|
agentPreset: job && job.session ? job.session.agentPreset : '',
|
|
522
592
|
},
|
|
523
593
|
}))
|
|
524
|
-
// 构建器状态:由既有表达式反解初始化;结构化变更写回 form.schedule
|
|
525
|
-
const [scheduleState, setScheduleState] = useState(() => parseSchedule(initialSchedule))
|
|
526
594
|
const [preview, setPreview] = useState(null)
|
|
595
|
+
const [previewError, setPreviewError] = useState(null)
|
|
527
596
|
const [error, setError] = useState(null)
|
|
528
597
|
const [saving, setSaving] = useState(false)
|
|
529
598
|
const [presetCatalog, setPresetCatalog] = useState({ defaultId: '', items: [] })
|
|
530
599
|
const set = (patch) => setForm((prev) => ({ ...prev, ...patch }))
|
|
531
600
|
const setSession = (patch) => setForm((prev) => ({ ...prev, session: { ...prev.session, ...patch } }))
|
|
532
601
|
const applyScheduleState = (next) => {
|
|
602
|
+
// custom 频率下表达式携带合法后缀时以表达式为准(剥离归一并回填时区);
|
|
603
|
+
// 无后缀保留调用方传入的时区(select 与频率切换不丢配置);非法后缀原文保留且时区置空
|
|
604
|
+
if (next.freq === 'custom') {
|
|
605
|
+
try {
|
|
606
|
+
const split = sbSplitScheduleTz(next.expression)
|
|
607
|
+
if (split.timezoneOffset != null) next = { ...next, expression: split.base, timezone: split.timezoneOffset }
|
|
608
|
+
} catch {
|
|
609
|
+
next = { ...next, timezone: null }
|
|
610
|
+
}
|
|
611
|
+
}
|
|
533
612
|
setScheduleState(next)
|
|
534
613
|
set({ schedule: buildSchedule(next) })
|
|
535
614
|
}
|
|
@@ -545,9 +624,13 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
545
624
|
|
|
546
625
|
useEffect(() => {
|
|
547
626
|
let alive = true
|
|
627
|
+
// 表达式变更即清空旧错误:防抖窗口内不残留上一次失败文案
|
|
628
|
+
setPreviewError(null)
|
|
548
629
|
const timer = setTimeout(async () => {
|
|
549
630
|
const outcome = await request('POST', 'cron/preview', { schedule: form.schedule })
|
|
550
|
-
if (alive)
|
|
631
|
+
if (!alive) return
|
|
632
|
+
setPreview(outcome.ok ? outcome.data : null)
|
|
633
|
+
setPreviewError(outcome.ok ? null : outcome.error)
|
|
551
634
|
}, 300)
|
|
552
635
|
return () => { alive = false; clearTimeout(timer) }
|
|
553
636
|
}, [form.schedule])
|
|
@@ -575,7 +658,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
575
658
|
ariaLabel: '类型',
|
|
576
659
|
}))),
|
|
577
660
|
h(Section, { title: '调度计划' },
|
|
578
|
-
h(ScheduleBuilder, { scheduleState, onStateChange: applyScheduleState, preview })),
|
|
661
|
+
h(ScheduleBuilder, { scheduleState, onStateChange: applyScheduleState, preview, previewError, serverTzOffset })),
|
|
579
662
|
form.kind === 'shell'
|
|
580
663
|
? h(Section, { title: '运行配置(shell)' },
|
|
581
664
|
h(Field, { label: '命令', hint: '经由宿主 shell 执行,支持环境变量插值 $NAME' },
|
|
@@ -739,7 +822,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
739
822
|
onConfirm: () => deleteJob(pendingDelete),
|
|
740
823
|
onCancel: () => setPendingDelete(null),
|
|
741
824
|
}) : null,
|
|
742
|
-
formJob ? h(JobForm, { job: formJob.id ? formJob : null, wsModel, onDone: () => { setFormJob(null); reload() } }) : null)
|
|
825
|
+
formJob ? h(JobForm, { job: formJob.id ? formJob : null, wsModel, serverTzOffset: status ? status.serverTzOffset : undefined, onDone: () => { setFormJob(null); reload() } }) : null)
|
|
743
826
|
}
|
|
744
827
|
|
|
745
828
|
// —— 环境变量 Tab ——
|
|
@@ -874,11 +957,14 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
874
957
|
}
|
|
875
958
|
|
|
876
959
|
// —— 日志 Tab ——
|
|
877
|
-
function LogsTab({ jobs, reload, reloadFlag }) {
|
|
960
|
+
function LogsTab({ jobs, reload, reloadFlag, serverTzOffset }) {
|
|
878
961
|
const [jobId, setJobId] = useState(jobs.length > 0 ? jobs[0].id : null)
|
|
879
962
|
const [runs, setRuns] = useState([])
|
|
880
963
|
const [logText, setLogText] = useState(null)
|
|
881
964
|
const [logRunId, setLogRunId] = useState(null)
|
|
965
|
+
// 展示时区随所选任务:显式后缀用其偏移,无后缀用宿主默认(调度与展示同源)
|
|
966
|
+
const selectedJob = jobs.find((job) => job.id === jobId)
|
|
967
|
+
const displayTzOffset = scheduleTzOffset(selectedJob ? selectedJob.schedule : null) ?? (typeof serverTzOffset === 'number' ? serverTzOffset : undefined)
|
|
882
968
|
|
|
883
969
|
useEffect(() => {
|
|
884
970
|
let alive = true
|
|
@@ -912,7 +998,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
912
998
|
h('span', { className: 'cb-dot', style: { '--cb-status': STATUS_TONE_COLOR[meta.tone] } }),
|
|
913
999
|
h('span', { className: 'cb-meta' }, meta.label),
|
|
914
1000
|
h('span', { className: 'cb-badge' }, TRIGGER_META[run.trigger] ? TRIGGER_META[run.trigger].label : run.trigger),
|
|
915
|
-
h('span', { className: 'cb-meta' }, formatDateTime(run.createdAt)),
|
|
1001
|
+
h('span', { className: 'cb-meta' }, formatDateTime(run.createdAt, displayTzOffset)),
|
|
916
1002
|
typeof run.durationMs === 'number' ? h('span', { className: 'cb-meta' }, formatDuration(run.durationMs)) : null,
|
|
917
1003
|
run.message ? h('span', { className: 'cb-meta' }, run.message) : null,
|
|
918
1004
|
h('div', { className: 'cb-actions' },
|
|
@@ -972,7 +1058,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
972
1058
|
(status.nextAt ? ' · 下次 ' + relativeTime(status.nextAt, now) : '')) : null),
|
|
973
1059
|
tab === 'jobs' ? h(JobsTab, { key: 'jobs', jobs, status, now, reload, applyJobPatch, wsModel }) : null,
|
|
974
1060
|
tab === 'envs' ? h(EnvsTab, { key: 'envs', envs, reload, applyEnvPatch }) : null,
|
|
975
|
-
tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag }) : null)
|
|
1061
|
+
tab === 'logs' ? h(LogsTab, { key: 'logs', jobs, reload, reloadFlag, serverTzOffset: status ? status.serverTzOffset : undefined }) : null)
|
|
976
1062
|
}
|
|
977
1063
|
|
|
978
1064
|
// —— 主页面挂载:宿主官方全局面板契约(main keyed 插槽 + sidebar.panellist 侧栏入口)——
|
|
@@ -983,6 +1069,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
983
1069
|
const TAB_ID = 'cron-board:board'
|
|
984
1070
|
const PANEL_ID = 'cron-board'
|
|
985
1071
|
const PANEL_LABEL = '定时任务'
|
|
1072
|
+
const PKG_ID = '@mzzsfy/dsh-cron-board'
|
|
986
1073
|
|
|
987
1074
|
// 设置>插件页卡片:官方 PluginCard 形制(名称/描述头部 + chevron 折叠 + 行内开关);better-sidebar 在场可切换,否则仅展示禁用态
|
|
988
1075
|
const CARD_TITLE = '定时任务'
|
|
@@ -1133,6 +1220,15 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
|
1133
1220
|
() => h(CronBoardPluginCard, { ctx }),
|
|
1134
1221
|
)
|
|
1135
1222
|
}), 'cron-board settings card')
|
|
1223
|
+
// 插件管理页(0.1.7-rc.1+)包详情配置卡:key = 包名,管理页 ledger.bundles 判定 section
|
|
1224
|
+
// 在场;卡片自包含(只调本插件 API,不触宿主 settings)。两代槽位并存注册:inject 是
|
|
1225
|
+
// 声明生命周期效应,错误世代宿主永不声明该槽,回调挂起不执行,零成本
|
|
1226
|
+
ctx.effect(() => ctx.slots.inject('plugins.bundle.config', function* () {
|
|
1227
|
+
yield ctx.slots.register(
|
|
1228
|
+
{ name: 'plugins.bundle.config', key: PKG_ID },
|
|
1229
|
+
() => h(CronBoardPluginCard, { ctx }),
|
|
1230
|
+
)
|
|
1231
|
+
}), 'cron-board plugin manager card')
|
|
1136
1232
|
ctx.inject(['betterSidebar'], (bsCtx) => {
|
|
1137
1233
|
currentSidebar = bsCtx.betterSidebar
|
|
1138
1234
|
applyPref()
|
package/src/cron.mjs
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
// cron 解析薄封装:croner 承担表达式解析与触发点计算(5/6 段通吃、时区、DST)。
|
|
2
|
-
//
|
|
2
|
+
// 表达式支持内联时区后缀 T±N(语法归 schedule-builder):显式后缀按固定偏移,
|
|
3
|
+
// 无后缀按宿主系统时区(crontab 惯例,与 croner 原生行为一致)。
|
|
3
4
|
|
|
4
5
|
import { Cron } from 'croner'
|
|
5
6
|
|
|
7
|
+
import { splitScheduleTz, formatTzOffset } from './schedule-builder.mjs'
|
|
8
|
+
|
|
9
|
+
// 固定偏移 → croner 时区名(Etc/GMT 为 POSIX 反转:UTC+8 即 Etc/GMT-8)
|
|
10
|
+
function etcGmtName(timezoneOffset) {
|
|
11
|
+
if (timezoneOffset === 0) return 'UTC'
|
|
12
|
+
return timezoneOffset > 0 ? 'Etc/GMT-' + timezoneOffset : 'Etc/GMT+' + (-timezoneOffset)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// croner 构造选项:无后缀不指定时区(宿主系统时区)
|
|
16
|
+
function cronOptions(timezoneOffset) {
|
|
17
|
+
return timezoneOffset === null ? {} : { timezone: etcGmtName(timezoneOffset) }
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
// 求自 from 起下一次触发点(ms epoch);表达式无未来触发点时返回 null
|
|
7
21
|
export function nextRunAtOf(schedule, from = new Date()) {
|
|
8
|
-
const
|
|
22
|
+
const { base, timezoneOffset } = splitScheduleTz(schedule)
|
|
23
|
+
const job = new Cron(base, cronOptions(timezoneOffset))
|
|
9
24
|
const next = job.nextRun(from)
|
|
10
25
|
return next ? next.getTime() : null
|
|
11
26
|
}
|
|
12
27
|
|
|
13
28
|
// 求自 from 起下 N 次触发点(编辑表单实时预览用)
|
|
14
29
|
export function nextRunsOf(schedule, count, from = new Date()) {
|
|
15
|
-
const
|
|
30
|
+
const { base, timezoneOffset } = splitScheduleTz(schedule)
|
|
31
|
+
const job = new Cron(base, cronOptions(timezoneOffset))
|
|
16
32
|
return job.nextRuns(count, from).map((date) => date.getTime())
|
|
17
33
|
}
|
|
18
34
|
|
|
19
|
-
// 表达式合法性校验:合法返回 null,非法抛业务错误
|
|
35
|
+
// 表达式合法性校验:合法返回 null,非法抛业务错误(后缀与表达式分别报因)
|
|
20
36
|
export function assertValidSchedule(schedule) {
|
|
37
|
+
const { base } = splitScheduleTz(schedule)
|
|
21
38
|
try {
|
|
22
|
-
new Cron(
|
|
39
|
+
new Cron(base)
|
|
23
40
|
} catch (error) {
|
|
24
41
|
throw new Error('cron 表达式不合法: ' + schedule)
|
|
25
42
|
}
|
|
@@ -28,23 +45,32 @@ export function assertValidSchedule(schedule) {
|
|
|
28
45
|
const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六']
|
|
29
46
|
const PAD2 = (value) => String(value).padStart(2, '0')
|
|
30
47
|
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
// 时区尾注:显式后缀标注自身,无后缀标注宿主默认(hostOffset 可显式传入以便测试)
|
|
49
|
+
function tzNote(timezoneOffset, hostOffset) {
|
|
50
|
+
return timezoneOffset === null
|
|
51
|
+
? '(宿主 UTC' + formatTzOffset(hostOffset) + ')'
|
|
52
|
+
: '(UTC' + formatTzOffset(timezoneOffset) + ')'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 人话摘要(轻量规则覆盖常用形态,兜底回原表达式),一律携带时区尾注
|
|
56
|
+
export function summarizeCron(schedule, hostOffset = -new Date().getTimezoneOffset() / 60) {
|
|
57
|
+
const { base, timezoneOffset } = splitScheduleTz(schedule)
|
|
58
|
+
const note = tzNote(timezoneOffset, hostOffset)
|
|
59
|
+
const parts = base.trim().split(/\s+/)
|
|
60
|
+
if (parts.length !== 5) return base + note
|
|
35
61
|
const [minute, hour, day, month, weekday] = parts
|
|
36
62
|
const clock = (h, m) => PAD2(h) + ':' + PAD2(m)
|
|
37
63
|
const dayFree = day === '*' && month === '*' && weekday === '*'
|
|
38
|
-
if (dayFree && /^\*\/\d+$/.test(hour) && minute === '0') return '每 ' + hour.slice(2) + ' 小时'
|
|
39
|
-
if (dayFree && hour.startsWith('*/')) return '每 ' + hour.slice(2) + ' 小时(第 ' + minute + ' 分)'
|
|
40
|
-
if (dayFree && /^\*\/\d+$/.test(minute) && hour === '*') return '每 ' + minute.slice(2) + ' 分钟'
|
|
41
|
-
if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return
|
|
64
|
+
if (dayFree && /^\*\/\d+$/.test(hour) && minute === '0') return '每 ' + hour.slice(2) + ' 小时' + note
|
|
65
|
+
if (dayFree && hour.startsWith('*/')) return '每 ' + hour.slice(2) + ' 小时(第 ' + minute + ' 分)' + note
|
|
66
|
+
if (dayFree && /^\*\/\d+$/.test(minute) && hour === '*') return '每 ' + minute.slice(2) + ' 分钟' + note
|
|
67
|
+
if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) return base + note
|
|
42
68
|
if (month !== '*' || day !== '*') {
|
|
43
|
-
if (/^\d+$/.test(day) && month === '*') return '每月 ' + day + ' 日 ' + clock(hour, minute)
|
|
44
|
-
return
|
|
69
|
+
if (/^\d+$/.test(day) && month === '*') return '每月 ' + day + ' 日 ' + clock(hour, minute) + note
|
|
70
|
+
return base + note
|
|
45
71
|
}
|
|
46
|
-
if (weekday === '1-5') return '工作日 ' + clock(hour, minute)
|
|
47
|
-
if (/^\d$/.test(weekday)) return '每周' + (WEEKDAY_LABELS[Number(weekday)] || weekday) + ' ' + clock(hour, minute)
|
|
48
|
-
if (day === '*' && weekday === '*') return '每天 ' + clock(hour, minute)
|
|
49
|
-
return
|
|
72
|
+
if (weekday === '1-5') return '工作日 ' + clock(hour, minute) + note
|
|
73
|
+
if (/^\d$/.test(weekday)) return '每周' + (WEEKDAY_LABELS[Number(weekday)] || weekday) + ' ' + clock(hour, minute) + note
|
|
74
|
+
if (day === '*' && weekday === '*') return '每天 ' + clock(hour, minute) + note
|
|
75
|
+
return base + note
|
|
50
76
|
}
|
package/src/index.js
CHANGED
|
@@ -43,6 +43,21 @@ const SETTINGS_SCHEMA = schemastery.object({
|
|
|
43
43
|
sidebarTab: schemastery.boolean().default(false).description('看板移入 better-sidebar 侧边栏(需已安装;关闭时始终使用主界面)'),
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
+
// 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
|
|
47
|
+
// settings.register 注册同名命名空间。根级 volatile 包装:0.1.7 下整节为 live
|
|
48
|
+
// 表单,节写经 loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
|
|
49
|
+
export const Config = volatileWrap(SETTINGS_SCHEMA)
|
|
50
|
+
|
|
51
|
+
function volatileWrap(schema) {
|
|
52
|
+
return typeof schema.volatile === 'function' ? schema.volatile() : schema
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
|
|
56
|
+
// legacy 宿主 config 为普通对象原样透传
|
|
57
|
+
function unwrapVolatile(value) {
|
|
58
|
+
return typeof value?.get === 'function' ? value.get() : value
|
|
59
|
+
}
|
|
60
|
+
|
|
46
61
|
export function resolveDataDir(env = process.env) {
|
|
47
62
|
const override = env[DATA_DIR_ENV]
|
|
48
63
|
if (override && override.trim() !== '') return override.trim()
|
|
@@ -139,17 +154,42 @@ export function apply(ctx, config) {
|
|
|
139
154
|
const value = readSettingValue(name)
|
|
140
155
|
return typeof value === 'boolean' ? value : fallback
|
|
141
156
|
}
|
|
142
|
-
//
|
|
143
|
-
|
|
157
|
+
// 配置面双形态(maintain 同构,判定一律延迟到使用点,settings 挂载时序无保证):
|
|
158
|
+
// legacy(≤0.1.6)settings 服务 register/get/update 命名空间语义;
|
|
159
|
+
// 0.1.7+ 静态 Config 导出生成节表单,读经 volatile ref 解包,写经 configEditor
|
|
160
|
+
const legacySettingsFace = () => {
|
|
144
161
|
const settings = ctx.get('settings')
|
|
145
|
-
|
|
146
|
-
|
|
162
|
+
return typeof settings?.register === 'function' && typeof settings?.update === 'function'
|
|
163
|
+
}
|
|
164
|
+
// 0.1.7 写路径:configEditor.edit 定位本条目(fiber.entry,与命名空间解耦),
|
|
165
|
+
// change 回调合并落 profile patch;仅 volatile 字段变化时 loader 原地热更不重启
|
|
166
|
+
async function persistPatch(patch) {
|
|
167
|
+
if (legacySettingsFace()) {
|
|
168
|
+
await ctx.get('settings').update(SETTINGS_NS, patch)
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
const editor = ctx.get('configEditor')
|
|
172
|
+
const entry = ctx.fiber?.entry
|
|
173
|
+
if (!editor || typeof editor.edit !== 'function' || !entry) return false
|
|
174
|
+
await editor.edit(entry, (current) => ({ ...current, ...patch }))
|
|
147
175
|
return true
|
|
148
176
|
}
|
|
177
|
+
// 客户端设置面板写入通道。必须 await:宿主写失败在异步段抛出(0.1.5 前
|
|
178
|
+
// settings 对未注册命名空间 "No configurable plugin entry" 实测打崩宿主),
|
|
179
|
+
// await 后异常传播到 api.handle 的路由级 catch,降级为 400 响应,宿主存活
|
|
180
|
+
async function updateUiSettings(patch) {
|
|
181
|
+
return persistPatch(patch)
|
|
182
|
+
}
|
|
149
183
|
function readSettingValue(name) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
184
|
+
try {
|
|
185
|
+
const settings = ctx.get('settings')
|
|
186
|
+
// 双形读:legacy settings 命名空间;0.1.7+ apply 入参 config 整节解包
|
|
187
|
+
const scope = settings && settings.get ? settings.get(SETTINGS_NS) : unwrapVolatile(config)
|
|
188
|
+
return scope ? scope[name] : undefined
|
|
189
|
+
} catch {
|
|
190
|
+
// 与写入同防:宿主 settings 对未注册命名空间抛错时读面降级为缺省
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
153
193
|
}
|
|
154
194
|
function readTickMs() {
|
|
155
195
|
return Math.max(MIN_TICK_MS, readNumber('tickSeconds', DEFAULT_TICK_MS / 1000) * 1000)
|
|
@@ -183,7 +223,14 @@ export function apply(ctx, config) {
|
|
|
183
223
|
})
|
|
184
224
|
|
|
185
225
|
ctx.inject(['settings'], (sctx) => {
|
|
186
|
-
|
|
226
|
+
// 方法面守卫:settings 服务缺 register 面(0.1.7 已移除)即走新形态分支
|
|
227
|
+
if (typeof sctx.settings.register !== 'function') {
|
|
228
|
+
// 0.1.7:原生自动设置页与本包插件卡重复,特性检测关闭(maintain 同构)
|
|
229
|
+
if (typeof sctx.settings?.configure === 'function') {
|
|
230
|
+
sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber))
|
|
231
|
+
}
|
|
232
|
+
return
|
|
233
|
+
}
|
|
187
234
|
const scope = sctx.settings.register(SETTINGS_NS, SETTINGS_SCHEMA, { base: config })
|
|
188
235
|
// tick 周期等设置变更即时对账(重建 interval)
|
|
189
236
|
if (scope && typeof scope.watch === 'function') {
|
package/src/schedule-builder.mjs
CHANGED
|
@@ -10,7 +10,8 @@ export const MINUTE_MAX = 59
|
|
|
10
10
|
export const HOUR_MAX = 23
|
|
11
11
|
export const INTERVAL_MIN_MINUTES = 1
|
|
12
12
|
|
|
13
|
-
// 结构化调度状态:custom 时 expression 为唯一有效字段,其余字段仍保留(切回结构化频率不丢配置)
|
|
13
|
+
// 结构化调度状态:custom 时 expression 为唯一有效字段,其余字段仍保留(切回结构化频率不丢配置);
|
|
14
|
+
// timezone 为内联时区后缀的偏移小时数,null 表示无后缀(按宿主系统时区)
|
|
14
15
|
export function createScheduleState(expression) {
|
|
15
16
|
return {
|
|
16
17
|
freq: 'daily',
|
|
@@ -19,6 +20,7 @@ export function createScheduleState(expression) {
|
|
|
19
20
|
weekdays: ['1'],
|
|
20
21
|
intervalMinutes: 30,
|
|
21
22
|
expression,
|
|
23
|
+
timezone: null,
|
|
22
24
|
}
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -28,11 +30,21 @@ const clampInt = (raw, min, max, fallback) => {
|
|
|
28
30
|
return value
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
// cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom
|
|
33
|
+
// cron(5 段)→ 构建器状态:匹配失败即 custom;非法数值一律归 custom,不猜;
|
|
34
|
+
// 非法时区后缀同样 custom 兜底(原样保留,合法性由校验通道业务报错)
|
|
32
35
|
export function parseSchedule(expression) {
|
|
33
36
|
const raw = String(expression).trim()
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
let split
|
|
38
|
+
try {
|
|
39
|
+
split = splitScheduleTz(raw)
|
|
40
|
+
} catch {
|
|
41
|
+
const state = createScheduleState(raw)
|
|
42
|
+
state.freq = 'custom'
|
|
43
|
+
return state
|
|
44
|
+
}
|
|
45
|
+
const state = createScheduleState(split.base)
|
|
46
|
+
state.timezone = split.timezoneOffset
|
|
47
|
+
const parts = split.base.split(/\s+/)
|
|
36
48
|
if (parts.length !== 5) { state.freq = 'custom'; return state }
|
|
37
49
|
const [minute, hour, day, month, weekday] = parts
|
|
38
50
|
if (day !== '*' || month !== '*') { state.freq = 'custom'; return state }
|
|
@@ -60,8 +72,19 @@ export function parseSchedule(expression) {
|
|
|
60
72
|
return state
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
//
|
|
75
|
+
// 偏移小时数 → 后缀符号值:+8 / -5 / +0
|
|
76
|
+
export function formatTzOffset(offset) {
|
|
77
|
+
return (offset >= 0 ? '+' : '') + offset
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 构建器状态 → cron;custom 透传剥离后手工表达式;显式时区统一附加 T±N 后缀
|
|
64
81
|
export function buildSchedule(state) {
|
|
82
|
+
const base = buildBase(state)
|
|
83
|
+
if (state.timezone == null) return base
|
|
84
|
+
return base + 'T' + formatTzOffset(state.timezone)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildBase(state) {
|
|
65
88
|
if (state.freq === 'custom') return String(state.expression).trim()
|
|
66
89
|
const minute = String(state.minute)
|
|
67
90
|
const hour = String(state.hour)
|
|
@@ -71,3 +94,23 @@ export function buildSchedule(state) {
|
|
|
71
94
|
if (state.freq === 'interval') return '*/' + state.intervalMinutes + ' * * * *'
|
|
72
95
|
return String(state.expression).trim()
|
|
73
96
|
}
|
|
97
|
+
|
|
98
|
+
// —— 内联时区后缀:表达式形态的一部分,构建器读写,cron.mjs 桥接 croner 时复用 ——
|
|
99
|
+
|
|
100
|
+
export const TZ_SUFFIX_MIN = -12
|
|
101
|
+
export const TZ_SUFFIX_MAX = 14
|
|
102
|
+
// T±N 整数小时,允许与表达式空格分隔,大小写均可
|
|
103
|
+
const TZ_SUFFIX_PATTERN = /\s*T([+-])(\d{1,2})$/i
|
|
104
|
+
|
|
105
|
+
// 剥离时区后缀:返回 { base, timezoneOffset },无后缀时 timezoneOffset 为 null;
|
|
106
|
+
// 后缀超出支持范围抛业务错误(校验与桥接共用,脏数据须暴露)
|
|
107
|
+
export function splitScheduleTz(schedule) {
|
|
108
|
+
const text = String(schedule).trim()
|
|
109
|
+
const match = text.match(TZ_SUFFIX_PATTERN)
|
|
110
|
+
if (!match) return { base: text, timezoneOffset: null }
|
|
111
|
+
const offset = Number(match[1] + match[2])
|
|
112
|
+
if (offset < TZ_SUFFIX_MIN || offset > TZ_SUFFIX_MAX) {
|
|
113
|
+
throw new Error('时区后缀不合法: T' + match[1] + match[2] + '(支持 T±N 整数小时 ' + TZ_SUFFIX_MIN + ' 至 ' + TZ_SUFFIX_MAX + ')')
|
|
114
|
+
}
|
|
115
|
+
return { base: text.slice(0, match.index).trim(), timezoneOffset: offset }
|
|
116
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// cron 解析封装测试:内联时区后缀 T±N 的触发点计算、校验与人话摘要(BDD feat-timezone S1)。
|
|
2
|
+
// 后缀语义:显式后缀按固定偏移;无后缀按宿主系统时区(与 croner 原生行为一致)。
|
|
3
|
+
|
|
4
|
+
import { test } from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { Cron } from 'croner'
|
|
7
|
+
|
|
8
|
+
import { nextRunAtOf, nextRunsOf, assertValidSchedule, summarizeCron } from '../src/cron.mjs'
|
|
9
|
+
import { buildSchedule } from '../src/schedule-builder.mjs'
|
|
10
|
+
|
|
11
|
+
// 固定起点:2026-09-17T12:00:00Z(北京 20:00),之后的 9:00 触发点跨日,断言无歧义
|
|
12
|
+
const FROM = new Date('2026-09-17T12:00:00Z')
|
|
13
|
+
|
|
14
|
+
test('cron:后缀 T+8 按东八区计算触发点', () => {
|
|
15
|
+
// Given 表达式 0 9 * * *T+8
|
|
16
|
+
// When 求自固定起点起的下一次触发
|
|
17
|
+
const at = nextRunAtOf('0 9 * * *T+8', FROM)
|
|
18
|
+
// Then 触发点为北京 09-18 09:00,即 01:00Z
|
|
19
|
+
assert.equal(at, Date.UTC(2026, 8, 18, 1, 0, 0))
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('cron:后缀 T+0 与 T-5 按固定偏移计算,允许后缀前空格', () => {
|
|
23
|
+
// Then T+0 即 UTC 触发点 09:00Z
|
|
24
|
+
assert.equal(nextRunAtOf('0 9 * * *T+0', FROM), Date.UTC(2026, 8, 18, 9, 0, 0))
|
|
25
|
+
// Then 空格分隔等价紧贴
|
|
26
|
+
assert.equal(nextRunAtOf('0 9 * * * T+0', FROM), Date.UTC(2026, 8, 18, 9, 0, 0))
|
|
27
|
+
// Then T-5 即 UTC-5 的 9:00,起点(12:00Z)之后的下一次为当日 14:00Z
|
|
28
|
+
assert.equal(nextRunAtOf('0 9 * * *T-5', FROM), Date.UTC(2026, 8, 17, 14, 0, 0))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('cron:后缀 T+8 下三次触发连续推进', () => {
|
|
32
|
+
// Given 显式东八区,取 3 个触发点
|
|
33
|
+
const runs = nextRunsOf('0 9 * * *T+8', 3, FROM)
|
|
34
|
+
// Then 为连续三天北京 09:00(01:00Z)
|
|
35
|
+
assert.deepEqual(runs, [
|
|
36
|
+
Date.UTC(2026, 8, 18, 1, 0, 0),
|
|
37
|
+
Date.UTC(2026, 8, 19, 1, 0, 0),
|
|
38
|
+
Date.UTC(2026, 8, 20, 1, 0, 0),
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('cron:无后缀与 croner 原生系统时区行为一致', () => {
|
|
43
|
+
// Given 无后缀表达式
|
|
44
|
+
// When 求下一次触发
|
|
45
|
+
const at = nextRunAtOf('30 8 * * *', FROM)
|
|
46
|
+
// Then 与 croner 原生(不传 timezone)一致,零回归
|
|
47
|
+
const native = new Cron('30 8 * * *').nextRun(FROM).getTime()
|
|
48
|
+
assert.equal(at, native)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('cron:后缀解析边界——负零、前导零、小写、空格归一', () => {
|
|
52
|
+
// Then 负零等价 +0(UTC)
|
|
53
|
+
assert.equal(nextRunAtOf('0 9 * * *T-0', FROM), Date.UTC(2026, 8, 18, 9, 0, 0))
|
|
54
|
+
// Then 前导零等价单位数
|
|
55
|
+
assert.equal(nextRunAtOf('0 9 * * *T+08', FROM), Date.UTC(2026, 8, 18, 1, 0, 0))
|
|
56
|
+
// Then 小写 t 同样识别
|
|
57
|
+
assert.equal(nextRunAtOf('0 9 * * *t+8', FROM), Date.UTC(2026, 8, 18, 1, 0, 0))
|
|
58
|
+
// Then 空格分隔反解后回写归一为紧贴形态
|
|
59
|
+
const state = { freq: 'daily', hour: 9, minute: 0, weekdays: ['1'], intervalMinutes: 30, expression: '0 9 * * *', timezone: 8 }
|
|
60
|
+
assert.equal(buildSchedule(state), '0 9 * * *T+8')
|
|
61
|
+
// Then 负零写侧归一为 +0(不输出 T-0)
|
|
62
|
+
assert.equal(buildSchedule({ ...state, timezone: -0 }), '0 9 * * *T+0')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('cron:nextRunAtOf 对非法后缀直接抛业务错误', () => {
|
|
66
|
+
assert.throws(() => nextRunAtOf('0 9 * * *T+15', FROM), /时区后缀不合法/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('cron:非法后缀抛业务错误,坏表达式抛表达式错误', () => {
|
|
70
|
+
// Then 后缀超界(-12..14 之外)报后缀不合法
|
|
71
|
+
assert.throws(() => assertValidSchedule('0 9 * * *T+15'), /时区后缀不合法: T\+15/)
|
|
72
|
+
assert.throws(() => assertValidSchedule('0 9 * * *T-13'), /时区后缀不合法/)
|
|
73
|
+
// Then 表达式本身坏报表达式不合法(文案含原文)
|
|
74
|
+
assert.throws(() => assertValidSchedule('not-a-cronT+8'), /cron 表达式不合法: not-a-cronT\+8/)
|
|
75
|
+
// Then 合法表达式不抛
|
|
76
|
+
assert.doesNotThrow(() => assertValidSchedule('0 9 * * *T+8'))
|
|
77
|
+
assert.doesNotThrow(() => assertValidSchedule('0 9 * * *'))
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('cron:摘要显式后缀尾注 (UTC+N)', () => {
|
|
81
|
+
assert.equal(summarizeCron('0 9 * * *T+8'), '每天 09:00(UTC+8)')
|
|
82
|
+
assert.equal(summarizeCron('30 8 * * 1-5 T+0'), '工作日 08:30(UTC+0)')
|
|
83
|
+
assert.equal(summarizeCron('0 22 * * 6T-5'), '每周六 22:00(UTC-5)')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('cron:摘要无后缀尾注显示宿主偏移,显式传参可测', () => {
|
|
87
|
+
// Given 宿主偏移显式传 0
|
|
88
|
+
assert.equal(summarizeCron('0 9 * * *', 0), '每天 09:00(宿主 UTC+0)')
|
|
89
|
+
// Given 宿主偏移显式传 5.5(半时区宿主)
|
|
90
|
+
assert.equal(summarizeCron('0 9 * * *', 5.5), '每天 09:00(宿主 UTC+5.5)')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('cron:摘要 custom 与非 5 段兜底同样携带时区尾注', () => {
|
|
94
|
+
// Given 指定日月(非构建器形态)带后缀
|
|
95
|
+
assert.equal(summarizeCron('0 0 1 1 *T+8'), '0 0 1 1 *(UTC+8)')
|
|
96
|
+
// Given 非法表达式(不构成摘要)带宿主偏移传参
|
|
97
|
+
assert.equal(summarizeCron('not-a-cron', 0), 'not-a-cron(宿主 UTC+0)')
|
|
98
|
+
})
|
package/test/executor.test.mjs
CHANGED
|
@@ -36,14 +36,21 @@ async function makeExecutor(t, { jobsBroken = false, logLines = [] } = {}) {
|
|
|
36
36
|
|
|
37
37
|
const JOB_BASE = { name: 't', kind: 'shell', command: 'echo hi', schedule: '* * * * *', enabled: true, timeoutMs: 60 * 1000 }
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
for (
|
|
43
|
-
|
|
44
|
-
if (
|
|
39
|
+
// 轮询至断言目标状态可见:执行链在 runs 终态后仍有任务卡回填/留痕等后台落定动作,单次读取存在竞态窗口
|
|
40
|
+
async function waitFor(predicate, timeoutMs = 2 * 1000) {
|
|
41
|
+
const deadline = Date.now() + timeoutMs
|
|
42
|
+
for (;;) {
|
|
43
|
+
if (predicate()) return
|
|
44
|
+
if (Date.now() > deadline) throw new Error('waitFor 超时')
|
|
45
45
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
46
46
|
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function dispatchAndSettle(executor, store, jobId) {
|
|
50
|
+
const [runId] = await executor.dispatch({ ...JOB_BASE, id: jobId }, 'manual')
|
|
51
|
+
// 等执行链完整落定:runs 终态内存生效早于写链持久化,任务卡回填在其后才可见,以回填为收尾信号
|
|
52
|
+
// 信号为任务级(同名任务任一单元回填即满足),非特定 run 级;当前调用点无依赖特定 run 落定的断言
|
|
53
|
+
await waitFor(() => store.jobs.get(jobId)?.lastStatus !== undefined)
|
|
47
54
|
return runId
|
|
48
55
|
}
|
|
49
56
|
|
|
@@ -87,12 +94,8 @@ test('executor:pinned 自愈回写 session.pinnedSessionId 且清除顶层孤立
|
|
|
87
94
|
readMaxConcurrent: () => 2,
|
|
88
95
|
})
|
|
89
96
|
const [runId] = await executorWithPinned.dispatch({ ...JOB_BASE, kind: 'session', session: { mode: 'pinned', pinnedSessionId: '' }, id: 'j-pin' }, 'manual')
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (row && row.status !== 'queued' && row.status !== 'running') break
|
|
93
|
-
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
94
|
-
}
|
|
95
|
-
// Then 绑定写入 session 子对象,顶层无孤立残留
|
|
97
|
+
// Then 绑定写入 session 子对象,顶层无孤立残留;回填晚于 runs 终态,以回填可见为落定信号
|
|
98
|
+
await waitFor(() => store.jobs.get('j-pin')?.session?.pinnedSessionId === 's-new')
|
|
96
99
|
const job = store.jobs.get('j-pin')
|
|
97
100
|
assert.equal(job.session.pinnedSessionId, 's-new')
|
|
98
101
|
assert.equal(job.pinnedSessionId, undefined)
|
|
@@ -114,11 +117,8 @@ test('executor:runner 拒绝时补写 fail 终态', async (t) => {
|
|
|
114
117
|
})
|
|
115
118
|
await store.jobs.create({ ...JOB_BASE, id: 'j2' })
|
|
116
119
|
const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j2' }, 'manual')
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (row && row.status === 'fail') break
|
|
120
|
-
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
121
|
-
}
|
|
120
|
+
// catch 路径先补 runs 终态再回填任务卡,以回填可见为收尾信号
|
|
121
|
+
await waitFor(() => store.jobs.get('j2')?.lastStatus === 'fail')
|
|
122
122
|
const row = store.runs.get(runId)
|
|
123
123
|
assert.equal(row.status, 'fail')
|
|
124
124
|
assert.match(row.message, /会话服务爆炸/)
|
|
@@ -129,7 +129,9 @@ test('executor:runner 拒绝时补写 fail 终态', async (t) => {
|
|
|
129
129
|
test('executor:运行终态已落库而任务卡回填失败,历史不被覆写', async (t) => {
|
|
130
130
|
const { store, executor, logLines } = await makeExecutor(t, { jobsBroken: true })
|
|
131
131
|
await store.jobs.create({ ...JOB_BASE, id: 'j3' })
|
|
132
|
-
const runId = await
|
|
132
|
+
const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j3' }, 'manual')
|
|
133
|
+
// 任务卡回填被拒必留痕:留痕集合即 catch 路径收尾信号(此场景 lastStatus 永不可见)
|
|
134
|
+
await waitFor(() => logLines.length === 1)
|
|
133
135
|
// Then 事实成功保留,不被 catch 覆写为 fail;回填缺口留痕可观测
|
|
134
136
|
assert.equal(store.runs.get(runId).status, 'success')
|
|
135
137
|
assert.equal(logLines.length, 1)
|
|
@@ -143,7 +145,8 @@ test('executor:运行终态补写失败经 logSystem 留痕', async (t) => {
|
|
|
143
145
|
const [runId] = await executor.dispatch({ ...JOB_BASE, id: 'j4' }, 'manual')
|
|
144
146
|
// dispatch 完成(记录已落库、runUnit 已入队)后再让 runs 集合损坏:终态写入必失败
|
|
145
147
|
store.runs.update = async () => { throw new Error('数据文件已损坏已备份,已暂停写入以防数据丢失') }
|
|
146
|
-
|
|
148
|
+
// 补写失败必留痕:以留痕可见替代固定等待,消除时序脆弱
|
|
149
|
+
await waitFor(() => logLines.length === 1)
|
|
147
150
|
// Then 补写失败恰留痕一次,含 runId 与原始错误
|
|
148
151
|
assert.equal(logLines.length, 1)
|
|
149
152
|
assert.ok(logLines[0].includes(runId))
|
package/test/index.test.mjs
CHANGED
|
@@ -185,6 +185,36 @@ test('index:settings 注册 cron-board 命名空间且 timer 承载默认周期
|
|
|
185
185
|
assert.deepEqual(intervals.map((entry) => entry.ms), [30 * 1000])
|
|
186
186
|
})
|
|
187
187
|
|
|
188
|
+
test('index:settings.update 异步拒绝(镜像 rc.1 无命名空间条目)被路由级 catch 接住降级 400,不打崩进程', async () => {
|
|
189
|
+
// Given settings.update 返回 rejected promise 的宿主形态(0.1.7-rc.1 L3 实测:
|
|
190
|
+
// "No configurable plugin entry 'cron-board'" 在异步段抛出,不 await 时逃逸成
|
|
191
|
+
// uncaughtException 打崩宿主;await 后由 api.handle 路由级 catch 接住)
|
|
192
|
+
const { ctx, settingsService } = makeFullCtx()
|
|
193
|
+
settingsService.update = () => Promise.reject(new Error("No configurable plugin entry 'cron-board'"))
|
|
194
|
+
delete settingsService.get
|
|
195
|
+
const captured = []
|
|
196
|
+
const ctx2 = {
|
|
197
|
+
...ctx,
|
|
198
|
+
get(name) {
|
|
199
|
+
if (name === 'settings') return settingsService
|
|
200
|
+
return ctx.get(name)
|
|
201
|
+
},
|
|
202
|
+
webServer: { register(route) { captured.push(route); return () => {} } },
|
|
203
|
+
}
|
|
204
|
+
apply(ctx2)
|
|
205
|
+
const route = captured[0]
|
|
206
|
+
assert.ok(route)
|
|
207
|
+
const res = { status: null, payload: null }
|
|
208
|
+
res.writeHead = (status) => { res.status = status }
|
|
209
|
+
res.end = (text) => { res.payload = JSON.parse(text) }
|
|
210
|
+
const req = { method: 'POST', url: 'http://localhost/api/cron-board/ui-settings', headers: { 'content-type': 'application/json' } }
|
|
211
|
+
req.on = (event, fn) => { if (event === 'data') setImmediate(() => fn(Buffer.from(JSON.stringify({ sidebarTab: true })))); if (event === 'end') setImmediate(fn) }
|
|
212
|
+
await route.handler(req, res)
|
|
213
|
+
// Then 路由级 catch 接住,降级 400 系统级错误响应,进程存活(测试正常走完即证)
|
|
214
|
+
assert.equal(res.status, 400)
|
|
215
|
+
assert.ok(res.payload.error)
|
|
216
|
+
})
|
|
217
|
+
|
|
188
218
|
test('index:tickSeconds 设置变更经 watch 重建 interval', async () => {
|
|
189
219
|
// Given 全服务桩,settings.register 的 watch 回调被捕获
|
|
190
220
|
let watchFn = null
|
|
@@ -203,7 +233,8 @@ test('index:tick 到期任务被调度执行(端到端装配)', async (t) => {
|
|
|
203
233
|
const dir = await mkdtemp(join(tmpdir(), 'cron-board-idx-'))
|
|
204
234
|
t.after(async () => {
|
|
205
235
|
delete process.env.DSH_CRON_BOARD_DATA_DIR
|
|
206
|
-
|
|
236
|
+
// 运行终态可见后执行链仍有后台落盘(任务卡回填/日志写),与目录删除竞态,有界重试消解
|
|
237
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
207
238
|
})
|
|
208
239
|
process.env.DSH_CRON_BOARD_DATA_DIR = dir
|
|
209
240
|
const { ctx, routes, intervals } = makeFullCtx()
|
|
@@ -263,7 +294,188 @@ function waitForRecord(poll, timeoutMs = 10 * 1000) {
|
|
|
263
294
|
}
|
|
264
295
|
setTimeout(tick, 50)
|
|
265
296
|
}).catch(reject)
|
|
297
|
+
}
|
|
298
|
+
tick()
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// --- 0.1.7 settings 面双形适配(maintain a913c2c 同构) ---
|
|
303
|
+
|
|
304
|
+
// 0.1.7 形态装配桩:settings 无 register/get(宿主已移除),config 为整节单 ref 桩,
|
|
305
|
+
// configEditor 桩经 change 合并后原样写回(模拟 loader 仅 volatile 变化的原地热更)
|
|
306
|
+
function makeRc17Ctx({ configStore = {}, configEditorAvailable = true, configureAvailable = true } = {}) {
|
|
307
|
+
const routes = new Map()
|
|
308
|
+
const calls = { editCalls: [], configureCalls: [], registerCalls: [] }
|
|
309
|
+
const config = { get: () => ({ ...configStore }) }
|
|
310
|
+
const settingsService = {}
|
|
311
|
+
if (configureAvailable) {
|
|
312
|
+
settingsService.configure = (presentation, owner) => {
|
|
313
|
+
calls.configureCalls.push({ presentation, owner })
|
|
314
|
+
return () => {}
|
|
266
315
|
}
|
|
267
|
-
|
|
268
|
-
|
|
316
|
+
}
|
|
317
|
+
const configEditor = {
|
|
318
|
+
async edit(entry, change) {
|
|
319
|
+
calls.editCalls.push(entry)
|
|
320
|
+
const next = change({ ...configStore }, {})
|
|
321
|
+
for (const [key, value] of Object.entries(next)) configStore[key] = value
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
const effects = []
|
|
325
|
+
const ctx = {
|
|
326
|
+
calls,
|
|
327
|
+
fiber: { entry: { options: { id: 'cron-board', name: '@mzzsfy/dsh-cron-board' } } },
|
|
328
|
+
effect(fn, label) {
|
|
329
|
+
// 会话等待轮询为真实定时器形态:桩跳过(与 makeFullCtx 同款),其余同步执行
|
|
330
|
+
if (label === 'cron-board session wait') return
|
|
331
|
+
effects.push(fn)
|
|
332
|
+
fn()
|
|
333
|
+
},
|
|
334
|
+
get(name) {
|
|
335
|
+
if (name === 'settings') return settingsService
|
|
336
|
+
if (name === 'configEditor') return configEditorAvailable ? configEditor : undefined
|
|
337
|
+
if (name === 'agents') return { get: () => undefined }
|
|
338
|
+
if (name === 'sessionQuery') return { listSessions: async () => [] }
|
|
339
|
+
if (name === 'workspaceRegistry') return { archivedSessionIds: [] }
|
|
340
|
+
if (name === 'agentPresets') return { defaultId: '', list: async () => [] }
|
|
341
|
+
return undefined
|
|
342
|
+
},
|
|
343
|
+
inject(deps, fn) {
|
|
344
|
+
if (deps[0] === 'timer') {
|
|
345
|
+
fn({ interval(intervalFn) { return () => {} } })
|
|
346
|
+
}
|
|
347
|
+
if (deps[0] === 'settings') {
|
|
348
|
+
fn({
|
|
349
|
+
settings: settingsService,
|
|
350
|
+
effect(stubEffect) {
|
|
351
|
+
const disposer = stubEffect()
|
|
352
|
+
if (typeof disposer === 'function') effects.push(disposer)
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
webServer: {
|
|
358
|
+
register(route) {
|
|
359
|
+
routes.set(route.path, route.handler)
|
|
360
|
+
return () => {}
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
}
|
|
364
|
+
return { ctx, routes, config, configStore, calls, effects }
|
|
269
365
|
}
|
|
366
|
+
|
|
367
|
+
// ui-settings 请求走 prefix 路由(api.handle 按 method+segments 分发);
|
|
368
|
+
// 需真实数据目录(getRuntime 装配 store),env 注入临时目录
|
|
369
|
+
async function callApi(routes, path, body) {
|
|
370
|
+
const handler = routes.get('/api/cron-board')
|
|
371
|
+
assert.ok(handler, 'prefix 路由未注册')
|
|
372
|
+
const res = { status: null, payload: null }
|
|
373
|
+
res.writeHead = (status) => { res.status = status }
|
|
374
|
+
res.end = (text) => { res.payload = text ? JSON.parse(text) : null }
|
|
375
|
+
const req = new EventEmitter()
|
|
376
|
+
req.method = body === undefined ? 'GET' : 'POST'
|
|
377
|
+
req.url = 'http://localhost' + path
|
|
378
|
+
req.headers = { 'content-type': 'application/json' }
|
|
379
|
+
// 装配层初始化后才挂读体监听:桩事件须延后发射(与既有 post 桩同款)
|
|
380
|
+
setTimeout(() => {
|
|
381
|
+
if (body !== undefined) req.emit('data', Buffer.from(JSON.stringify(body)))
|
|
382
|
+
req.emit('end')
|
|
383
|
+
}, 80)
|
|
384
|
+
await handler(req, res)
|
|
385
|
+
return res
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', () => {
|
|
389
|
+
// Given 静态 Config 导出(0.1.7 节表单事实源)
|
|
390
|
+
assert.ok(mod.Config, 'Config 导出必须在场')
|
|
391
|
+
// When 空配置校验
|
|
392
|
+
const resolved = mod.Config['~standard'].validate({})
|
|
393
|
+
// Then 产整节单 ref(get 协议),字段为 schema 默认值
|
|
394
|
+
assert.equal(resolved.issues, undefined)
|
|
395
|
+
const section = resolved.value.get()
|
|
396
|
+
assert.equal(section.tickSeconds, 30)
|
|
397
|
+
assert.equal(section.maxConcurrent, 2)
|
|
398
|
+
assert.equal(section.logKeepPerJob, 200)
|
|
399
|
+
assert.equal(section.maskEnvInPrompt, false)
|
|
400
|
+
assert.equal(section.sidebarTab, false)
|
|
401
|
+
// When 显式值校验
|
|
402
|
+
// Then 值透传
|
|
403
|
+
const provided = mod.Config['~standard'].validate({ sidebarTab: true }).value.get()
|
|
404
|
+
assert.equal(provided.sidebarTab, true)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test('0.1.7 形态:ui-settings GET 读 config 节值(ref 解包),非默认值', async (t) => {
|
|
408
|
+
// Given settings 无 register/get 的宿主 + config ref 携带用户值
|
|
409
|
+
const dir = await mkdtemp(join(tmpdir(), 'cron-board-rc17-'))
|
|
410
|
+
t.after(async () => {
|
|
411
|
+
delete process.env.DSH_CRON_BOARD_DATA_DIR
|
|
412
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
413
|
+
})
|
|
414
|
+
process.env.DSH_CRON_BOARD_DATA_DIR = dir
|
|
415
|
+
const { ctx, routes, config } = makeRc17Ctx({ configStore: { sidebarTab: true, tickSeconds: 60 } })
|
|
416
|
+
apply(ctx, config)
|
|
417
|
+
// When GET ui-settings
|
|
418
|
+
const res = await callApi(routes, '/api/cron-board/status')
|
|
419
|
+
// Then 返回 config 节值
|
|
420
|
+
assert.equal(res.status, 200)
|
|
421
|
+
assert.equal(res.payload.ui.sidebarTab, true)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
test('0.1.7 形态:ui-settings POST 经 configEditor.edit 落 fiber.entry 且持久合并', async (t) => {
|
|
425
|
+
// Given configEditor 桩记录 edit 调用
|
|
426
|
+
const dir = await mkdtemp(join(tmpdir(), 'cron-board-rc17-'))
|
|
427
|
+
t.after(async () => {
|
|
428
|
+
delete process.env.DSH_CRON_BOARD_DATA_DIR
|
|
429
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
430
|
+
})
|
|
431
|
+
process.env.DSH_CRON_BOARD_DATA_DIR = dir
|
|
432
|
+
const { ctx, routes, config, configStore, calls } = makeRc17Ctx({ configStore: { tickSeconds: 60 } })
|
|
433
|
+
apply(ctx, config)
|
|
434
|
+
// When POST 写 sidebarTab
|
|
435
|
+
const res = await callApi(routes, '/api/cron-board/ui-settings', { sidebarTab: true })
|
|
436
|
+
// Then edit 定位本条目,合并写回,响应 200 携新值
|
|
437
|
+
assert.equal(res.status, 200)
|
|
438
|
+
assert.equal(calls.editCalls.length, 1)
|
|
439
|
+
assert.equal(calls.editCalls[0].options.id, 'cron-board')
|
|
440
|
+
assert.equal(configStore.sidebarTab, true)
|
|
441
|
+
assert.equal(configStore.tickSeconds, 60)
|
|
442
|
+
assert.equal(res.payload.ui.sidebarTab, true)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
test('0.1.7 形态:configEditor 缺失即 200 ok:false 拒写,不崩溃', async (t) => {
|
|
446
|
+
// Given configEditor 服务缺席
|
|
447
|
+
const dir = await mkdtemp(join(tmpdir(), 'cron-board-rc17-'))
|
|
448
|
+
t.after(async () => {
|
|
449
|
+
delete process.env.DSH_CRON_BOARD_DATA_DIR
|
|
450
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
451
|
+
})
|
|
452
|
+
process.env.DSH_CRON_BOARD_DATA_DIR = dir
|
|
453
|
+
const { ctx, routes, config } = makeRc17Ctx({ configEditorAvailable: false })
|
|
454
|
+
apply(ctx, config)
|
|
455
|
+
// When POST 写设置
|
|
456
|
+
const res = await callApi(routes, '/api/cron-board/ui-settings', { sidebarTab: true })
|
|
457
|
+
// Then ok:false 拒写(api 层 persisted=false 语义),进程存活(测试走完即证)
|
|
458
|
+
assert.equal(res.status, 200)
|
|
459
|
+
assert.equal(res.payload.ok, false)
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test('0.1.7 形态:settings.configure 在场即关闭原生自动页,缺席静默跳过', () => {
|
|
463
|
+
// Given configure 在场
|
|
464
|
+
const { ctx, calls, config } = makeRc17Ctx()
|
|
465
|
+
apply(ctx, config)
|
|
466
|
+
// Then 关闭自动页,owner 为本插件 fiber
|
|
467
|
+
assert.deepEqual(calls.configureCalls, [{ presentation: { auto: false }, owner: ctx.fiber }])
|
|
468
|
+
// Given configure 缺席
|
|
469
|
+
const bare = makeRc17Ctx({ configureAvailable: false })
|
|
470
|
+
// When apply
|
|
471
|
+
// Then 不抛错(测试走完即证)
|
|
472
|
+
apply(bare.ctx, bare.config)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
test('0.1.7 形态:settings 注入回调不因 register 缺失抛错', () => {
|
|
476
|
+
// Given settings 桩全空方法面(比真实宿主更严)
|
|
477
|
+
const { ctx, config } = makeRc17Ctx({ configureAvailable: false })
|
|
478
|
+
// When apply
|
|
479
|
+
// Then 注入回调静默返回,无异常
|
|
480
|
+
apply(ctx, config)
|
|
481
|
+
})
|
package/test/routes.test.mjs
CHANGED
|
@@ -324,10 +324,11 @@ test('status 路由:ui.sidebarTab 透出用户侧边栏移入偏好', async (t)
|
|
|
324
324
|
})
|
|
325
325
|
|
|
326
326
|
test('ui-settings 路由:布尔补丁经 updateUiSettings 写入并回读', async (t) => {
|
|
327
|
-
// Given 写入桩记录补丁,读取桩翻转返回(模拟 settings.update 后生效)
|
|
327
|
+
// Given 写入桩记录补丁,读取桩翻转返回(模拟 settings.update 后生效);
|
|
328
|
+
// 写入桩 async 返回 true 镜像持久化成功契约(宿主 update 为异步面)
|
|
328
329
|
let stored = false
|
|
329
330
|
const { api } = await makeApi(t, {
|
|
330
|
-
updateUiSettings: (patch) => { if (typeof patch.sidebarTab === 'boolean') stored = patch.sidebarTab },
|
|
331
|
+
updateUiSettings: async (patch) => { if (typeof patch.sidebarTab === 'boolean') { stored = patch.sidebarTab; return true } return false },
|
|
331
332
|
readSidebarTab: () => stored,
|
|
332
333
|
})
|
|
333
334
|
// When POST 开关开
|
|
@@ -465,9 +466,9 @@ test('cron preview 路由:人话摘要与下三次触发', async (t) => {
|
|
|
465
466
|
const { api } = await makeApi(t)
|
|
466
467
|
// When 预览「每天 08:30」
|
|
467
468
|
const ok = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: '30 8 * * *' })
|
|
468
|
-
// Then
|
|
469
|
+
// Then 摘要含每天与时刻并带宿主时区尾注,三次触发时间戳齐备
|
|
469
470
|
assert.equal(ok.status, 200)
|
|
470
|
-
assert.
|
|
471
|
+
assert.match(ok.payload.summary, /^每天 08:30\(宿主 UTC[+-]\d/)
|
|
471
472
|
assert.equal(ok.payload.nextAt.length, 3)
|
|
472
473
|
assert.ok(ok.payload.nextAt.every((value) => typeof value === 'number' && value > Date.now()))
|
|
473
474
|
// When 非法表达式
|
|
@@ -477,6 +478,33 @@ test('cron preview 路由:人话摘要与下三次触发', async (t) => {
|
|
|
477
478
|
assert.match(bad.payload.error, /cron/i)
|
|
478
479
|
})
|
|
479
480
|
|
|
481
|
+
test('jobs 与 preview 路由:时区后缀表达式的调度与校验', async (t) => {
|
|
482
|
+
const { api } = await makeApi(t)
|
|
483
|
+
// Given 带 T+8 后缀的任务
|
|
484
|
+
const created = await call(api, 'POST', '/api/cron-board/jobs', { name: 'tz', kind: 'shell', command: 'x', schedule: '0 9 * * *T+8' })
|
|
485
|
+
assert.equal(created.status, 200)
|
|
486
|
+
// Then 默认触发点按东八区 9:00(01:00Z),与宿主自身时区无关
|
|
487
|
+
assert.equal(new Date(created.payload.nextRunAt).toISOString().slice(11, 16), '01:00')
|
|
488
|
+
// When 非法后缀创建任务
|
|
489
|
+
const bad = await call(api, 'POST', '/api/cron-board/jobs', { name: 'tz2', kind: 'shell', command: 'x', schedule: '0 9 * * *T+15' })
|
|
490
|
+
// Then 400 且报后缀不合法
|
|
491
|
+
assert.equal(bad.status, 400)
|
|
492
|
+
assert.match(bad.payload.error, /时区后缀不合法/)
|
|
493
|
+
// When 带后缀预览
|
|
494
|
+
const preview = await call(api, 'POST', '/api/cron-board/cron/preview', { schedule: '30 8 * * *T+8' })
|
|
495
|
+
// Then 摘要尾注与触发点均为东八区语义
|
|
496
|
+
assert.equal(preview.status, 200)
|
|
497
|
+
assert.equal(preview.payload.summary, '每天 08:30(UTC+8)')
|
|
498
|
+
assert.equal(new Date(preview.payload.nextAt[0]).toISOString().slice(11, 16), '00:30')
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('status 路由:暴露宿主时区偏移 serverTzOffset', async (t) => {
|
|
502
|
+
const { api } = await makeApi(t)
|
|
503
|
+
const res = await call(api, 'GET', '/api/cron-board/status')
|
|
504
|
+
assert.equal(res.status, 200)
|
|
505
|
+
assert.equal(typeof res.payload.serverTzOffset, 'number')
|
|
506
|
+
})
|
|
507
|
+
|
|
480
508
|
test('runs 路由:无参数查全量,带 jobId 查单任务', async (t) => {
|
|
481
509
|
// Given 两个任务各有一条运行记录
|
|
482
510
|
const { store, api } = await makeApi(t)
|
|
@@ -102,6 +102,54 @@ test('字段越界数值反解归 custom,不猜修正', () => {
|
|
|
102
102
|
assert.equal(parseSchedule('* 9 * * *').freq, 'custom')
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
test('时区后缀:反解与回写 T+N', () => {
|
|
106
|
+
const state = parseSchedule('0 9 * * *T+8')
|
|
107
|
+
assert.equal(state.freq, 'daily')
|
|
108
|
+
assert.equal(state.hour, 9)
|
|
109
|
+
assert.equal(state.timezone, 8)
|
|
110
|
+
assert.equal(buildSchedule(state), '0 9 * * *T+8')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('时区后缀:无后缀反解 timezone 为 null,回写不附加', () => {
|
|
114
|
+
const state = parseSchedule('30 8 * * *')
|
|
115
|
+
assert.equal(state.timezone, null)
|
|
116
|
+
assert.equal(buildSchedule(state), '30 8 * * *')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('时区后缀:interval 与工作日形态同样携带', () => {
|
|
120
|
+
const interval = parseSchedule('*/30 * * * *T+0')
|
|
121
|
+
assert.equal(interval.freq, 'interval')
|
|
122
|
+
assert.equal(interval.timezone, 0)
|
|
123
|
+
assert.equal(buildSchedule(interval), '*/30 * * * *T+0')
|
|
124
|
+
const weekdays = parseSchedule('0 9 * * 1-5T-5')
|
|
125
|
+
assert.equal(weekdays.freq, 'weekdays')
|
|
126
|
+
assert.equal(weekdays.timezone, -5)
|
|
127
|
+
assert.equal(buildSchedule(weekdays), '0 9 * * 1-5T-5')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('时区后缀:非法后缀反解 custom 且原文保留,不抛', () => {
|
|
131
|
+
const state = parseSchedule('0 9 * * *T+15')
|
|
132
|
+
assert.equal(state.freq, 'custom')
|
|
133
|
+
assert.equal(state.expression, '0 9 * * *T+15')
|
|
134
|
+
assert.equal(state.timezone, null)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('时区后缀:custom 合法后缀剥离存储,回写统一附加', () => {
|
|
138
|
+
const state = parseSchedule('0 9 * * * 2026T+8')
|
|
139
|
+
assert.equal(state.freq, 'custom')
|
|
140
|
+
assert.equal(state.expression, '0 9 * * * 2026')
|
|
141
|
+
assert.equal(state.timezone, 8)
|
|
142
|
+
assert.equal(buildSchedule(state), '0 9 * * * 2026T+8')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('时区后缀:createScheduleState 默认无后缀,置空回写不带后缀', () => {
|
|
146
|
+
const state = createScheduleState('')
|
|
147
|
+
assert.equal(state.timezone, null)
|
|
148
|
+
state.freq = 'daily'
|
|
149
|
+
state.hour = 2
|
|
150
|
+
assert.equal(buildSchedule(state), '0 2 * * *')
|
|
151
|
+
})
|
|
152
|
+
|
|
105
153
|
test('parity:client.js SBUILD 段与核心实现逐字镜像', () => {
|
|
106
154
|
// Given client 半区无模块系统,构建器逻辑必须内联(经典 script)
|
|
107
155
|
// When 从两侧源码提取函数体文本(SBUILD 标记段 vs schedule-builder.mjs 导出体)
|
|
@@ -115,7 +163,7 @@ test('parity:client.js SBUILD 段与核心实现逐字镜像', () => {
|
|
|
115
163
|
const core = readFileSync(join(pkgRoot, 'src', 'schedule-builder.mjs'), 'utf8')
|
|
116
164
|
// 逐函数对比:剥离 export 关键词后,核心源码的每个可执行行都应出现在 client 镜像段
|
|
117
165
|
const coreLines = core.split('\n')
|
|
118
|
-
.map((line) => line.replace(/^export /, '').replace(/\bFREQS\b/g, 'SB_FREQS').replace(/\bWEEKDAY_ORDER\b/g, 'SB_WEEKDAY_ORDER').replace(/\bWEEKDAY_LABELS\b/g, 'SB_WEEKDAY_LABELS').replace(/\bMINUTE_MAX\b/g, 'SB_MINUTE_MAX').replace(/\bHOUR_MAX\b/g, 'SB_HOUR_MAX').replace(/\bINTERVAL_MIN_MINUTES\b/g, 'SB_INTERVAL_MIN_MINUTES').replace(/\bclampInt\b/g, 'sbClampInt').trim())
|
|
166
|
+
.map((line) => line.replace(/^export /, '').replace(/\bFREQS\b/g, 'SB_FREQS').replace(/\bWEEKDAY_ORDER\b/g, 'SB_WEEKDAY_ORDER').replace(/\bWEEKDAY_LABELS\b/g, 'SB_WEEKDAY_LABELS').replace(/\bMINUTE_MAX\b/g, 'SB_MINUTE_MAX').replace(/\bHOUR_MAX\b/g, 'SB_HOUR_MAX').replace(/\bINTERVAL_MIN_MINUTES\b/g, 'SB_INTERVAL_MIN_MINUTES').replace(/\bTZ_SUFFIX_MIN\b/g, 'SB_TZ_SUFFIX_MIN').replace(/\bTZ_SUFFIX_MAX\b/g, 'SB_TZ_SUFFIX_MAX').replace(/\bTZ_SUFFIX_PATTERN\b/g, 'SB_TZ_SUFFIX_PATTERN').replace(/\bsplitScheduleTz\b/g, 'sbSplitScheduleTz').replace(/\bformatTzOffset\b/g, 'sbFormatTzOffset').replace(/\bbuildBase\b/g, 'sbBuildBase').replace(/\bclampInt\b/g, 'sbClampInt').trim())
|
|
119
167
|
.filter((line) => line !== '' && !line.startsWith('//'))
|
|
120
168
|
for (const line of coreLines) {
|
|
121
169
|
assert.ok(block.includes(line), 'client 镜像段缺少核心行: ' + line)
|
|
@@ -109,6 +109,11 @@ test('client.js 主页面双形态挂载契约(默认宿主全局面板;设置
|
|
|
109
109
|
assert.match(source, /function CronBoardPluginCard/)
|
|
110
110
|
assert.match(source, /'ui-settings'/)
|
|
111
111
|
assert.match(source, /sidebarReady/)
|
|
112
|
+
// 插件管理页包详情配置卡(0.1.7-rc.1+ plugins.bundle.config,key=包名);两代槽位并存注册,
|
|
113
|
+
// 各自宿主只消费其一(旧宿主无 plugins.bundle.config 消费方,rc.1 无 settings.plugin.item 消费方)
|
|
114
|
+
assert.match(source, /const PKG_ID = '@mzzsfy\/dsh-cron-board'/)
|
|
115
|
+
assert.match(source, /ctx\.slots\.inject\('plugins\.bundle\.config'/)
|
|
116
|
+
assert.match(source, /name: 'plugins\.bundle\.config', key: PKG_ID/)
|
|
112
117
|
// 挂载仲裁:偏好(status.ui.sidebarTab)驱动全局面板/扩展槽互斥
|
|
113
118
|
assert.match(source, /applyPref\(\)/)
|
|
114
119
|
assert.match(source, /ui\.sidebarTab/)
|