@mzzsfy/dsh-cron-board 0.1.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.
@@ -0,0 +1,383 @@
1
+ // scheduler 测试:到期触发 / misfire / 孤儿收尾 / 重复触发防护(设计 §4.1、§7 BDD 调度组)。
2
+ // 虚拟时钟:now 注入,虚拟时间推进驱动 tick。
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { mkdtemp, rm } from 'node:fs/promises'
7
+ import { tmpdir } from 'node:os'
8
+ import { join } from 'node:path'
9
+
10
+ import { createStore } from '../src/store.mjs'
11
+ import { createLogger } from '../src/logger.mjs'
12
+ import { createExecutor } from '../src/executor.mjs'
13
+ import { createScheduler } from '../src/scheduler.mjs'
14
+
15
+ const TICK_MS = 30 * 1000
16
+
17
+ // 可控 runner:执行挂起直到测试放行
18
+ function stubRunner() {
19
+ const pending = []
20
+ return {
21
+ pending,
22
+ releaseAll(status = 'success') {
23
+ while (pending.length > 0) pending.shift()({ status, exitCode: 0 })
24
+ },
25
+ run() {
26
+ return new Promise((resolve) => { pending.push(resolve) })
27
+ },
28
+ }
29
+ }
30
+
31
+ async function makeScheduler(t, { maxConcurrent = 2 } = {}) {
32
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-sched-'))
33
+ // Windows 下并行测试时 Defender/索引器短暂锁目录,rmdir 报瞬态 EBUSY,有界重试消解
34
+ t.after(() => rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
35
+ const store = await createStore({ dir })
36
+ const logger = createLogger({ rootDir: join(dir, 'logs') })
37
+ const runner = stubRunner()
38
+ const executor = createExecutor({ store, logger, runner, readMaxConcurrent: () => maxConcurrent })
39
+ let nowMs = Date.now()
40
+ const scheduler = createScheduler({
41
+ store,
42
+ executor,
43
+ readTickMs: () => TICK_MS,
44
+ now: () => nowMs,
45
+ })
46
+ return {
47
+ store,
48
+ runner,
49
+ scheduler,
50
+ advance: (ms) => { nowMs += ms },
51
+ now: () => nowMs,
52
+ }
53
+ }
54
+
55
+ test('scheduler:到期触发产生 cron RunRecord 且 nextRunAt 先推进', async (t) => {
56
+ // Given 启用任务 nextRunAt 刚过期(宽限内)
57
+ const { store, runner, scheduler, advance } = await makeScheduler(t)
58
+ const job = await store.jobs.create({
59
+ name: '签到', kind: 'shell', command: 'echo hi', schedule: '30 8 * * *',
60
+ enabled: true, timeoutMs: 60 * 1000, nextRunAt: Date.now() + TICK_MS,
61
+ })
62
+ advance(TICK_MS + 1000)
63
+ // When 时钟到达触发点后的首个 tick
64
+ await scheduler.tick()
65
+ // Then 记录预占且 nextRunAt 已推进到未来(先于执行完成)
66
+ const rows = store.runs.list(job.id)
67
+ assert.equal(rows.length, 1)
68
+ assert.equal(rows[0].trigger, 'cron')
69
+ const updated = store.jobs.get(job.id)
70
+ assert.ok(updated.nextRunAt > Date.now())
71
+ assert.ok(updated.lastRunAt > 0)
72
+ // Then 执行放行后状态流转到 success(等执行单元真正开始再放行,终态写轮询等待)
73
+ await waitFor(() => (runner.pending.length > 0 ? true : null))
74
+ runner.releaseAll()
75
+ const finished = await waitFor(() => {
76
+ const record = store.runs.get(rows[0].runId)
77
+ return record && record.status !== 'queued' && record.status !== 'running' ? record : null
78
+ })
79
+ assert.equal(finished.status, 'success')
80
+ })
81
+
82
+ test('scheduler:同一触发点连续两次 tick 不重复触发', async (t) => {
83
+ const { store, runner, scheduler, advance } = await makeScheduler(t)
84
+ const job = await store.jobs.create({
85
+ name: 'j', kind: 'shell', command: 'x', schedule: '* * * * *',
86
+ enabled: true, timeoutMs: 1000, nextRunAt: Date.now() + 100,
87
+ })
88
+ advance(200)
89
+ // When 未再推进时钟连续两次 tick
90
+ await scheduler.tick()
91
+ await scheduler.tick()
92
+ // Then 仅一次触发(nextRunAt 首次 tick 已推进)
93
+ assert.equal(store.runs.list(job.id).length, 1)
94
+ // 收尾:等执行单元开始并放行到终态,再删临时目录
95
+ await waitFor(() => (runner.pending.length > 0 ? true : null))
96
+ runner.releaseAll()
97
+ await waitFor(() => {
98
+ const rows2 = store.runs.list(job.id)
99
+ return rows2.length === 1 && rows2[0].status !== 'queued' && rows2[0].status !== 'running' ? true : null
100
+ })
101
+ })
102
+
103
+ test('scheduler:停机期间错过不补跑,记 skipped 且推进到未来', async (t) => {
104
+ // Given nextRunAt 过期远超一个 tick 周期
105
+ const { store, scheduler, advance } = await makeScheduler(t)
106
+ const job = await store.jobs.create({
107
+ name: 'j', kind: 'shell', command: 'x', schedule: '0 23 * * *',
108
+ enabled: true, timeoutMs: 1000, nextRunAt: Date.now(),
109
+ })
110
+ advance(TICK_MS * 10)
111
+ // When tick
112
+ await scheduler.tick()
113
+ // Then skipped(message=停机期间错过)且 nextRunAt 在未来,未执行
114
+ const rows = store.runs.list(job.id)
115
+ assert.equal(rows.length, 1)
116
+ assert.equal(rows[0].status, 'skipped')
117
+ assert.equal(rows[0].message, '停机期间错过')
118
+ assert.ok(store.jobs.get(job.id).nextRunAt > Date.now())
119
+ })
120
+
121
+ test('scheduler:禁用任务不触发', async (t) => {
122
+ const { store, scheduler, advance } = await makeScheduler(t)
123
+ const job = await store.jobs.create({
124
+ name: 'j', kind: 'shell', command: 'x', schedule: '* * * * *',
125
+ enabled: false, timeoutMs: 1000, nextRunAt: Date.now() - 1000,
126
+ })
127
+ advance(TICK_MS + 1000)
128
+ await scheduler.tick()
129
+ assert.equal(store.runs.list(job.id).length, 0)
130
+ })
131
+
132
+ test('scheduler:recover 将遗留 queued/running 孤儿记录标 interrupted', async (t) => {
133
+ // Given runs.json 遗留 queued 与 running 记录(上次进程遗留)
134
+ const { store, scheduler } = await makeScheduler(t)
135
+ await store.runs.create({ runId: 'r-queued', jobId: 'j1', trigger: 'cron', status: 'queued' })
136
+ await store.runs.create({ runId: 'r-running', jobId: 'j1', trigger: 'cron', status: 'running' })
137
+ await store.runs.create({ runId: 'r-done', jobId: 'j1', trigger: 'cron', status: 'success' })
138
+ // When 重启恢复
139
+ await scheduler.recover()
140
+ // Then 孤儿标 interrupted(message=dsh 重启中断),终态记录不动
141
+ assert.equal(store.runs.get('r-queued').status, 'interrupted')
142
+ assert.equal(store.runs.get('r-queued').message, 'dsh 重启中断')
143
+ assert.equal(store.runs.get('r-running').status, 'interrupted')
144
+ assert.equal(store.runs.get('r-done').status, 'success')
145
+ })
146
+
147
+ test('scheduler:并发闸门——maxConcurrent=1 时第二个单元排队,首个完成后推进', async (t) => {
148
+ // Given 两个任务同时到期,全局并发 1
149
+ const { store, runner, scheduler, advance } = await makeScheduler(t, { maxConcurrent: 1 })
150
+ const j1 = await store.jobs.create({
151
+ name: 'j1', kind: 'shell', command: 'x', schedule: '* * * * *',
152
+ enabled: true, timeoutMs: 1000, nextRunAt: Date.now() + 100,
153
+ })
154
+ const j2 = await store.jobs.create({
155
+ name: 'j2', kind: 'shell', command: 'x', schedule: '* * * * *',
156
+ enabled: true, timeoutMs: 1000, nextRunAt: Date.now() + 100,
157
+ })
158
+ await advance(200)
159
+ await scheduler.tick()
160
+ // 等第一组真正开始(链上写排空后才调 runner.run)
161
+ await waitFor(() => (runner.pending.length > 0 ? true : null))
162
+ // Then 仅第一个进入 running,第二个 queued
163
+ assert.equal(runner.pending.length, 1)
164
+ assert.equal(store.runs.list(j1.id)[0].status, 'running')
165
+ assert.equal(store.runs.list(j2.id)[0].status, 'queued')
166
+ // When 首个放行
167
+ runner.releaseAll()
168
+ await waitFor(() => (runner.pending.length > 0 ? true : null))
169
+ // Then 第二个获得许可进入 running
170
+ assert.equal(runner.pending.length, 1)
171
+ assert.equal(store.runs.list(j2.id)[0].status, 'running')
172
+ // 收尾:等终态写落定再删临时目录
173
+ runner.releaseAll()
174
+ await waitFor(() => {
175
+ const rows2 = [...store.runs.list(j1.id), ...store.runs.list(j2.id)]
176
+ return rows2.every((row) => row.status !== 'queued' && row.status !== 'running') ? true : null
177
+ })
178
+ })
179
+
180
+ test('scheduler:任务级 concurrency 收紧全局上限', async (t) => {
181
+ // Given 单任务同名多值两组展开,全局并发 2,任务级 concurrency=1
182
+ const { store, runner, scheduler, advance } = await makeScheduler(t, { maxConcurrent: 2 })
183
+ await store.envs.create({ name: 'A', value: '1', enabled: true })
184
+ await store.envs.create({ name: 'A', value: '2', enabled: true })
185
+ const job = await store.jobs.create({
186
+ name: 'j', kind: 'shell', command: 'x', schedule: '* * * * *',
187
+ enabled: true, timeoutMs: 1000, concurrency: 1, nextRunAt: Date.now() + 100,
188
+ })
189
+ advance(200)
190
+ await scheduler.tick()
191
+ // 等第一组真正开始(链上写排空后才调 runner.run)
192
+ await waitFor(() => (runner.pending.length > 0 ? true : null))
193
+ // Then 同任务仅一组并行
194
+ assert.equal(runner.pending.length, 1)
195
+ assert.equal(store.runs.list(job.id).length, 2)
196
+ runner.releaseAll()
197
+ await waitFor(() => (runner.pending.length > 0 ? true : null))
198
+ assert.equal(runner.pending.length, 1)
199
+ // 收尾:等终态写落定再删临时目录
200
+ runner.releaseAll()
201
+ await waitFor(() => {
202
+ const rows2 = store.runs.list(job.id)
203
+ return rows2.every((row) => row.status !== 'queued' && row.status !== 'running') ? true : null
204
+ })
205
+ })
206
+
207
+ test('scheduler:status 报告活跃与队列深度', async (t) => {
208
+ const { scheduler } = await makeScheduler(t)
209
+ assert.deepEqual(scheduler.status(), { active: 0, queued: 0 })
210
+ })
211
+
212
+ test('scheduler:窗口内正常发起,窗口外 skip 记录且节奏不变', async (t) => {
213
+ // Given 会话任务窗口 09:00-23:00,onMiss=skip,注入会话 runner 桩
214
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-win-'))
215
+ t.after(() => rm(dir, { recursive: true, force: true }))
216
+ const store = await createStore({ dir })
217
+ const logger = createLogger({ rootDir: join(dir, 'logs') })
218
+ const sessionRuns = []
219
+ const sessionRunner = { run: async () => { sessionRuns.push(1); return { status: 'success' } } }
220
+ const executor = createExecutor({ store, logger, sessionRunner, readMaxConcurrent: () => 2 })
221
+ // 虚拟时钟锚定今日 08:00(窗口外)
222
+ const base = new Date(); base.setHours(8, 0, 0, 0)
223
+ let nowMs = base.getTime()
224
+ const scheduler = createScheduler({ store, executor, readTickMs: () => TICK_MS, now: () => nowMs })
225
+ const job = await store.jobs.create({
226
+ name: 'w', kind: 'session', prompt: '早报', schedule: '0 8 * * *', enabled: true,
227
+ timeoutMs: 60 * 1000, session: { mode: 'fresh', windowStart: '09:00', windowEnd: '23:00', onMiss: 'skip' },
228
+ })
229
+ await store.jobs.update(job.id, { nextRunAt: nowMs })
230
+ // When tick(触发点 08:00,窗口外)
231
+ await scheduler.tick()
232
+ // Then skipped(窗口外跳过),未发起,节奏正常推进(明日 08:00)
233
+ const rows = store.runs.list(job.id)
234
+ assert.equal(rows.length, 1)
235
+ assert.equal(rows[0].status, 'skipped')
236
+ assert.equal(rows[0].message, '窗口外跳过')
237
+ assert.equal(sessionRuns.length, 0)
238
+ const tomorrow8 = new Date(base); tomorrow8.setDate(tomorrow8.getDate() + 1)
239
+ assert.equal(store.jobs.get(job.id).nextRunAt, tomorrow8.getTime())
240
+ })
241
+
242
+ test('scheduler:窗口外 defer 挂起至窗口起点后发起', async (t) => {
243
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-defer-'))
244
+ t.after(() => rm(dir, { recursive: true, force: true }))
245
+ const store = await createStore({ dir })
246
+ const logger = createLogger({ rootDir: join(dir, 'logs') })
247
+ const sessionRuns = []
248
+ const sessionRunner = { run: async () => { sessionRuns.push(1); return { status: 'success' } } }
249
+ const executor = createExecutor({ store, logger, sessionRunner, readMaxConcurrent: () => 2 })
250
+ // defer 定时桩:捕获 (at, fn),测试手动放行
251
+ const deferred = []
252
+ const base = new Date(); base.setHours(8, 0, 0, 0)
253
+ let nowMs = base.getTime()
254
+ const scheduler = createScheduler({
255
+ store, executor, readTickMs: () => TICK_MS, now: () => nowMs,
256
+ scheduleAt: (at, fn) => { deferred.push({ at, fn }) },
257
+ })
258
+ const job = await store.jobs.create({
259
+ name: 'w', kind: 'session', prompt: '早报', schedule: '0 8 * * *', enabled: true,
260
+ timeoutMs: 60 * 1000, session: { mode: 'fresh', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer' },
261
+ })
262
+ await store.jobs.update(job.id, { nextRunAt: nowMs })
263
+ // When tick(窗口外 defer)
264
+ await scheduler.tick()
265
+ // Then 立即不发起,挂起点 = 今日 09:00
266
+ assert.equal(sessionRuns.length, 0)
267
+ assert.equal(deferred.length, 1)
268
+ const at = new Date(deferred[0].at)
269
+ assert.equal(at.getHours(), 9)
270
+ assert.equal(at.getMinutes(), 0)
271
+ // When 挂起到点放行(真实时钟推进到窗口起点,重读后窗口内才发起)
272
+ nowMs = at.getTime()
273
+ deferred[0].fn()
274
+ await tickMillis(50)
275
+ // Then 发起会话
276
+ assert.equal(sessionRuns.length, 1)
277
+ // 且 cron 节奏不变(明日 08:00)
278
+ const tomorrow8 = new Date(base); tomorrow8.setDate(tomorrow8.getDate() + 1)
279
+ assert.equal(store.jobs.get(job.id).nextRunAt, tomorrow8.getTime())
280
+ })
281
+
282
+ test('scheduler:窗口外 defer 到点重读任务——已禁用任务放弃发起(不吃旧快照)', async (t) => {
283
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-defer2-'))
284
+ t.after(() => rm(dir, { recursive: true, force: true }))
285
+ const store = await createStore({ dir })
286
+ const logger = createLogger({ rootDir: join(dir, 'logs') })
287
+ const sessionRuns = []
288
+ const sessionRunner = { run: async () => { sessionRuns.push(1); return { status: 'success' } } }
289
+ const executor = createExecutor({ store, logger, sessionRunner, readMaxConcurrent: () => 2 })
290
+ const deferred = []
291
+ const base = new Date(); base.setHours(8, 0, 0, 0)
292
+ let nowMs = base.getTime()
293
+ const scheduler = createScheduler({
294
+ store, executor, readTickMs: () => TICK_MS, now: () => nowMs,
295
+ scheduleAt: (at, fn) => { deferred.push({ at, fn }); return () => {} },
296
+ })
297
+ const job = await store.jobs.create({
298
+ name: 'w', kind: 'session', prompt: '早报', schedule: '0 8 * * *', enabled: true,
299
+ timeoutMs: 60 * 1000, session: { mode: 'fresh', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer' },
300
+ })
301
+ await store.jobs.update(job.id, { nextRunAt: nowMs })
302
+ await scheduler.tick()
303
+ assert.equal(deferred.length, 1)
304
+ // When 挂起期间任务被禁用,到点放行
305
+ await store.jobs.update(job.id, { enabled: false })
306
+ deferred[0].fn()
307
+ await tickMillis(50)
308
+ // Then 不发起
309
+ assert.equal(sessionRuns.length, 0)
310
+ })
311
+
312
+ test('scheduler:dispose 清除未到点的 defer 挂起(生命周期外零触发)', async (t) => {
313
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-defer3-'))
314
+ t.after(() => rm(dir, { recursive: true, force: true }))
315
+ const store = await createStore({ dir })
316
+ const logger = createLogger({ rootDir: join(dir, 'logs') })
317
+ const sessionRuns = []
318
+ const sessionRunner = { run: async () => { sessionRuns.push(1); return { status: 'success' } } }
319
+ const executor = createExecutor({ store, logger, sessionRunner, readMaxConcurrent: () => 2 })
320
+ const deferred = []
321
+ const cancels = []
322
+ const base = new Date(); base.setHours(8, 0, 0, 0)
323
+ let nowMs = base.getTime()
324
+ const scheduler = createScheduler({
325
+ store, executor, readTickMs: () => TICK_MS, now: () => nowMs,
326
+ scheduleAt: (at, fn) => { deferred.push({ at, fn }); cancels.push(0); return () => { cancels[cancels.length - 1] = 1 } },
327
+ })
328
+ const job = await store.jobs.create({
329
+ name: 'w', kind: 'session', prompt: '早报', schedule: '0 8 * * *', enabled: true,
330
+ timeoutMs: 60 * 1000, session: { mode: 'fresh', windowStart: '09:00', windowEnd: '23:00', onMiss: 'defer' },
331
+ })
332
+ await store.jobs.update(job.id, { nextRunAt: nowMs })
333
+ await scheduler.tick()
334
+ // When dispose 后再放行挂起回调
335
+ scheduler.dispose()
336
+ assert.equal(cancels[0], 1)
337
+ deferred[0].fn()
338
+ await tickMillis(50)
339
+ // Then 不发起
340
+ assert.equal(sessionRuns.length, 0)
341
+ })
342
+
343
+ test('scheduler:tick 重入闸门——上一轮未完成时下一轮直接跳过(防同点双触发)', async (t) => {
344
+ const { store, runner, scheduler, advance } = await makeScheduler(t)
345
+ await store.jobs.create({
346
+ name: '单发', kind: 'shell', command: 'echo hi', schedule: '*/5 * * * *',
347
+ enabled: true, timeoutMs: 60 * 1000, nextRunAt: Date.now() + TICK_MS,
348
+ })
349
+ advance(TICK_MS + 1000)
350
+ // When 同一 tick 未完成时并发发起第二次 tick(store 写链让首 tick 至少跨越一个微任务)
351
+ const first = scheduler.tick()
352
+ const second = scheduler.tick()
353
+ await Promise.all([first, second])
354
+ // Then 仅预占一条运行记录
355
+ const rows = store.runs.list()
356
+ assert.equal(rows.length, 1)
357
+ await waitFor(() => (runner.pending.length > 0 ? true : null))
358
+ runner.releaseAll()
359
+ })
360
+
361
+ async function tickMillis(ms) {
362
+ await new Promise((resolve) => setTimeout(resolve, ms))
363
+ }
364
+
365
+ // 轮询等待:超时抛错(链上写时长不定,固定 sleep 不可靠)
366
+ function waitFor(pick, timeoutMs = 10 * 1000) {
367
+ return new Promise((resolve, reject) => {
368
+ const started = Date.now()
369
+ const tick = () => {
370
+ const value = pick()
371
+ if (value) {
372
+ resolve(value)
373
+ return
374
+ }
375
+ if (Date.now() - started > timeoutMs) {
376
+ reject(new Error('waitFor 超时'))
377
+ return
378
+ }
379
+ setTimeout(tick, 20)
380
+ }
381
+ tick()
382
+ })
383
+ }
@@ -0,0 +1,161 @@
1
+ // session-driver 测试:HostApiDriver 会话编排语义(设计 §4.3、§7 BDD 会话组)。
2
+ // 桩替 sessionController/agents/sessionQuery,验证 fresh/pinned/忙跳过/丢失自愈/超时/完成判定。
3
+
4
+ import { test } from 'node:test'
5
+ import assert from 'node:assert/strict'
6
+ import { setTimeout as delay } from 'node:timers/promises'
7
+
8
+ import { createSessionDriver } from '../src/session-driver.mjs'
9
+
10
+ // 可控桩:会话注册表 + 控制器(autoIdle 模拟宿主投递后处理完成转 idle;超时场景关掉)
11
+ function makeStubs({ autoIdle = true } = {}) {
12
+ const sessions = new Map()
13
+ let nextId = 0
14
+ const created = []
15
+ const prompts = []
16
+ const sessionController = {
17
+ async create({ cwd } = {}) {
18
+ const sessionId = 's-' + (++nextId)
19
+ sessions.set(sessionId, { id: sessionId, cwd, status: 'running' })
20
+ created.push({ sessionId, cwd })
21
+ return { sessionId }
22
+ },
23
+ async prompt(args) {
24
+ prompts.push(args)
25
+ if (autoIdle) {
26
+ queueMicrotask(() => {
27
+ const entry = sessions.get(args.sessionId)
28
+ if (entry) entry.status = 'idle'
29
+ })
30
+ }
31
+ return { accepted: true }
32
+ },
33
+ }
34
+ const agents = {
35
+ get: (id) => sessions.get(id),
36
+ }
37
+ const sessionQuery = {
38
+ async listSessions() {
39
+ return [...sessions.values()].map((entry) => ({ id: entry.id }))
40
+ },
41
+ }
42
+ return { sessions, created, prompts, sessionController, agents, sessionQuery }
43
+ }
44
+
45
+ function makeDriver(stubs, overrides = {}) {
46
+ return createSessionDriver({
47
+ sessionController: stubs.sessionController,
48
+ agents: stubs.agents,
49
+ sessionQuery: stubs.sessionQuery,
50
+ pollIntervalMs: 5,
51
+ ...overrides,
52
+ })
53
+ }
54
+
55
+ const BASE_JOB = { id: 'j1', kind: 'session', prompt: '跑日报', session: { mode: 'fresh' }, timeoutMs: 60 * 1000 }
56
+ const PINNED_S1 = { ...BASE_JOB, session: { mode: 'pinned', pinnedSessionId: 's-1' } }
57
+ const PINNED_GONE = { ...BASE_JOB, session: { mode: 'pinned', pinnedSessionId: 'gone' } }
58
+
59
+ test('session-driver:fresh 每次新建,两次触发不同 sessionId', async () => {
60
+ const stubs = makeStubs()
61
+ const driver = makeDriver(stubs)
62
+ // When 同一 fresh 任务发起两次
63
+ const first = await driver.run({ job: BASE_JOB, env: {} })
64
+ const second = await driver.run({ job: BASE_JOB, env: {} })
65
+ // Then 两个不同会话,均被投递
66
+ assert.notEqual(first.sessionId, second.sessionId)
67
+ assert.equal(stubs.created.length, 2)
68
+ assert.equal(stubs.prompts.length, 2)
69
+ assert.equal(first.status, 'success')
70
+ })
71
+
72
+ test('session-driver:pinned 复用同一会话,投递指向 S1', async () => {
73
+ const stubs = makeStubs()
74
+ stubs.sessions.set('s-1', { id: 's-1', status: 'idle' })
75
+ const driver = makeDriver(stubs)
76
+ // When pinned 任务触发
77
+ const outcome = await driver.run({ job: PINNED_S1, env: {} })
78
+ // Then 向 S1 投递且 outcome.sessionId=S1
79
+ assert.equal(outcome.status, 'success')
80
+ assert.equal(outcome.sessionId, 's-1')
81
+ assert.equal(stubs.prompts[0].sessionId, 's-1')
82
+ assert.equal(stubs.prompts[0].mode, 'queue')
83
+ assert.equal(stubs.created.length, 0)
84
+ })
85
+
86
+ test('session-driver:pinned 忙时跳过,不投递', async () => {
87
+ const stubs = makeStubs()
88
+ stubs.sessions.set('s-1', { id: 's-1', status: 'running' })
89
+ const driver = makeDriver(stubs)
90
+ // When pinned 会话正在运行
91
+ const outcome = await driver.run({ job: PINNED_S1, env: {} })
92
+ // Then skipped(上一轮仍在进行)且未新建未投递
93
+ assert.equal(outcome.status, 'skipped')
94
+ assert.equal(outcome.message, '上一轮仍在进行')
95
+ assert.equal(stubs.prompts.length, 0)
96
+ assert.equal(stubs.created.length, 0)
97
+ })
98
+
99
+ test('session-driver:pinned 会话丢失自愈:新建并回写', async () => {
100
+ const stubs = makeStubs()
101
+ const driver = makeDriver(stubs)
102
+ // When pinned 指向的会话不存在
103
+ const outcome = await driver.run({ job: PINNED_GONE, env: {} })
104
+ // Then 新建会话顶替,message 含「原会话丢失已重建」
105
+ assert.equal(outcome.status, 'success')
106
+ assert.equal(outcome.pinnedNewId, 's-1')
107
+ assert.ok(String(outcome.message).includes('原会话丢失已重建'))
108
+ assert.equal(stubs.created.length, 1)
109
+ })
110
+
111
+ test('session-driver:投递被拒绝记 fail', async () => {
112
+ const stubs = makeStubs()
113
+ stubs.sessionController.prompt = async () => ({ accepted: false })
114
+ const driver = makeDriver(stubs)
115
+ const outcome = await driver.run({ job: BASE_JOB, env: {} })
116
+ assert.equal(outcome.status, 'fail')
117
+ assert.ok(outcome.message)
118
+ })
119
+
120
+ test('session-driver:发起成功即写 RunRecord.sessionId', async () => {
121
+ const stubs = makeStubs()
122
+ const driver = makeDriver(stubs)
123
+ const updates = []
124
+ // When 发起(updateRecord 回调捕获)
125
+ await driver.run({ job: BASE_JOB, env: {}, updateRecord: (patch) => updates.push(patch) })
126
+ // Then sessionId 在完成等待前已写入
127
+ assert.ok(updates.some((patch) => typeof patch.sessionId === 'string' && patch.sessionId !== ''))
128
+ })
129
+
130
+ test('session-driver:会话 idle 即完成(success)', async () => {
131
+ const stubs = makeStubs()
132
+ const driver = makeDriver(stubs)
133
+ // When 发起后把会话置 idle(模拟完成)
134
+ const pending = driver.run({ job: BASE_JOB, env: {} })
135
+ await delay(30)
136
+ const sessionId = stubs.created[0].sessionId
137
+ stubs.sessions.get(sessionId).status = 'idle'
138
+ const outcome = await pending
139
+ // Then success 且 RunRecord.sessionId 已写
140
+ assert.equal(outcome.status, 'success')
141
+ assert.equal(outcome.sessionId, sessionId)
142
+ })
143
+
144
+ test('session-driver:timeoutMs 到点记 timeout', async () => {
145
+ // 会话持续 running(autoIdle 关),轮询到超时
146
+ const stubs = makeStubs({ autoIdle: false })
147
+ const driver = makeDriver(stubs, { pollIntervalMs: 5 })
148
+ const outcome = await driver.run({ job: { ...BASE_JOB, timeoutMs: 40 }, env: {} })
149
+ assert.equal(outcome.status, 'timeout')
150
+ })
151
+
152
+ test('session-driver:prompt 携带变量折叠文本', async () => {
153
+ const stubs = makeStubs()
154
+ const driver = makeDriver(stubs)
155
+ // When 带环境变量发起
156
+ await driver.run({ job: BASE_JOB, env: { CITY: 'hangzhou' }, mask: false })
157
+ // Then 投递文本含 prompt 与变量段
158
+ const text = stubs.prompts[0].content[0].text
159
+ assert.ok(text.includes('跑日报'))
160
+ assert.ok(text.includes('CITY=hangzhou'))
161
+ })
@@ -0,0 +1,87 @@
1
+ // ShellRunner 测试:child_process 真实 spawn,跨平台用 node -e 构造用例。
2
+
3
+ import { test } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { mkdtemp, rm } from 'node:fs/promises'
6
+ import { tmpdir } from 'node:os'
7
+ import { join } from 'node:path'
8
+
9
+ import { createShellRunner } from '../src/shell-runner.mjs'
10
+
11
+ // 便捷断言:等待执行结束并收集日志
12
+ async function runAndCollect(runner, { command, env, timeoutMs, workdir }) {
13
+ const chunks = []
14
+ const outcome = await runner.run({
15
+ job: { command, workdir, timeoutMs: timeoutMs ?? 10 * 1000 },
16
+ env: env || {},
17
+ logSink: { append: (chunk) => { chunks.push(chunk) } },
18
+ })
19
+ return { outcome, log: chunks.join('') }
20
+ }
21
+
22
+ test('shell-runner:命令成功退出码 0 记 success,输出进日志', async (t) => {
23
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-runner-'))
24
+ t.after(() => rm(dir, { recursive: true, force: true }))
25
+ const runner = createShellRunner({ workdirFallback: dir })
26
+ // When 执行成功命令
27
+ const { outcome, log } = await runAndCollect(runner, { command: `${process.execPath} -e "console.log('marker-ok')"` })
28
+ // Then success 且日志含输出
29
+ assert.equal(outcome.status, 'success')
30
+ assert.equal(outcome.exitCode, 0)
31
+ assert.match(log, /marker-ok/)
32
+ })
33
+
34
+ test('shell-runner:非零退出记 fail 并保留退出码', async (t) => {
35
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-runner-'))
36
+ t.after(() => rm(dir, { recursive: true, force: true }))
37
+ const runner = createShellRunner({ workdirFallback: dir })
38
+ // When 执行 exit 3
39
+ const { outcome, log } = await runAndCollect(runner, { command: `${process.execPath} -e "process.stderr.write('bad'); process.exit(3)"` })
40
+ // Then fail 且 stderr 合流入日志
41
+ assert.equal(outcome.status, 'fail')
42
+ assert.equal(outcome.exitCode, 3)
43
+ assert.match(log, /bad/)
44
+ })
45
+
46
+ test('shell-runner:环境变量注入叠加 process.env 基础之上', async (t) => {
47
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-runner-'))
48
+ t.after(() => rm(dir, { recursive: true, force: true }))
49
+ const runner = createShellRunner({ workdirFallback: dir })
50
+ // When 注入 CB_TEST_VAR 并回读 env 与基础 PATH
51
+ const { log } = await runAndCollect(runner, {
52
+ command: `${process.execPath} -e "console.log('V=' + process.env.CB_TEST_VAR); console.log('HAS_PATH=' + Boolean(process.env.PATH))"`,
53
+ env: { CB_TEST_VAR: 'injected' },
54
+ })
55
+ // Then 注入值可见且基础 env 未丢
56
+ assert.match(log, /V=injected/)
57
+ assert.match(log, /HAS_PATH=true/)
58
+ })
59
+
60
+ test('shell-runner:workdir 作为子进程 cwd', async (t) => {
61
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-runner-'))
62
+ t.after(() => rm(dir, { recursive: true, force: true }))
63
+ const runner = createShellRunner({ workdirFallback: dir })
64
+ // When 声明 workdir 执行回显 cwd
65
+ const { log } = await runAndCollect(runner, {
66
+ command: `${process.execPath} -e "console.log(process.cwd())"`,
67
+ workdir: dir,
68
+ })
69
+ // Then cwd 即声明目录
70
+ assert.match(log, new RegExp(dir.replace(/[/\\]/g, '[/\\\\]')))
71
+ })
72
+
73
+ test('shell-runner:超时终止进程记 timeout', async (t) => {
74
+ const dir = await mkdtemp(join(tmpdir(), 'cron-board-runner-'))
75
+ t.after(() => rm(dir, { recursive: true, force: true }))
76
+ const runner = createShellRunner({ workdirFallback: dir })
77
+ // When 运行长任务且 timeoutMs 极短
78
+ const started = Date.now()
79
+ const { outcome, log } = await runAndCollect(runner, {
80
+ command: `${process.execPath} -e "console.log('started'); setTimeout(() => {}, 60000)"`,
81
+ timeoutMs: 500,
82
+ })
83
+ // Then timeout 且进程被提前终止
84
+ assert.equal(outcome.status, 'timeout')
85
+ assert.ok(Date.now() - started < 30 * 1000)
86
+ assert.match(log, /started/)
87
+ })