Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 10 additions & 13 deletions src/CronExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
29 changes: 29 additions & 0 deletions tests/CronExpression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 * * * * *';
Expand Down
Loading