@amenophis1er/foreman 0.1.15 → 0.1.17

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,149 @@
1
+ /**
2
+ * The rules a scheduled mission has to get past before it spends anything.
3
+ *
4
+ * A schedule fires while nobody is watching, which is the whole point and also
5
+ * the whole danger: a mission that fails at 03:00 will fail again at 03:00
6
+ * tomorrow, and a cadence that costs $3 a firing costs $90 a month if nothing
7
+ * counts. So the ticker asks four questions every pass — is it due, is the
8
+ * project free, has it been failing, and can the project still afford it — and
9
+ * every one of those answers lives here rather than in the server.
10
+ *
11
+ * The point of the separation is testability: src/server.ts starts listening
12
+ * on import, so nothing that imports it can be a unit test. These functions
13
+ * take the world as arguments and return decisions; the server is left with
14
+ * the doing.
15
+ */
16
+ import type { Schedule } from './types.js';
17
+ import { nextRunAt } from './schedule.js';
18
+
19
+ /** Default ceiling on what a project's SCHEDULED runs may cost in one calendar month. */
20
+ export const DEFAULT_SCHEDULED_MONTHLY_CAP_USD = 25;
21
+
22
+ /**
23
+ * What this project's scheduled runs have cost so far this calendar month.
24
+ * Months are local, matching the local-time cadences: an operator who says
25
+ * "every night at 2am" means their nights, and their month rolls over on their
26
+ * midnight. Only runs with a scheduleId count; a hand-started mission spends
27
+ * the human's attention, not the schedule's allowance.
28
+ */
29
+ export function monthlyScheduledSpend(
30
+ runs: Array<{ projectId?: string; scheduleId?: string; createdAt: number; costUsd: number }>,
31
+ projectId: string,
32
+ now: Date,
33
+ ): number {
34
+ const year = now.getFullYear();
35
+ const month = now.getMonth();
36
+ let total = 0;
37
+ for (const run of runs) {
38
+ if (!run.scheduleId || run.projectId !== projectId) continue;
39
+ const at = new Date(run.createdAt);
40
+ if (at.getFullYear() !== year || at.getMonth() !== month) continue;
41
+ total += run.costUsd;
42
+ }
43
+ return total;
44
+ }
45
+
46
+ /**
47
+ * Did this scheduled run fail for the purposes of the failure pause? — status
48
+ * 'error', or 'interrupted' with a stopReason (budget/turns/time/tokens). An
49
+ * interruption with no stopReason is a server restart sweeping its orphans,
50
+ * not the schedule's fault, and pausing a schedule for that would mean a
51
+ * reboot silently switches off the operator's nightly missions.
52
+ */
53
+ export function isFailedOutcome(run: { status: string; stopReason?: string | null }): boolean {
54
+ if (run.status === 'error') return true;
55
+ return run.status === 'interrupted' && !!run.stopReason;
56
+ }
57
+
58
+ /**
59
+ * The schedule's failure bookkeeping after one of its runs ended. Two
60
+ * consecutive failures pause it: once is a bad night, twice is a standing
61
+ * instruction that no longer works, and there is nobody awake to notice the
62
+ * third. A 'done' resets the count but does not un-pause — a schedule stopped
63
+ * for the monthly cap or by a human stays stopped until they say otherwise.
64
+ */
65
+ export function afterRunOutcome(
66
+ schedule: Pick<Schedule, 'consecutiveFailures' | 'pausedReason'>,
67
+ run: { status: string; stopReason?: string | null },
68
+ ): { consecutiveFailures: number; pausedReason: Schedule['pausedReason']; pausedNow: boolean } {
69
+ if (!isFailedOutcome(run)) {
70
+ return { consecutiveFailures: 0, pausedReason: schedule.pausedReason, pausedNow: false };
71
+ }
72
+ const consecutiveFailures = schedule.consecutiveFailures + 1;
73
+ const pausedNow = schedule.pausedReason === null && consecutiveFailures >= 2;
74
+ return {
75
+ consecutiveFailures,
76
+ pausedReason: pausedNow ? 'failures' : schedule.pausedReason,
77
+ pausedNow,
78
+ };
79
+ }
80
+
81
+ export type TickAction =
82
+ | { kind: 'start'; scheduleId: string; at: number; nextRunAt: number | null }
83
+ | { kind: 'skip'; scheduleId: string; reason: string; nextRunAt: number | null }
84
+ | { kind: 'pause'; scheduleId: string; reason: 'monthly-cap'; spent: number; cap: number };
85
+
86
+ /**
87
+ * What the ticker should do this pass. Pure: give it the world, it returns the
88
+ * decisions; the server performs them.
89
+ */
90
+ export function decideTicks(input: {
91
+ schedules: Schedule[];
92
+ now: Date;
93
+ /** Projects that already have an active mission (reserveProject would fail). */
94
+ busyProjectIds: Set<string> | string[];
95
+ /** This month's scheduled spend per project id. */
96
+ monthSpend: Map<string, number> | Record<string, number>;
97
+ /** The project's monthly ceiling for scheduled runs. */
98
+ capFor: (projectId: string) => number;
99
+ }): TickAction[] {
100
+ const nowMs = input.now.getTime();
101
+ // Copies, because a start in this pass has to be visible to the schedules
102
+ // decided after it.
103
+ const busy = new Set(input.busyProjectIds);
104
+ const spend = input.monthSpend instanceof Map
105
+ ? new Map(input.monthSpend)
106
+ : new Map(Object.entries(input.monthSpend));
107
+
108
+ // Earliest due first, tie-broken on createdAt, so that when two schedules in
109
+ // one project come due together the older one starts and the newer one is
110
+ // the one that waits. Without an order the array's order would decide, and a
111
+ // schedule could be starved every night by a later-created neighbour.
112
+ const due = input.schedules
113
+ .filter((s) => s.enabled && s.pausedReason === null && s.nextRunAt !== null && s.nextRunAt <= nowMs)
114
+ .sort((a, b) => (a.nextRunAt! - b.nextRunAt!) || (a.createdAt - b.createdAt) || a.id.localeCompare(b.id));
115
+
116
+ // The next firing is computed from `now`, never from the slot that was
117
+ // missed. That is why this function takes `now` at all: if the machine was
118
+ // asleep for a week, the schedule fires once on waking and then goes back to
119
+ // its cadence — a missed mission is missed, not queued, and catch-up happens
120
+ // at most once per schedule however long Foreman was down.
121
+ const advance = (s: Schedule): number | null => {
122
+ const next = nextRunAt(s.cadence, input.now);
123
+ return next ? next.getTime() : null;
124
+ };
125
+
126
+ const actions: TickAction[] = [];
127
+ for (const s of due) {
128
+ if (busy.has(s.projectId)) {
129
+ actions.push({ kind: 'skip', scheduleId: s.id, reason: 'project busy', nextRunAt: advance(s) });
130
+ continue;
131
+ }
132
+ const spent = spend.get(s.projectId) ?? 0;
133
+ const cap = input.capFor(s.projectId);
134
+ if (spent + s.budgetUsd > cap) {
135
+ // No advance: a paused schedule is resumed by a human, who then gets a
136
+ // fresh next time. Leaving nextRunAt in the past would make it fire the
137
+ // instant it came back.
138
+ actions.push({ kind: 'pause', scheduleId: s.id, reason: 'monthly-cap', spent, cap });
139
+ continue;
140
+ }
141
+ actions.push({ kind: 'start', scheduleId: s.id, at: nowMs, nextRunAt: advance(s) });
142
+ // One active mission per project: this project is now taken for the rest
143
+ // of the pass, and its month spend is charged straight away so a second
144
+ // schedule cannot slip under the ceiling by being decided in the same tick.
145
+ busy.add(s.projectId);
146
+ spend.set(s.projectId, spent + s.budgetUsd);
147
+ }
148
+ return actions;
149
+ }
@@ -0,0 +1,240 @@
1
+ // Scheduling is wall-clock arithmetic, so the tests have to own the clock:
2
+ // pin the zone before anything reads a Date, and skip the DST assertions on a
3
+ // machine where the pin did not take (Node re-reads process.env.TZ, but a
4
+ // container without tzdata cannot honour it).
5
+ process.env.TZ = 'America/New_York';
6
+
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import {
10
+ describeCadence, nextRunAt, nextRuns, parseCron, validateCadence, type Cadence,
11
+ } from './schedule.js';
12
+
13
+ /** Is the process actually in US Eastern, offsets and all? */
14
+ const EASTERN = new Date(2026, 0, 15, 12, 0).getTimezoneOffset() === 300
15
+ && new Date(2026, 6, 15, 12, 0).getTimezoneOffset() === 240;
16
+
17
+ /** A local-time Date, written the way the assertions read. */
18
+ const local = (y: number, mo: number, d: number, h = 0, mi = 0, s = 0) => new Date(y, mo - 1, d, h, mi, s, 0);
19
+
20
+ /** 'YYYY-MM-DD HH:MM' in local time — what a failure message should show. */
21
+ const show = (d: Date | null) => (d === null ? 'null' : [
22
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`,
23
+ `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`,
24
+ ].join(' '));
25
+
26
+ const at = (cadence: Cadence, after: Date) => show(nextRunAt(cadence, after));
27
+
28
+ test('daily fires at its wall-clock time, and rolls to tomorrow once past', () => {
29
+ const daily: Cadence = { kind: 'daily', at: '07:30' };
30
+ assert.equal(at(daily, local(2026, 3, 2, 6, 0)), '2026-03-02 07:30');
31
+ assert.equal(at(daily, local(2026, 3, 2, 7, 29, 59)), '2026-03-02 07:30');
32
+ // Strictly after: standing exactly on the firing minute means tomorrow.
33
+ assert.equal(at(daily, local(2026, 3, 2, 7, 30)), '2026-03-03 07:30');
34
+ assert.equal(at(daily, local(2026, 3, 2, 8, 0)), '2026-03-03 07:30');
35
+ // Seconds and milliseconds are never carried into a firing.
36
+ const next = nextRunAt(daily, local(2026, 3, 2, 6, 0, 42))!;
37
+ assert.equal(next.getSeconds(), 0);
38
+ assert.equal(next.getMilliseconds(), 0);
39
+ // Month and year roll on their own.
40
+ assert.equal(at(daily, local(2026, 12, 31, 9, 0)), '2027-01-01 07:30');
41
+ });
42
+
43
+ test('weekly fires on its day, rolling a whole week when the day has passed', () => {
44
+ const monday: Cadence = { kind: 'weekly', day: 1, at: '09:00' };
45
+ // 2026-03-04 is a Wednesday.
46
+ assert.equal(local(2026, 3, 4).getDay(), 3);
47
+ assert.equal(at(monday, local(2026, 3, 4, 12, 0)), '2026-03-09 09:00');
48
+ // On the day, before and after the hour.
49
+ assert.equal(at(monday, local(2026, 3, 9, 8, 59)), '2026-03-09 09:00');
50
+ assert.equal(at(monday, local(2026, 3, 9, 9, 0)), '2026-03-16 09:00');
51
+ // Sunday is 0, not 7.
52
+ assert.equal(at({ kind: 'weekly', day: 0, at: '23:00' }, local(2026, 3, 4, 12, 0)), '2026-03-08 23:00');
53
+ });
54
+
55
+ test('interval is elapsed time from `after`, to the millisecond', () => {
56
+ const six: Cadence = { kind: 'interval', everyMinutes: 360 };
57
+ const from = local(2026, 5, 4, 10, 17, 33);
58
+ assert.equal(nextRunAt(six, from)!.getTime() - from.getTime(), 360 * 60_000);
59
+ const ninety = nextRunAt({ kind: 'interval', everyMinutes: 90 }, from)!;
60
+ assert.equal(ninety.getTime() - from.getTime(), 90 * 60_000);
61
+ });
62
+
63
+ test('cron: single values, lists, ranges and steps', () => {
64
+ assert.equal(at({ kind: 'cron', expr: '0 9 * * *' }, local(2026, 3, 2, 12, 0)), '2026-03-03 09:00');
65
+ // A list of minutes inside the hour.
66
+ const list: Cadence = { kind: 'cron', expr: '1,15,45 * * * *' };
67
+ assert.equal(at(list, local(2026, 3, 2, 12, 2)), '2026-03-02 12:15');
68
+ assert.equal(at(list, local(2026, 3, 2, 12, 45)), '2026-03-02 13:01');
69
+ // A range of weekdays: Friday 09:00 jumps to Monday.
70
+ const weekdays: Cadence = { kind: 'cron', expr: '0 9 * * 1-5' };
71
+ assert.equal(local(2026, 3, 6).getDay(), 5);
72
+ assert.equal(at(weekdays, local(2026, 3, 6, 9, 0)), '2026-03-09 09:00');
73
+ // Steps, on the minute field and on a range.
74
+ assert.equal(at({ kind: 'cron', expr: '*/15 * * * *' }, local(2026, 3, 2, 12, 1)), '2026-03-02 12:15');
75
+ assert.equal(at({ kind: 'cron', expr: '10-50/20 * * * *' }, local(2026, 3, 2, 12, 31)), '2026-03-02 12:50');
76
+ assert.equal(at({ kind: 'cron', expr: '10-50/20 * * * *' }, local(2026, 3, 2, 12, 51)), '2026-03-02 13:10');
77
+ // An hour step lands on the next multiple, not the next hour.
78
+ assert.equal(at({ kind: 'cron', expr: '0 */6 * * *' }, local(2026, 3, 2, 7, 0)), '2026-03-02 12:00');
79
+ });
80
+
81
+ test('parseCron: fields come back sorted, and 7 means Sunday', () => {
82
+ const c = parseCron('45,15 1-3 1,15 */6 7');
83
+ assert.deepEqual(c.minutes, [15, 45]);
84
+ assert.deepEqual(c.hours, [1, 2, 3]);
85
+ assert.deepEqual(c.daysOfMonth, [1, 15]);
86
+ assert.deepEqual(c.months, [1, 7]);
87
+ assert.deepEqual(c.daysOfWeek, [0]);
88
+ assert.equal(c.domRestricted, true);
89
+ assert.equal(c.dowRestricted, true);
90
+ const star = parseCron('0 0 * * *');
91
+ assert.equal(star.domRestricted, false);
92
+ assert.equal(star.dowRestricted, false);
93
+ assert.equal(star.daysOfMonth.length, 31);
94
+ assert.equal(star.daysOfWeek.length, 7);
95
+ // Extra whitespace between fields is not an error.
96
+ assert.deepEqual(parseCron(' 0 9 * * 1 ').hours, [9]);
97
+ });
98
+
99
+ test('cron: with both day fields restricted, either one may match', () => {
100
+ // "the 1st of the month, and every Monday" — not the intersection.
101
+ const both: Cadence = { kind: 'cron', expr: '0 0 1 * 1' };
102
+ // 2026-04-01 is a Wednesday; it fires on the date alone.
103
+ assert.equal(local(2026, 4, 1).getDay(), 3);
104
+ assert.equal(at(both, local(2026, 3, 31, 12, 0)), '2026-04-01 00:00');
105
+ // and on the next Monday, which is not the 1st.
106
+ assert.equal(at(both, local(2026, 4, 1, 0, 0)), '2026-04-06 00:00');
107
+ assert.equal(local(2026, 4, 6).getDay(), 1);
108
+ // One field restricted is a plain AND with the other's "every".
109
+ assert.equal(at({ kind: 'cron', expr: '0 0 1 * *' }, local(2026, 4, 1, 0, 0)), '2026-05-01 00:00');
110
+ });
111
+
112
+ test('cron: month ends, February, and a leap day', () => {
113
+ // The 31st skips the 30-day months entirely: Jan 31 → Mar 31.
114
+ assert.equal(at({ kind: 'cron', expr: '0 0 31 * *' }, local(2026, 1, 31, 0, 0)), '2026-03-31 00:00');
115
+ assert.equal(at({ kind: 'cron', expr: '0 0 31 * *' }, local(2026, 4, 1, 0, 0)), '2026-05-31 00:00');
116
+ // A February end-of-month rule lands on the 28th in a common year...
117
+ assert.equal(at({ kind: 'cron', expr: '0 12 28-31 2 *' }, local(2026, 2, 27, 0, 0)), '2026-02-28 12:00');
118
+ assert.equal(at({ kind: 'cron', expr: '0 12 28-31 2 *' }, local(2026, 2, 28, 12, 0)), '2027-02-28 12:00');
119
+ // ...and on the 29th in a leap year. 2028 is one; 2100 would not be.
120
+ assert.equal(at({ kind: 'cron', expr: '0 12 29 2 *' }, local(2026, 1, 1, 0, 0)), '2028-02-29 12:00');
121
+ // February 30th never happens; the scan gives up instead of hanging.
122
+ assert.equal(nextRunAt({ kind: 'cron', expr: '0 0 30 2 *' }, local(2026, 1, 1, 0, 0)), null);
123
+ });
124
+
125
+ test('interval survives a DST change; wall-clock cadences keep the clock time', { skip: EASTERN ? false : 'not in America/New_York' }, () => {
126
+ // 2026-03-08 is spring forward in US Eastern: 02:00 becomes 03:00.
127
+ assert.equal(new Date(2026, 2, 8, 0, 30).getTimezoneOffset(), 300);
128
+ assert.equal(new Date(2026, 2, 8, 12, 0).getTimezoneOffset(), 240);
129
+
130
+ // Six hours means six hours: 00:30 EST + 6 h reads 07:30 EDT on the wall.
131
+ const from = local(2026, 3, 8, 0, 30);
132
+ const six = nextRunAt({ kind: 'interval', everyMinutes: 360 }, from)!;
133
+ assert.equal(six.getTime() - from.getTime(), 6 * 3_600_000);
134
+ assert.equal(show(six), '2026-03-08 07:30');
135
+
136
+ // 02:30 does not exist that morning. It must still resolve to a real
137
+ // instant just after the skip, not hang and not vanish.
138
+ const daily: Cadence = { kind: 'daily', at: '02:30' };
139
+ const skipped = nextRunAt(daily, local(2026, 3, 7, 12, 0))!;
140
+ assert.ok(skipped.getTime() > local(2026, 3, 7, 12, 0).getTime());
141
+ assert.equal(show(skipped), '2026-03-08 03:30');
142
+ // and the day after is an ordinary 02:30 again.
143
+ assert.equal(show(nextRunAt(daily, skipped)), '2026-03-09 02:30');
144
+
145
+ // Fall back (2026-11-01, 02:00 becomes 01:00): the hour repeats, and a
146
+ // daily 01:30 fires once, then the next day.
147
+ const back = nextRunAt({ kind: 'daily', at: '01:30' }, local(2026, 11, 1, 0, 0))!;
148
+ assert.equal(show(back), '2026-11-01 01:30');
149
+ assert.equal(show(nextRunAt({ kind: 'daily', at: '01:30' }, back)), '2026-11-02 01:30');
150
+
151
+ // A whole DST week of firings stays strictly increasing.
152
+ const week = nextRuns({ kind: 'daily', at: '02:30' }, local(2026, 3, 6, 12, 0), 5);
153
+ assert.equal(week.length, 5);
154
+ for (let i = 1; i < week.length; i++) assert.ok(week[i].getTime() > week[i - 1].getTime());
155
+ });
156
+
157
+ test('parseCron: bad expressions say which field and why', () => {
158
+ const why = (expr: string) => {
159
+ try {
160
+ parseCron(expr);
161
+ } catch (e) {
162
+ return (e as Error).message;
163
+ }
164
+ assert.fail(`expected ${JSON.stringify(expr)} to be rejected`);
165
+ };
166
+ assert.match(why('62 * * * *'), /minute: "62" is out of range 0-59/);
167
+ assert.match(why('0 24 * * *'), /hour: "24" is out of range 0-23/);
168
+ assert.match(why('0 0 0 * *'), /day-of-month: "0" is out of range 1-31/);
169
+ assert.match(why('0 0 * 13 *'), /month: "13" is out of range 1-12/);
170
+ assert.match(why('0 0 * * 8'), /day-of-week: "8" is out of range 0-7/);
171
+ assert.match(why('0 0 * * mon'), /day-of-week: "mon" is out of range/);
172
+ assert.match(why('0 0 * *'), /five fields.*got 4/);
173
+ assert.match(why('0 0 * * * *'), /five fields.*got 6/);
174
+ assert.match(why(''), /five fields.*got 0/);
175
+ assert.match(why('5-1 * * * *'), /minute: range "5-1" runs backwards/);
176
+ assert.match(why('*/0 * * * *'), /minute: step "0" must be a whole number of 1 or more/);
177
+ assert.match(why('0,,5 * * * *'), /minute: .* has an empty item/);
178
+ assert.match(why('1-2-3 * * * *'), /minute: "1-2-3" is not a range/);
179
+ assert.match(why('*/2/2 * * * *'), /minute: .* has more than one step/);
180
+ });
181
+
182
+ test('validateCadence: accepts what it should and explains what it will not', () => {
183
+ assert.deepEqual(validateCadence({ kind: 'daily', at: '07:30' }), { ok: true, cadence: { kind: 'daily', at: '07:30' } });
184
+ assert.deepEqual(validateCadence({ kind: 'weekly', day: 6, at: '23:59' }), { ok: true, cadence: { kind: 'weekly', day: 6, at: '23:59' } });
185
+ assert.deepEqual(validateCadence({ kind: 'interval', everyMinutes: 60 }), { ok: true, cadence: { kind: 'interval', everyMinutes: 60 } });
186
+ // Extra fields are dropped, and a cron expression comes back normalised.
187
+ assert.deepEqual(validateCadence({ kind: 'cron', expr: ' 0 9 * * 1-5 ', junk: 1 }), { ok: true, cadence: { kind: 'cron', expr: '0 9 * * 1-5' } });
188
+
189
+ const bad = (input: unknown) => {
190
+ const r = validateCadence(input);
191
+ assert.equal(r.ok, false, `expected ${JSON.stringify(input)} to be rejected`);
192
+ return (r as { ok: false; error: string }).error;
193
+ };
194
+ assert.match(bad(null), /must be an object/);
195
+ assert.match(bad('daily'), /must be an object/);
196
+ assert.match(bad([{ kind: 'daily', at: '07:30' }]), /must be an object/);
197
+ assert.match(bad({ kind: 'hourly' }), /kind must be daily, weekly, interval or cron/);
198
+ assert.match(bad({ kind: 'daily' }), /"HH:MM"/);
199
+ assert.match(bad({ kind: 'daily', at: '7:30' }), /"HH:MM"/);
200
+ assert.match(bad({ kind: 'daily', at: '24:00' }), /"HH:MM"/);
201
+ assert.match(bad({ kind: 'daily', at: '07:60' }), /"HH:MM"/);
202
+ assert.match(bad({ kind: 'weekly', day: 7, at: '07:30' }), /day must be a whole number 0/);
203
+ assert.match(bad({ kind: 'weekly', day: 1.5, at: '07:30' }), /day must be a whole number 0/);
204
+ assert.match(bad({ kind: 'weekly', at: '07:30' }), /day must be a whole number 0/);
205
+ assert.match(bad({ kind: 'interval', everyMinutes: 59 }), /at least 60/);
206
+ assert.match(bad({ kind: 'interval', everyMinutes: 90.5 }), /whole number of minutes/);
207
+ assert.match(bad({ kind: 'interval', everyMinutes: '90' }), /whole number of minutes/);
208
+ // The point of validating cron here: the reason travels to the 400.
209
+ assert.match(bad({ kind: 'cron', expr: '99 * * * *' }), /minute: "99" is out of range 0-59/);
210
+ assert.match(bad({ kind: 'cron', expr: ' ' }), /expr must be a cron expression/);
211
+ assert.match(bad({ kind: 'cron' }), /expr must be a cron expression/);
212
+ });
213
+
214
+ test('nextRuns previews N firings, strictly increasing', () => {
215
+ const three = nextRuns({ kind: 'daily', at: '07:30' }, local(2026, 5, 4, 9, 0), 3);
216
+ assert.deepEqual(three.map(show), ['2026-05-05 07:30', '2026-05-06 07:30', '2026-05-07 07:30']);
217
+ for (let i = 1; i < three.length; i++) assert.ok(three[i].getTime() > three[i - 1].getTime());
218
+
219
+ assert.deepEqual(
220
+ nextRuns({ kind: 'interval', everyMinutes: 90 }, local(2026, 5, 4, 9, 0), 3).map(show),
221
+ ['2026-05-04 10:30', '2026-05-04 12:00', '2026-05-04 13:30'],
222
+ );
223
+ assert.deepEqual(
224
+ nextRuns({ kind: 'cron', expr: '0 9 * * 1-5' }, local(2026, 3, 6, 12, 0), 3).map(show),
225
+ ['2026-03-09 09:00', '2026-03-10 09:00', '2026-03-11 09:00'],
226
+ );
227
+ // Nothing to preview: an impossible expression, and a zero count.
228
+ assert.deepEqual(nextRuns({ kind: 'cron', expr: '0 0 30 2 *' }, local(2026, 1, 1), 3), []);
229
+ assert.deepEqual(nextRuns({ kind: 'daily', at: '07:30' }, local(2026, 1, 1), 0), []);
230
+ });
231
+
232
+ test('describeCadence puts the cadence in words', () => {
233
+ assert.equal(describeCadence({ kind: 'daily', at: '07:30' }), 'daily 07:30');
234
+ assert.equal(describeCadence({ kind: 'weekly', day: 1, at: '09:00' }), 'every Monday 09:00');
235
+ assert.equal(describeCadence({ kind: 'weekly', day: 0, at: '18:15' }), 'every Sunday 18:15');
236
+ assert.equal(describeCadence({ kind: 'interval', everyMinutes: 360 }), 'every 6 hours');
237
+ assert.equal(describeCadence({ kind: 'interval', everyMinutes: 60 }), 'every hour');
238
+ assert.equal(describeCadence({ kind: 'interval', everyMinutes: 90 }), 'every 90 minutes');
239
+ assert.equal(describeCadence({ kind: 'cron', expr: '0 9 * * 1-5' }), 'cron 0 9 * * 1-5');
240
+ });