Files
deepseek-harness/packages/util/time/tests/cron.spec.ts
T
ZiyaandMagolor 341ea6eea4 feat(schedule): integrate standalone tasks with the existing WebUI (#6189)
* feat(schedule): manage Host tasks with typed timing and delivery targets

* fix(schedule): align task creation button halves

* refactor(schedule): remove obsolete Session reminder runtime

* refactor(schedule): simplify task UI and preserve keyboard navigation

* fix(schedule): address task delivery and interface review findings

* test(schedule): normalize bundle module paths on Windows

* fix(schedule): cover reserved targets and trim unrelated changes

* refactor(schedule): minimize task management changes after rebase

* test(schedule): align rebased fixtures and documentation records

* test(schedule): refresh SDK scenarios added on master

---------

Co-authored-by: Magolor <[email protected]>
2026-10-29 18:11:48 +08:00

361 lines
19 KiB
TypeScript

/** Cron parsing and bounded calendar arithmetic without Schedule task records or policy. */
import { describe, expect, it, vi } from 'vitest'
import { Temporal } from '@js-temporal/polyfill'
import { Cron, CRON_SEARCH_HORIZON_YEARS } from '../src/index.ts'
function parsed(expression: string, timeZone = 'UTC'): Cron {
const result = Cron.parse(expression, timeZone)
if (result.kind === 'rejected') throw new Error(result.detail)
return result.rule
}
function canonicalExpression(expression: string): string {
return parsed(expression).expression
}
/** Pair a valid rule with the committed floor used by its resolve operation. */
function reference(now: string, expression: string, timeZone = 'UTC') {
const rule = parsed(expression, timeZone)
const scheduledAt = rule.next(Date.parse(now))
if (scheduledAt === undefined) throw new Error('Cron fixture has no future occurrence')
return { rule, scheduledAt }
}
function decision(record: ReturnType<typeof reference>, now: string) {
return record.rule.resolve(record.scheduledAt, Date.parse(now))
}
describe('cron occurrence resolution across a DST fall-back', () => {
it('keeps the repeated hour as the latest due occurrence', () => {
// 2026-11-01 repeats 01:00-01:59 in America/New_York: 01:45 EDT is 05:45Z and the
// decision at 06:30Z renders as 01:30 EST, the second pass. The rule's 01:45 is
// later on the wall clock than that 01:30 yet resolves to the earlier overlap, so
// a due occurrence survives a saved target that predates the previous interval.
const record = reference('2026-10-25T05:45:00.000Z', '45 1 * * 0', 'America/New_York')
expect(decision(record, '2026-11-01T06:30:00.000Z').occurrenceAt).toBe('2026-11-01T05:45:00.000Z')
})
})
describe('cron occurrence resolution when the due local date is not the UTC decision date', () => {
it('resolves a due local date one day after the UTC decision date', () => {
// Pacific/Kiritimati runs UTC+14, so at 11:00Z its local date is already the next
// day: the due 00:30 local minute belongs to the UTC decision date plus one, and a
// walk bounded by the decision's own UTC date resolves the previous day instead.
const zone = 'Pacific/Kiritimati'
const decision = Date.parse('2026-01-01T11:00:00.000Z')
const record = reference('2025-12-31T00:00:00.000Z', '30 0 * * *', zone)
const resolved = Date.parse(record.rule.resolve(record.scheduledAt, decision).occurrenceAt)
expect(resolved).toBe(Date.parse('2026-01-01T10:30:00.000Z'))
// An exhaustive scan of every minute-aligned instant around the decision finds the
// same occurrence, and it is not the decision itself: the walk searched.
let scanned = Number.NEGATIVE_INFINITY
for (let at = decision - 4 * 86_400_000; at <= decision; at += 60_000) {
const local = Temporal.Instant.fromEpochMilliseconds(at).toZonedDateTimeISO(zone)
if (local.hour === 0 && local.minute === 30 && local.second === 0) scanned = local.epochMilliseconds
}
expect(resolved).toBe(scanned)
expect(resolved).not.toBe(decision)
// The occurrence a walk bounded by the decision's UTC date would take is earlier, so
// this fixture separates the shipped bound from that one.
const utcDate = Temporal.Instant.fromEpochMilliseconds(decision).toZonedDateTimeISO('UTC').toPlainDate()
const bounded = Temporal.PlainDateTime.from(`${utcDate.toString()}T00:30`).toZonedDateTime(zone).epochMilliseconds
expect(bounded).toBeLessThan(resolved)
expect(bounded).toBeLessThanOrEqual(decision)
})
})
describe('cron expression parsing and canonicalization', () => {
it.each<[string, string]>([
['0 0 * * *', '0 0 * * *'],
['*/15 9-17 * * 1-5', '*/15 9-17 * * 1-5'],
['30,10,20 * * * *', '10-30/10 * * * *'],
['1,2,5 0 * * *', '1-2,5 0 * * *'],
['20,30,10 * * * *', '10-30/10 * * * *'],
['0,0,30 * * * *', '0-30/30 * * * *'],
['*/1 * * * *', '* * * * *'],
['0 0 */2 * *', '0 0 */2 * *'],
['0-59/1 0 * * *', '0-59 0 * * *'],
['0-59 0-23 1-31 1-12 0-7', '0-59 0-23 1-31 1-12 0-6'],
['0 0 * * 0', '0 0 * * 0'],
['0 0 * * 7', '0 0 * * 0'],
['0 0 * * 1-2,3-4', '0 0 * * 1-4'],
['0 0 * * 5-7', '0 0 * * 0,5-6'],
['1,3,5 * * * *', '1-5/2 * * * *'],
['1-5/2 * * * *', '1-5/2 * * * *'],
['00 09 * * 5,4,3,2,1', '0 9 * * 1-5'],
['0 0 1-15/2 * *', '0 0 1-15/2 * *'],
['0 0 * * */7', '0 0 * * */7'],
['0 0 * * */3', '0 0 * * */3'],
// A field's canonical spelling keeps its star flag: a `*`-led field stays `*`-led, and a
// field that did not start with `*` never gains one, so day-field AND/OR selection survives
// the stored canonical expression.
['0 9 1-31 * */7', '0 9 1-31 * */7'],
['0 9 * * */7', '0 9 * * */7'],
['0 9 * * */2', '0 9 * * */2'],
['0 9 */31 * 1', '0 9 */31 * 1'],
['0 9 */2 * 1', '0 9 */2 * 1'],
['0 9 */2,4 * *', '0 9 */2,4 * *'],
['0 9 1-31/2 * 1', '0 9 1-31/2 * 1'],
['0 9 * * 1', '0 9 * * 1'],
['* * * * *', '* * * * *'],
])('canonicalizes %j to %j', (input, canonical) => {
expect(canonicalExpression(input)).toBe(canonical)
expect(canonicalExpression(canonical)).toBe(canonical)
})
it('keeps the unrestricted star distinct from an explicit full range', () => {
expect(canonicalExpression('* * * * *')).toBe('* * * * *')
expect(canonicalExpression('0-59 0-23 1-31 1-12 0-7')).toBe('0-59 0-23 1-31 1-12 0-6')
expect(canonicalExpression('*,5 0 * * *')).toBe('* 0 * * *')
expect(canonicalExpression('5,* 0 * * *')).toBe('0-59 0 * * *')
})
it('preserves every field star flag and idempotence across canonicalization', () => {
const expressions = [
'0 9 1-31 * */7', '0 9 */31 * 1', '0 9 */2 * 1', '0 9 * * */7', '0 9 * * 1', '*/1 * * * *',
'0 9 */2,4 * *', '0 9 1-31/2 * 1', '0 9 * * */2', '0 9 */30 * *', '0,0,30 * * * *',
]
for (const expression of expressions) {
const canonical = canonicalExpression(expression)
expect(canonicalExpression(canonical)).toBe(canonical)
const fields = expression.split(' ')
const canonicalFields = canonical.split(' ')
expect(canonicalFields).toHaveLength(fields.length)
fields.forEach((field, index) => {
expect(canonicalFields[index]!.startsWith('*')).toBe(field.startsWith('*'))
})
}
})
it('resolves an expression exactly like its stored canonical spelling', () => {
const expressions = [
'0 9 1-31 * */7', '0 9 */31 * 1', '0 9 */2 * 1', '0 9 * * */7', '0 9 * * 1',
'0 9 */2,4 * *', '0 9 1-31/2 * 1', '0 9 * * */2', '0 0 1-31 * 5',
]
const floors = ['2026-09-02T00:00:00Z', '2026-09-13T00:00:00Z', '2026-09-20T00:00:00Z']
for (const expression of expressions) {
const canonical = canonicalExpression(expression)
for (const floor of floors) {
const record = reference(floor, expression)
const raw = { ...record, rule: parsed(expression) }
const stored = { ...record, rule: parsed(canonical) }
const later = new Date(Math.max(Date.parse(floor) + 86_400_000, Date.parse(record.scheduledAt) + 1)).toISOString()
expect(decision(raw, later)).toEqual(decision(stored, later))
}
}
})
it('keeps a star-led day field from changing the day rule after storage', () => {
const starLed = reference('2026-09-01T00:00:00Z', '0 9 1-31 * */7')
const restricted = { ...starLed, rule: parsed('0 9 1-31 * 0') }
expect(starLed.rule.expression).toBe('0 9 1-31 * */7')
expect(decision(starLed, '2026-09-07T00:00:00Z').nextScheduledAt).toBe('2026-09-13T09:00:00.000Z')
expect(decision(restricted, '2026-09-07T00:00:00Z').nextScheduledAt).toBe('2026-09-07T09:00:00.000Z')
})
it.each<[string, string, RegExp]>([
['six fields', '0 0 * * * *', /exactly five/],
['four fields', '0 0 * *', /exactly five/],
['a macro', '@daily', /exactly five/],
['last day', '0 0 L * *', /day-of-month/],
['nearest weekday', '0 0 15W * *', /day-of-month/],
['nth weekday', '0 0 * * 1#2', /day-of-week/],
['a month name', '0 0 * JAN *', /month/],
['a weekday name', '0 0 * * MON', /day-of-week/],
['minute above range', '60 0 * * *', /minute field value 60 is outside 0-59/],
['hour above range', '0 24 * * *', /hour field value 24 is outside 0-23/],
['day-of-month below range', '0 0 0 * *', /day-of-month field value 0 is outside 1-31/],
['day-of-month above range', '0 0 32 * *', /day-of-month field value 32 is outside 1-31/],
['month below range', '0 0 * 0 *', /month field value 0 is outside 1-12/],
['month above range', '0 0 * 13 *', /month field value 13 is outside 1-12/],
['weekday above range', '0 0 * * 8', /day-of-week field value 8 is outside 0-7/],
['inverted day-of-month range', '0 0 20-10 * *', /day-of-month field range 20-10 is inverted/],
['inverted month range', '0 0 * 12-1 *', /month field range 12-1 is inverted/],
['zero step', '*/0 * * * *', /minute field step must be a positive integer/],
['zero range step', '0-10/0 * * * *', /minute field step must be a positive integer/],
['empty first element', '0,,1 * * * *', /minute field must not contain an empty list element/],
['trailing comma', '0 0 * * 1,', /day-of-week field must not contain an empty list element/],
['a bare step', '5/2 * * * *', /minute field element "5\/2"/],
['a bare star suffix', '*5 * * * *', /minute field element "\*5"/],
['a negative value', '-5 * * * *', /minute field element "-5"/],
['leading whitespace', ' 0 0 * * *', /non-empty trimmed string/],
['trailing whitespace', '0 0 * * * ', /non-empty trimmed string/],
['empty text', '', /non-empty trimmed string/],
['blank text', ' ', /non-empty trimmed string/],
])('rejects %s', (_label, expression, message) => {
const result = Cron.parse(expression, 'UTC')
expect(result).toMatchObject({ kind: 'rejected', reason: 'syntax' })
expect(result).toHaveProperty('detail', expect.stringMatching(message))
})
})
describe('cron target selection', () => {
it.each<[string, string]>([
['2026-09-16T09:29:59.999Z', '2026-09-16T09:30:00.000Z'],
['2026-09-16T09:30:00.000Z', '2026-09-17T09:30:00.000Z'],
['2026-09-16T09:30:00.001Z', '2026-09-17T09:30:00.000Z'],
])('selects the strictly future instant from %s', (now, scheduledAt) => {
expect(reference(now, '30 9 * * *').scheduledAt).toBe(scheduledAt)
})
it.each<[string, string, string]>([
['2026-04-01T00:00:00Z', '0 0 31 * *', '2026-05-31T00:00:00.000Z'],
['2026-01-01T00:00:00Z', '0 0 29 2 *', '2028-02-29T00:00:00.000Z'],
['2026-09-16T00:00:00Z', '0 0 1 * *', '2026-10-01T00:00:00.000Z'],
['2026-09-16T00:00:00Z', '0 0 * 1 *', '2027-01-01T00:00:00.000Z'],
])('selects the next calendar occurrence from %s for %j', (now, expression, scheduledAt) => {
expect(reference(now, expression).scheduledAt).toBe(scheduledAt)
})
it('skips a local time and whole date that fall in a DST gap', () => {
const record = reference('2026-03-01T00:00:00Z', '30 2 * 3 0', 'America/New_York')
expect(record.scheduledAt).toBe('2026-03-01T07:30:00.000Z')
expect(decision(record, '2026-03-08T12:00:00Z')).toEqual({
occurrenceAt: '2026-03-01T07:30:00.000Z', nextScheduledAt: '2026-03-15T06:30:00.000Z',
})
expect(reference('2026-03-02T00:00:00Z', '30 2 * 3 0', 'America/New_York').scheduledAt)
.toBe('2026-03-15T06:30:00.000Z')
expect(decision(record, '2026-03-15T06:30:00Z')).toEqual({
occurrenceAt: '2026-03-15T06:30:00.000Z', nextScheduledAt: '2026-03-22T06:30:00.000Z',
})
})
it('uses the earlier occurrence of a DST overlap once for that date', () => {
const record = reference('2026-10-25T00:00:00Z', '30 1 * 11 0', 'America/New_York')
expect(record.scheduledAt).toBe('2026-11-01T05:30:00.000Z')
for (const now of ['2026-11-01T05:30:00Z', '2026-11-01T06:00:00Z', '2026-11-01T06:30:00Z']) {
expect(decision(record, now)).toEqual({
occurrenceAt: '2026-11-01T05:30:00.000Z', nextScheduledAt: '2026-11-08T06:30:00.000Z',
})
}
})
it.each<[string, string, string, string]>([
['friday the 13th matches an earlier Friday', '2026-09-01T00:00:00Z', '0 0 13 * 5', '2026-09-04T00:00:00.000Z'],
['friday the 13th matches the 13th alone', '2026-09-12T00:00:00Z', '0 0 13 * 5', '2026-09-13T00:00:00.000Z'],
['a restricted day-of-month constrains alone', '2026-09-01T00:00:00Z', '0 0 13 * *', '2026-09-13T00:00:00.000Z'],
['a restricted day-of-week constrains alone', '2026-09-01T00:00:00Z', '0 0 * * 5', '2026-09-04T00:00:00.000Z'],
['two stars match every date', '2026-09-01T00:00:00Z', '0 0 * * *', '2026-09-02T00:00:00.000Z'],
['an explicit full day-of-month stays restricted', '2026-09-01T00:00:00Z', '0 0 1-31 * 5', '2026-09-02T00:00:00.000Z'],
['an explicit full day-of-week stays restricted', '2026-09-01T00:00:00Z', '0 0 13 * 0-7', '2026-09-02T00:00:00.000Z'],
['a stepped day-of-month star accepts an odd matching Monday', '2026-09-01T00:00:00Z', '0 9 */2 * 1', '2026-09-07T09:00:00.000Z'],
['a stepped day-of-month star rejects an even matching Monday', '2026-09-13T00:00:00Z', '0 9 */2 * 1', '2026-09-21T09:00:00.000Z'],
['a stepped day-of-week star still requires its own match', '2026-09-02T00:00:00Z', '0 9 1-31 * */2', '2026-09-03T09:00:00.000Z'],
['two stepped stars require both fields', '2026-09-02T00:00:00Z', '0 9 */2 * */2', '2026-09-03T09:00:00.000Z'],
['a stepped day-of-month star with a bare day-of-week star', '2026-09-02T00:00:00Z', '0 9 */2 * *', '2026-09-03T09:00:00.000Z'],
['a bare day-of-month star with a stepped day-of-week star', '2026-09-02T00:00:00Z', '0 9 * * */2', '2026-09-03T09:00:00.000Z'],
['a bare day-of-month star keeps only the weekday restriction', '2026-09-13T00:00:00Z', '0 9 * * 1', '2026-09-14T09:00:00.000Z'],
])('%s', (_label, now, expression, scheduledAt) => {
expect(reference(now, expression).scheduledAt).toBe(scheduledAt)
})
it('selects only the latest due occurrence after downtime, including a same-date later time', () => {
const record = reference('2026-09-15T00:00:00Z', '0 9,17 * * *')
expect(record.scheduledAt).toBe('2026-09-15T09:00:00.000Z')
expect(decision(record, '2026-09-15T10:00:00Z')).toEqual({
occurrenceAt: '2026-09-15T09:00:00.000Z', nextScheduledAt: '2026-09-15T17:00:00.000Z',
})
expect(decision(record, '2026-09-15T20:00:00Z')).toEqual({
occurrenceAt: '2026-09-15T17:00:00.000Z', nextScheduledAt: '2026-09-16T09:00:00.000Z',
})
})
it('honors a pinned committed target and never repeats a same-date occurrence', () => {
const pinned = {
...reference('2026-09-15T00:00:00Z', '0 9,17 * * *'), scheduledAt: '2026-09-15T12:00:00.000Z',
}
expect(decision(pinned, '2026-09-15T12:30:00Z')).toEqual({
occurrenceAt: pinned.scheduledAt, nextScheduledAt: '2026-09-15T17:00:00.000Z',
})
expect(decision(pinned, '2026-09-15T17:30:00Z')).toEqual({
occurrenceAt: '2026-09-15T17:00:00.000Z', nextScheduledAt: '2026-09-16T09:00:00.000Z',
})
})
it('selects only the latest due weekday after a long downtime', () => {
const record = reference('2026-06-01T00:00:00Z', '0 9 * * 1-5')
expect(record.scheduledAt).toBe('2026-06-01T09:00:00.000Z')
expect(decision(record, '2026-09-16T08:59:59Z')).toEqual({
occurrenceAt: '2026-09-15T09:00:00.000Z', nextScheduledAt: '2026-09-16T09:00:00.000Z',
})
expect(decision(record, '2026-09-16T09:00:00Z')).toEqual({
occurrenceAt: '2026-09-16T09:00:00.000Z', nextScheduledAt: '2026-09-17T09:00:00.000Z',
})
})
it('retains the committed target when no local occurrence is representable', () => {
const impossible = { rule: parsed('0 0 30 2 *'), scheduledAt: '2026-09-15T00:00:00.000Z' }
expect(decision(impossible, '2026-09-16T00:00:00Z')).toEqual({ occurrenceAt: impossible.scheduledAt })
})
it('stops an unsatisfiable rule at the documented horizon instead of the year ceiling', () => {
const impossible = { rule: parsed('0 0 30 2 *'), scheduledAt: '2026-09-15T00:00:00.000Z' }
// The search advances one LocalDate per candidate day and per skipped month, and the
// polyfill exposes that arithmetic on its prototypes, so those calls count the walk.
const forward = vi.spyOn(Temporal.PlainDate.prototype, 'add')
const backward = vi.spyOn(Temporal.PlainDate.prototype, 'subtract')
let walked = 0
try {
expect(decision(impossible, '2026-09-16T00:00:00Z')).toEqual({ occurrenceAt: impossible.scheduledAt })
walked = forward.mock.calls.length + backward.mock.calls.length
} finally {
forward.mockRestore()
backward.mockRestore()
}
// Each searched year costs at most 366 day steps and 12 month skips in each direction.
expect(walked).toBeGreaterThan(0)
expect(walked).toBeLessThanOrEqual(CRON_SEARCH_HORIZON_YEARS * (366 + 12) * 2)
})
it('converts a walked date once while it lies beyond the decision', () => {
const record = reference('2026-09-16T00:00:00Z', '* * * * *', 'America/New_York')
// Every candidate the walk evaluates is built from its date here, so counting
// this counts the candidates, not the dates visited.
const converted = vi.spyOn(Temporal.PlainDate.prototype, 'toPlainDateTime')
let candidates = 0
try {
decision(record, '2026-09-16T12:00:00Z')
candidates = converted.mock.calls.length
} finally {
converted.mockRestore()
}
// The date after the decision costs one candidate and the date that straddles it
// scans down from 23:59 to the decision's minute: 962 across the two reverse
// walks, plus the two the forward walk converts on the floor date. Without the
// whole-date skip the reverse walk also built all 1440 times of the date beyond
// it, 2402 in total, which this ceiling separates.
expect(candidates).toBeGreaterThan(0)
expect(candidates).toBeLessThanOrEqual(1_500)
})
it('resolves local dates on either side of the lowest supported UTC year', () => {
expect(reference('0001-01-01T00:00:00Z', '30 0 * * *').scheduledAt).toBe('0001-01-01T00:30:00.000Z')
expect(reference('0001-01-01T00:00:00Z', '0 0 * * *', 'Etc/GMT-1').scheduledAt)
.toBe('0001-01-01T23:00:00.000Z')
// A decision this early keeps the backward search on the four-digit floor, not the horizon.
expect(decision(reference('0001-01-01T00:00:00Z', '0 0 * * *'), '0001-01-02T00:00:00Z')).toEqual({
occurrenceAt: '0001-01-02T00:00:00.000Z', nextScheduledAt: '0001-01-03T00:00:00.000Z',
})
})
it('resolves a negative-offset due minute inside the floor date of its own zone', () => {
// The four-digit floor is an instant, so this rule's latest due minute before a
// decision inside the floor's UTC day is local and dated one day earlier.
const record = reference('0001-01-01T00:01:00Z', '* * * * *', 'Etc/GMT+1')
expect(decision(record, '0001-01-01T00:30:00Z')).toEqual({
occurrenceAt: '0001-01-01T00:30:00.000Z', nextScheduledAt: '0001-01-01T00:31:00.000Z',
})
})
it('retains the final occurrence and reports exhaustion without a five-digit target', () => {
const record = reference('9999-12-31T23:00:00Z', '* * * * *')
expect(decision(record, '9999-12-31T23:59:59.999Z')).toEqual({ occurrenceAt: '9999-12-31T23:59:00.000Z' })
expect(parsed('* * * * *').next(Date.parse('9999-12-31T23:59:00Z'))).toBeUndefined()
})
})