Files
orca/src/shared/automation-schedule-drift.test.ts
Neil e4a9d24e0c fix(automations): repair cron step expansion and day restriction (#20202)
The semantic half of the cron repair. Both defects change what an already-saved
schedule does, so they ship together and behind a decision.

#15723: parseCronField set end = start for a bare numeric field even with a
slash step, so 5/15 expanded to [5] and fired hourly instead of every fifteen
minutes. N/step is the open-ended N-max/step sequence now.

#15896: day restriction came from expanded set cardinality, so 1-31 read as
unrestricted and */2 as restricted. Restriction is lexical now: a day field
restricts iff no term of it ranges over a star, matching vixie cron and
robfig/cron rather than crontab(5)'s prose. Verified differentially against
robfig/cron v1.2.0 across 22 expressions, 424 days, zero divergences.

The two cannot ship apart: 0 9 1/1 * 1 matches 124 days under the old parser,
104 under #15723 alone, and 730 under both, because the old cardinality flags
react to the corrected expansion.

describeAutomationScheduleDrift reads a saved expression under both semantics
and reports the ones that moved, so neither direction is silent; the service
names them once at startup. No expression Orca's own presets generate drifts.

Fixes #15723
Fixes #15896
2026-09-15 01:28:23 -07:00

84 lines
2.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { describeAutomationScheduleDrift } from './automation-schedule-drift'
// Both lists were recorded by running the same corpus through the real parent build
// (729491597f3) and this branch, not by re-deriving them from the detector under test.
const DRIFTED = [
'5/15 * * * *',
'0/30 * * * *',
'5/15 9 * * *',
'0 9/4 * * *',
'0 9 1/7 * *',
'0 9 * 1/3 *',
'0 9 * * 1/2',
'0 9 1-31 * 1',
'0 9 */2 * 1',
'0 9 */3 * 1',
'0 9 * MAR/3 *'
]
const STABLE = [
'0 * * * *',
'30 9 * * *',
'30 9 * * 1-5',
'30 9 * * 3',
'*/15 * * * *',
'*/5 * * * *',
'5 * * * *',
'0 9 */1 * 1',
'0 9 1,15 * 1',
'0 9 * * 0-6',
'0 9 * * 0-7',
'0 9 * * 1-7',
'0 9 */2 * *',
'0 9 * * */2',
'0 9 1-31 * *',
'0 9 * * *',
'0 9 15 * *',
'0 9 1-15 * 1',
'*/90 * * * *',
'5/90 * * * *',
'0 9 * * */8',
'0 9 * MAY MON',
'0 9 * * FRI'
]
const ANCHOR = new Date(2026, 0, 1).getTime()
describe('automation schedule drift', () => {
it('flags every schedule the repair changed', () => {
for (const expression of DRIFTED) {
expect(describeAutomationScheduleDrift(expression, ANCHOR), expression).not.toBeNull()
}
})
// The restriction flags move on several of these while the days they fire do not; reporting
// those would train the reader to ignore the notice.
it('stays silent on schedules the repair left alone', () => {
for (const expression of STABLE) {
expect(describeAutomationScheduleDrift(expression, ANCHOR), expression).toBeNull()
}
})
it('reports the direction and size of the change', () => {
// 1x/hour -> 4x/hour: the cadence users will feel as spend.
expect(describeAutomationScheduleDrift('5/15 * * * *', ANCHOR)).toEqual({
expression: '5/15 * * * *',
previousRunsPerYear: 8760,
currentRunsPerYear: 35040
})
// The quiet direction: an automation that now skips most of the days it used to run.
const fewer = describeAutomationScheduleDrift('0 9 */2 * 1', ANCHOR)
expect(fewer!.currentRunsPerYear).toBeLessThan(fewer!.previousRunsPerYear / 4)
})
it('ignores RRULE presets, which never used the repaired parser', () => {
expect(describeAutomationScheduleDrift('FREQ=DAILY;BYHOUR=9;BYMINUTE=0', ANCHOR)).toBeNull()
})
it('reports nothing for a schedule that cannot be read at all', () => {
expect(describeAutomationScheduleDrift('0 9 32 * *', ANCHOR)).toBeNull()
expect(describeAutomationScheduleDrift('not a cron', ANCHOR)).toBeNull()
})
})