From 970db12135e79e0150a2986c38e94a49c81889fe Mon Sep 17 00:00:00 2001 From: Harri Siirak Date: Mon, 3 Aug 2026 00:33:05 +0300 Subject: [PATCH] fix: round the current date to whole seconds so occurrences on the end date are returned --- src/CronExpression.ts | 23 ++++++++++------------- tests/CronExpression.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/CronExpression.ts b/src/CronExpression.ts index 4c89cd6..959f958 100644 --- a/src/CronExpression.ts +++ b/src/CronExpression.ts @@ -517,8 +517,15 @@ export class CronExpression { const dateMathVerb: DateMathOp = reverse ? DateMathOp.Subtract : DateMathOp.Add; const currentDate = new CronDate(this.#currentDate); const startTimestamp = currentDate.getTime(); - let stepCount = 0; + if (currentDate.getMilliseconds() > 0) { + currentDate.setMilliseconds(0); + if (!reverse) { + currentDate.applyDateOperation(DateMathOp.Add, TimeUnit.Second, this.#fields.hour.values.length); + } + } + + let stepCount = 0; while (++stepCount < LOOP_LIMIT) { this.#validateTimeSpan(currentDate); @@ -543,15 +550,8 @@ export class CronExpression { } if (startTimestamp === currentDate.getTime()) { - // Still on the start time. Step one second in the search direction and keep - // looking so a distinct occurrence is returned. A backwards search that began - // on a sub-second offset is the exception: stripping the milliseconds below - // already yields an earlier matching time, so break and accept it instead of - // spinning until the loop limit. - if (dateMathVerb === 'Add' || currentDate.getMilliseconds() === 0) { - currentDate.applyDateOperation(dateMathVerb, TimeUnit.Second, this.#fields.hour.values.length); - continue; - } + currentDate.applyDateOperation(dateMathVerb, TimeUnit.Second, this.#fields.hour.values.length); + continue; } break; } @@ -560,9 +560,6 @@ export class CronExpression { throw new Error(LOOPS_LIMIT_EXCEEDED_ERROR_MESSAGE); } - if (currentDate.getMilliseconds() !== 0) { - currentDate.setMilliseconds(0); - } this.#currentDate = currentDate; return currentDate; } diff --git a/tests/CronExpression.test.ts b/tests/CronExpression.test.ts index 7b9741e..19e5b3e 100644 --- a/tests/CronExpression.test.ts +++ b/tests/CronExpression.test.ts @@ -294,6 +294,15 @@ describe('CronExpression', () => { expect(() => cronExpression.prev()).toThrow(TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE); }); + test('should throw when the previous occurrence would land inside a sub-second start date', () => { + const cronExpression = CronExpressionParser.parse('* * * * * *', { + currentDate: new Date('2025-01-01T10:00:05.500Z'), + startDate: new Date('2025-01-01T10:00:05.200Z'), + tz: 'UTC', + }); + expect(() => cronExpression.prev()).toThrow(TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE); + }); + test('should throw when no occurrence exists within the loop limit', () => { // Day 31 restricted to April and June, which only ever have 30 days, so // the expression can never match and iteration must not return a bogus date. @@ -305,6 +314,26 @@ describe('CronExpression', () => { }); }); + test('returns an occurrence on the end date when the current date carries milliseconds', () => { + const interval = CronExpressionParser.parse('*/1 * * * * *', { + currentDate: new Date('2025-01-01T00:00:09.151Z'), + endDate: new Date('2025-01-01T00:00:10.000Z'), + tz: 'UTC', + }); + expect(interval.next().toISOString()).toBe('2025-01-01T00:00:10.000Z'); + }); + + test('keeps interleaved forward and backward iteration anchored to the last result', () => { + const interval = CronExpressionParser.parse('*/5 * * * *', { + currentDate: new Date('2025-01-01T12:02:00.000Z'), + tz: 'UTC', + }); + expect(interval.next().toISOString()).toBe('2025-01-01T12:05:00.000Z'); + expect(interval.next().toISOString()).toBe('2025-01-01T12:10:00.000Z'); + expect(interval.prev().toISOString()).toBe('2025-01-01T12:05:00.000Z'); + expect(interval.next().toISOString()).toBe('2025-01-01T12:10:00.000Z'); + }); + describe('stringify', () => { test('stringify cron expression all stars no seconds 0 * * * * *', () => { const expected = '0 * * * * *';