test/cron.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from 'vitest';
import { parseCron, isValidCron, nextCronTime, nextCronIso, CRON_MACROS, MAX_SCHEDULE_LEN, describeCron } from '../lib/cron.js';
// Local time throughout, because that is what the parser answers in. Building
// the reference points with `new Date(y, m, d, ...)` keeps the tests honest
// wherever the suite runs: an ISO literal would be UTC and would drift.
const at = (y, m, d, hh = 0, mm = 0) => new Date(y, m - 1, d, hh, mm, 0, 0).getTime();
const next = (expr, from) => new Date(nextCronTime(expr, from));
describe('the day-field star rule', () => {
const at = (y, m, d, hh = 0, mm = 0) => new Date(y, m - 1, d, hh, mm, 0, 0).getTime();
const next = (expr, from) => new Date(nextCronTime(expr, from));
it('treats a step like */2 as a star, the way Vixie cron does', () => {
// dow begins with `*`, so BOTH day conditions must hold: the 1st of a
// month AND an even weekday. The OR reading would return the very next
// even weekday instead of waiting for a matching 1st.
const d = next('0 0 1 * */2', at(2026, 5, 15));
expect(d.getDate()).toBe(1);
expect([0, 2, 4, 6]).toContain(d.getDay());
});
it('applies the same rule with the step on the day-of-month side', () => {
const d = next('0 0 */2 * 1', at(2026, 5, 15));
expect(d.getDay()).toBe(1); // a Monday
expect(d.getDate() % 2).toBe(1); // on an odd day (*/2 from 1)
});
});
describe('parseCron', () => {
it('expands each field into the values it matches', () => {
const p = parseCron('0 9 * * 1-5');
expect(p.error).toBeUndefined();
expect([...p.minute]).toEqual([0]);
expect([...p.hour]).toEqual([9]);
expect([...p.dow].sort()).toEqual([1, 2, 3, 4, 5]);
expect(p.domStar).toBe(true);
expect(p.dowStar).toBe(false);
});
it('handles steps, ranges, lists, and a bare number with a step', () => {
expect([...parseCron('*/15 * * * *').minute]).toEqual([0, 15, 30, 45]);
expect([...parseCron('0-10/5 * * * *').minute]).toEqual([0, 5, 10]);
expect([...parseCron('0,30 * * * *').minute]).toEqual([0, 30]);
// "5/20" is crontab for "from 5 to the end of the field, every 20".
expect([...parseCron('5/20 * * * *').minute]).toEqual([5, 25, 45]);
});
it('accepts 7 as Sunday, the same day 0 already is', () => {
expect([...parseCron('0 0 * * 7').dow]).toEqual([0]);
expect([...parseCron('0 0 * * 0,7').dow]).toEqual([0]);
});
it('expands every documented shorthand', () => {
for (const [macro, expanded] of Object.entries(CRON_MACROS)) {
expect(isValidCron(macro)).toBe(true);
expect(nextCronTime(macro, at(2026, 3, 4, 12))).toBe(nextCronTime(expanded, at(2026, 3, 4, 12)));
}
// Case-insensitively, since the field is free text.
expect(isValidCron('@DAILY')).toBe(true);
});
it('explains what is wrong rather than just failing', () => {
expect(parseCron('').error).toMatch(/needs a cron schedule/i);
expect(parseCron('0 9 * *').error).toMatch(/five fields/i);
expect(parseCron('0 9 * * * *').error).toMatch(/five fields/i);
expect(parseCron('60 * * * *').error).toMatch(/minute must be between 0 and 59/i);
expect(parseCron('0 24 * * *').error).toMatch(/hour must be between 0 and 23/i);
expect(parseCron('0 0 0 * *').error).toMatch(/day of month must be between 1 and 31/i);
expect(parseCron('0 0 * 13 *').error).toMatch(/month must be between 1 and 12/i);
expect(parseCron('0 0 * * 8').error).toMatch(/day of week must be between 0 and 7/i);
expect(parseCron('10-5 * * * *').error).toMatch(/backwards/i);
expect(parseCron('*/0 * * * *').error).toMatch(/step/i);
expect(parseCron('*/x * * * *').error).toMatch(/step/i);
expect(parseCron('1/2/3 * * * *').error).toMatch(/not a valid/i);
expect(parseCron('mon * * * *').error).toMatch(/not a valid minute/i);
expect(parseCron('0,,5 * * * *').error).toMatch(/empty minute/i);
expect(parseCron('@nightly').error).toMatch(/unknown schedule shorthand/i);
expect(parseCron('0 '.repeat(MAX_SCHEDULE_LEN)).error).toMatch(/too long/i);
});
});
describe('nextCronTime', () => {
it('is strictly after the moment asked about, so a job cannot re-fire on itself', () => {
// Exactly on a match: the answer is the NEXT one, not this one.
expect(next('0 * * * *', at(2026, 6, 10, 14, 0)).getTime()).toBe(at(2026, 6, 10, 15, 0));
});
it('finds the next matching minute within the hour', () => {
expect(next('*/15 * * * *', at(2026, 6, 10, 14, 3)).getTime()).toBe(at(2026, 6, 10, 14, 15));
});
it('rolls over the hour, the day, the month and the year', () => {
expect(next('0 9 * * *', at(2026, 6, 10, 14, 0)).getTime()).toBe(at(2026, 6, 11, 9, 0));
expect(next('0 0 1 * *', at(2026, 6, 10)).getTime()).toBe(at(2026, 7, 1, 0, 0));
expect(next('0 0 1 1 *', at(2026, 6, 10)).getTime()).toBe(at(2027, 1, 1, 0, 0));
});
it('skips the weekend for a weekday schedule', () => {
// 2026-08-28 is a Friday, so the next weekday 09:00 is the Monday.
expect(next('0 9 * * 1-5', at(2026, 8, 28, 10, 0)).getTime()).toBe(at(2026, 8, 31, 9, 0));
});
it('ORs the two day fields when both are restricted, and ANDs nothing when one is *', () => {
// Both restricted: the 13th OR any Friday. 2026-03-04 is a Wednesday, so
// the Friday (the 6th) comes before the 13th.
expect(next('0 0 13 * 5', at(2026, 3, 4, 12)).getTime()).toBe(at(2026, 3, 6, 0, 0));
// Only day-of-month restricted: the 13th, and the weekday is irrelevant.
expect(next('0 0 13 * *', at(2026, 3, 4, 12)).getTime()).toBe(at(2026, 3, 13, 0, 0));
});
it('reaches a date years away', () => {
// 29 February: 2028 is the next leap year after 2026.
expect(next('0 0 29 2 *', at(2026, 3, 1)).getTime()).toBe(at(2028, 2, 29, 0, 0));
});
it('gives up rather than looping on an expression that can never match', () => {
// Syntactically fine, but 30 February is not a date.
expect(nextCronTime('0 0 30 2 *', at(2026, 3, 1))).toBeNull();
expect(nextCronIso('0 0 30 2 *', at(2026, 3, 1))).toBeNull();
});
it('skips an hour that does not exist on a daylight-saving day, and keeps going', () => {
// 2027-03-14 02:30 never happens in a US spring-forward zone. Skipping it
// is what cron does; what matters here is that the walk still terminates
// and lands on the next real occurrence rather than spinning on local
// arithmetic that cannot advance.
const from = new Date(2027, 2, 13, 12, 0, 0).getTime();
const hit = nextCronTime('30 2 * * *', from);
expect(hit).not.toBeNull();
expect(hit).toBeGreaterThan(from);
// Either the 14th (in a zone with no transition) or the 15th (in one that
// has it) — never a time before the 14th, and never a hang.
expect(hit).toBeLessThanOrEqual(at(2027, 3, 15, 2, 30));
});
it('returns null for an expression it cannot parse', () => {
expect(nextCronTime('nonsense')).toBeNull();
expect(nextCronIso('nonsense')).toBeNull();
});
it('answers in ISO when asked to', () => {
expect(nextCronIso('0 9 * * *', at(2026, 6, 10, 14))).toBe(new Date(at(2026, 6, 11, 9)).toISOString());
});
});
describe('describeCron', () => {
it.each([
['0 9 * * 1', 'Mondays at 9:00 AM'],
['0 9 * * 1-5', 'Weekdays at 9:00 AM'],
['30 17 * * 1,3,5', 'Mondays, Wednesdays and Fridays at 5:30 PM'],
['0 10 * * 0,6', 'Weekends at 10:00 AM'],
['@daily', 'Every day at midnight'],
['0 12 * * *', 'Every day at 12:00 PM'],
['*/15 * * * *', 'Every 15 minutes'],
['* * * * *', 'Every minute'],
['0 * * * *', 'Every hour'],
['@hourly', 'Every hour'],
['15 * * * *', 'Every hour at :15'],
['0 */6 * * *', 'Every 6 hours'],
['0 9 1 * *', 'Monthly on the 1st at 9:00 AM'],
['0 9 22 * *', 'Monthly on the 22nd at 9:00 AM'],
['@weekly', 'Sundays at midnight'],
])('%s -> %s', (expr, words) => expect(describeCron(expr)).toBe(words));
it.each(['0 9 3 10 *', '0 9 1 * 1', '0,30 9 * * *', '9-17 * * * *', 'nonsense', '', '0 9 * * MON'])(
'returns null for %j so the raw cron is shown', (expr) => expect(describeCron(expr)).toBeNull());
});