diff --git a/README.md b/README.md index 35bebef5..21b395a2 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,8 @@ day of week 0-7 (0 or 7 is Sunday, or use names) - `unrefTimeout`: [OPTIONAL] - Useful for controlling event loop behavior. More details [here](https://nodejs.org/api/timers.html#timers_timeout_unref). +- `waitForCompletion`: [OPTIONAL] - If `true`, no additional instances of the `onTick` callback function will run until the current onTick callback has completed. Any new scheduled executions that occur while the current callback is running will be skipped entirely. Default is `false`. + #### Methods - `from` (static): Create a new CronJob object providing arguments as an object. See argument names and descriptions above. @@ -214,6 +216,23 @@ day of week 0-7 (0 or 7 is Sunday, or use names) - `addCallback`: Permits addition of `onTick` callbacks. +#### Properties + +- `isCallbackRunning`: [READ-ONLY] Indicates if a callback is currently executing. + + ```javascript + const job = new CronJob('* * * * * *', async () => { + console.log(job.isCallbackRunning); // true during callback execution + await someAsyncTask(); + console.log(job.isCallbackRunning); // still true until callback completes + }); + + console.log(job.isCallbackRunning); // false + job.start(); + console.log(job.running); // true (schedule is active) + console.log(job.isCallbackRunning); // false (no callback executing) + ``` + ### CronTime Class #### Constructor diff --git a/src/job.ts b/src/job.ts index b1d8be75..59555ca0 100644 --- a/src/job.ts +++ b/src/job.ts @@ -21,10 +21,16 @@ export class CronJob { onComplete?: WithOnComplete extends true ? CronOnCompleteCallback : undefined; + waitForCompletion = false; + private _isCallbackRunning = false; private _timeout?: NodeJS.Timeout; private _callbacks: CronCallback>[] = []; + get isCallbackRunning() { + return this._isCallbackRunning; + } + constructor( cronTime: CronJobParams['cronTime'], onTick: CronJobParams['onTick'], @@ -34,7 +40,8 @@ export class CronJob { context?: CronJobParams['context'], runOnInit?: CronJobParams['runOnInit'], utcOffset?: null, - unrefTimeout?: CronJobParams['unrefTimeout'] + unrefTimeout?: CronJobParams['unrefTimeout'], + waitForCompletion?: CronJobParams['waitForCompletion'] ); constructor( cronTime: CronJobParams['cronTime'], @@ -45,7 +52,8 @@ export class CronJob { context?: CronJobParams['context'], runOnInit?: CronJobParams['runOnInit'], utcOffset?: CronJobParams['utcOffset'], - unrefTimeout?: CronJobParams['unrefTimeout'] + unrefTimeout?: CronJobParams['unrefTimeout'], + waitForCompletion?: CronJobParams['waitForCompletion'] ); constructor( cronTime: CronJobParams['cronTime'], @@ -56,9 +64,11 @@ export class CronJob { context?: CronJobParams['context'], runOnInit?: CronJobParams['runOnInit'], utcOffset?: CronJobParams['utcOffset'], - unrefTimeout?: CronJobParams['unrefTimeout'] + unrefTimeout?: CronJobParams['unrefTimeout'], + waitForCompletion?: CronJobParams['waitForCompletion'] ) { this.context = (context ?? this) as CronContext; + this.waitForCompletion = Boolean(waitForCompletion); // runtime check for JS users if (timeZone != null && utcOffset != null) { @@ -92,7 +102,7 @@ export class CronJob { if (runOnInit) { this.lastExecution = new Date(); - this.fireOnTick(); + void this.fireOnTick(); } if (start) this.start(); @@ -117,7 +127,8 @@ export class CronJob { params.context, params.runOnInit, params.utcOffset, - params.unrefTimeout + params.unrefTimeout, + params.waitForCompletion ); } else if (params.utcOffset != null) { return new CronJob( @@ -129,7 +140,8 @@ export class CronJob { params.context, params.runOnInit, params.utcOffset, - params.unrefTimeout + params.unrefTimeout, + params.waitForCompletion ); } else { return new CronJob( @@ -141,7 +153,8 @@ export class CronJob { params.context, params.runOnInit, params.utcOffset, - params.unrefTimeout + params.unrefTimeout, + params.waitForCompletion ); } } @@ -193,14 +206,26 @@ export class CronJob { return this.cronTime.sendAt(); } - fireOnTick() { - for (const callback of this._callbacks) { - void callback.call( - this.context, - this.onComplete as WithOnComplete extends true - ? CronOnCompleteCallback - : never - ); + async fireOnTick() { + if (!this.waitForCompletion && this._isCallbackRunning) return; + + this._isCallbackRunning = true; + + try { + for (const callback of this._callbacks) { + const result = callback.call( + this.context, + this.onComplete as WithOnComplete extends true + ? CronOnCompleteCallback + : never + ); + + if (this.waitForCompletion) await result; + } + } catch (error) { + console.error('[Cron] error in callback', error); + } finally { + this._isCallbackRunning = false; } } @@ -209,9 +234,7 @@ export class CronJob { } start() { - if (this.running) { - return; - } + if (this.running) return; const MAXDELAY = 2147483647; // The maximum number of milliseconds setTimeout will wait. let timeout = this.cronTime.getTimeout(); @@ -262,11 +285,9 @@ export class CronJob { this.running = false; // start before calling back so the callbacks have the ability to stop the cron job - if (!this.runOnce) { - this.start(); - } + if (!this.runOnce) this.start(); - this.fireOnTick(); + void this.fireOnTick(); } }; @@ -290,14 +311,37 @@ export class CronJob { return this.lastExecution; } + private async _executeOnComplete() { + if (typeof this.onComplete !== 'function') return; + + try { + await this.onComplete.call(this.context); + } catch (error) { + console.error('[Cron] error in onComplete callback:', error); + } + } + + private async _waitForJobCompletion() { + while (this._isCallbackRunning) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + /** * Stop the cronjob. */ stop() { if (this._timeout) clearTimeout(this._timeout); this.running = false; - if (typeof this.onComplete === 'function') { - void this.onComplete.call(this.context); + + if (!this.waitForCompletion) { + void this._executeOnComplete(); + return; } + + void Promise.resolve().then(async () => { + await this._waitForJobCompletion(); + await this._executeOnComplete(); + }); } } diff --git a/src/types/cron.types.ts b/src/types/cron.types.ts index 86040441..4df8f09f 100644 --- a/src/types/cron.types.ts +++ b/src/types/cron.types.ts @@ -15,6 +15,7 @@ interface BaseCronJobParams< context?: C; runOnInit?: boolean | null; unrefTimeout?: boolean | null; + waitForCompletion?: boolean | null; } export type CronJobParams< diff --git a/tests/cron.test.ts b/tests/cron.test.ts index 0aef3ba4..c11e8f69 100644 --- a/tests/cron.test.ts +++ b/tests/cron.test.ts @@ -3,27 +3,35 @@ import sinon from 'sinon'; import { CronJob, CronTime } from '../src'; describe('cron', () => { - // eslint-disable-next-line jest/no-standalone-expect + let callback: jest.Mock; + + beforeEach(() => { + callback = jest.fn(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + // eslint-disable-next-line jest/no-standalone-expect expect.hasAssertions(); + sinon.restore(); }); describe('with seconds', () => { it('should run every second (* * * * * *)', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob('* * * * * *', callback, null, true); expect(callback).not.toHaveBeenCalled(); clock.tick(1000); job.stop(); - clock.restore(); expect(callback).toHaveBeenCalledTimes(1); }); it('should run second with onComplete (* * * * * *)', done => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob( '* * * * * *', @@ -37,12 +45,10 @@ describe('cron', () => { clock.tick(1000); job.stop(); - clock.restore(); }); it('should use standard cron no-seconds syntax (* * * * *)', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob('* * * * *', callback, null, true); clock.tick(1000); // tick second @@ -50,24 +56,22 @@ describe('cron', () => { clock.tick(59 * 1000); // tick minute job.stop(); - clock.restore(); - expect(callback).toHaveBeenCalledTimes(1); }); it('should run every second for 5 seconds (* * * * * *)', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob('* * * * * *', callback, null, true); - for (let i = 0; i < 5; i++) clock.tick(1000); + for (let i = 0; i < 5; i++) { + clock.tick(1000); + } job.stop(); - clock.restore(); expect(callback).toHaveBeenCalledTimes(5); }); it('should run every second for 5 seconds with onComplete (* * * * * *)', done => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); + const job = new CronJob( '* * * * * *', callback, @@ -77,44 +81,37 @@ describe('cron', () => { }, true ); + for (let i = 0; i < 5; i++) clock.tick(1000); job.stop(); - clock.restore(); }); it('should run every second for 5 seconds (*/1 * * * * *)', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob('*/1 * * * * *', callback, null, true); for (let i = 0; i < 5; i++) clock.tick(1000); job.stop(); - clock.restore(); expect(callback).toHaveBeenCalledTimes(5); }); it('should run every 2 seconds for 1 seconds (*/2 * * * * *)', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob('*/2 * * * * *', callback, null, true); clock.tick(1000); job.stop(); - clock.restore(); expect(callback).toHaveBeenCalledTimes(0); }); it('should run every 2 seconds for 5 seconds (*/2 * * * * *)', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob('*/2 * * * * *', callback, null, true); for (let i = 0; i < 5; i++) clock.tick(1000); job.stop(); - clock.restore(); expect(callback).toHaveBeenCalledTimes(2); }); it('should run every second for 5 seconds with onComplete (*/1 * * * * *)', done => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob( '*/1 * * * * *', callback, @@ -126,22 +123,18 @@ describe('cron', () => { ); for (let i = 0; i < 5; i++) clock.tick(1000); job.stop(); - clock.restore(); }); it('should run every second for a range ([start]-[end] * * * * *)', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob('0-8 * * * * *', callback, null, true); clock.tick(10000); job.stop(); - clock.restore(); expect(callback).toHaveBeenCalledTimes(8); }); it('should run every second for a range ([start]-[end] * * * * *) with onComplete', done => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob( '0-8 * * * * *', callback, @@ -153,12 +146,10 @@ describe('cron', () => { ); clock.tick(10000); job.stop(); - clock.restore(); }); it('should default to full range when upper range not provided (1/2 * * * * *)', done => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob( '1/2 * * * * *', callback, @@ -170,12 +161,10 @@ describe('cron', () => { ); clock.tick(1000 * 60); job.stop(); - clock.restore(); }); it('should run every second (* * * * * *) using the object constructor', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = CronJob.from({ cronTime: '* * * * * *', onTick: callback, @@ -183,13 +172,11 @@ describe('cron', () => { }); clock.tick(1000); job.stop(); - clock.restore(); expect(callback).toHaveBeenCalledTimes(1); }); it('should run every second with onComplete (* * * * * *) using the object constructor', done => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = CronJob.from({ cronTime: '* * * * * *', onTick: callback, @@ -201,7 +188,6 @@ describe('cron', () => { }); clock.tick(1000); job.stop(); - clock.restore(); }); }); @@ -225,22 +211,18 @@ describe('cron', () => { expect(l.every(i => i % 30 === 0)).toBe(true); job.stop(); - clock.restore(); }); it('should run every 45 minutes for 2 hours (0 */45 * * * *)', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob('0 */45 * * * *', callback, null, true); for (let i = 0; i < 2; i++) clock.tick(60 * 60 * 1000); job.stop(); - clock.restore(); expect(callback).toHaveBeenCalledTimes(4); }); it('should run every 45 minutes for 2 hours (0 */45 * * * *) with onComplete', done => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob( '0 */45 * * * *', callback, @@ -252,13 +234,11 @@ describe('cron', () => { ); for (let i = 0; i < 2; i++) clock.tick(60 * 60 * 1000); job.stop(); - clock.restore(); }); }); it('should start and stop job from outside', done => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob( '* * * * * *', function () { @@ -277,7 +257,6 @@ describe('cron', () => { it('should start and stop job from inside (default context)', done => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); new CronJob( '* * * * * *', function () { @@ -300,7 +279,6 @@ describe('cron', () => { const clock = sinon.useFakeTimers(d.getTime()); const s = d.getSeconds() + 1; d.setSeconds(s); - const callback = jest.fn(); const job = new CronJob( d, () => { @@ -312,7 +290,6 @@ describe('cron', () => { true ); clock.tick(1000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); @@ -321,7 +298,6 @@ describe('cron', () => { const d = new Date(); const clock = sinon.useFakeTimers(d.getTime()); d.setSeconds(d.getSeconds() + 1); - const callback = jest.fn(); await new Promise(resolve => { const job = new CronJob( @@ -338,7 +314,6 @@ describe('cron', () => { true ); clock.tick(1000); - clock.restore(); job.stop(); }); @@ -350,7 +325,6 @@ describe('cron', () => { const d = new Date(); const clock = sinon.useFakeTimers(d.getTime()); d.setSeconds(d.getSeconds() + 1); - const callback = jest.fn(); await new Promise(resolve => { const job = CronJob.from({ @@ -367,7 +341,6 @@ describe('cron', () => { start: true }); clock.tick(1000); - clock.restore(); job.stop(); }); @@ -403,12 +376,10 @@ describe('cron', () => { ); clock.tick(1000); job.stop(); - clock.restore(); }); it('should wait and not fire immediately', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const d = new Date().getTime() + 31 * 86400 * 1000; @@ -417,14 +388,12 @@ describe('cron', () => { clock.tick(1000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(0); }); it('should wait but fire on init', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const d = new Date().getTime() + 31 * 86400 * 1000; @@ -440,14 +409,12 @@ describe('cron', () => { clock.tick(1000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); it('should fire on init but not run until started', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = CronJob.from({ cronTime: '* * * * * *', @@ -461,7 +428,6 @@ describe('cron', () => { clock.tick(3500); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(4); }); @@ -470,7 +436,6 @@ describe('cron', () => { describe('with timezone', () => { it('should run a job using cron syntax with a timezone', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); let zone = 'America/Chicago'; // New Orleans time let t = DateTime.local().setZone(zone); @@ -500,14 +465,12 @@ describe('cron', () => { ); clock.tick(1000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); it('should run a job using cron syntax with a "UTC+HH:mm" offset as timezone', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); // Current time const d = DateTime.local(); @@ -539,7 +502,6 @@ describe('cron', () => { ); clock.tick(1000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); @@ -560,10 +522,8 @@ describe('cron', () => { expect(d.hour).not.toBe(t.hour); d = d.plus({ seconds: 1 }); const clock = sinon.useFakeTimers(d.valueOf()); - const callback = jest.fn(); const job = new CronJob(d.toJSDate(), callback, null, true, zone); clock.tick(1000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); @@ -595,7 +555,6 @@ describe('cron', () => { clock.tick(1000); - clock.restore(); job.stop(); }); @@ -613,7 +572,6 @@ describe('cron', () => { clock.tick(1000); - clock.restore(); job.stop(); }); @@ -634,7 +592,6 @@ describe('cron', () => { clock.tick(1000); - clock.restore(); job.stop(); }); @@ -653,7 +610,6 @@ describe('cron', () => { clock.tick(1000); - clock.restore(); job.stop(); }); }); @@ -683,7 +639,6 @@ describe('cron', () => { }); it('should test start of month', () => { - const callback = jest.fn(); const d = new Date('12/31/2014'); d.setSeconds(59); d.setMinutes(59); @@ -706,7 +661,6 @@ describe('cron', () => { }); it('should not fire if time was adjusted back', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers({ toFake: ['setTimeout'] }); @@ -716,12 +670,10 @@ describe('cron', () => { clock.tick(60000); expect(callback).toHaveBeenCalledTimes(0); - clock.restore(); job.stop(); }); it('should run every day', () => { - const callback = jest.fn(); const d = new Date('12/31/2014'); d.setSeconds(59); d.setMinutes(59); @@ -738,13 +690,11 @@ describe('cron', () => { const twoWeeks = 14 * 24 * 60 * 60 * 1000; clock.tick(twoWeeks); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(14); }); it('should run every 2 hours between hours', () => { - const callback = jest.fn(); const d = new Date('12/31/2014'); d.setSeconds(0); d.setMinutes(0); @@ -765,13 +715,11 @@ describe('cron', () => { expect(callback).toHaveBeenCalledTimes(3); clock.tick(2 * 60 * 1000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(3); }); it('should run every minute', () => { - const callback = jest.fn(); const d = new Date('12/31/2014'); d.setSeconds(0); d.setMinutes(0); @@ -789,13 +737,11 @@ describe('cron', () => { clock.tick(60 * 1000); expect(callback).toHaveBeenCalledTimes(2); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(2); }); it('should run every day at 12:30', () => { - const callback = jest.fn(); const d = new Date('12/31/2014'); d.setSeconds(0); d.setMinutes(0); @@ -817,13 +763,11 @@ describe('cron', () => { expect(callback).toHaveBeenCalledTimes(3); clock.tick(5 * day); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(8); }); it('should trigger onTick at midnight', () => { - const callback = jest.fn(); const d = new Date('12/31/2014'); d.setSeconds(59); d.setMinutes(59); @@ -840,13 +784,11 @@ describe('cron', () => { clock.tick(1000); // move clock 1 second expect(callback).toHaveBeenCalledTimes(1); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); it('should run every day UTC', () => { - const callback = jest.fn(); const d = new Date('12/31/2014'); d.setSeconds(0); d.setMinutes(0); @@ -869,14 +811,12 @@ describe('cron', () => { expect(callback).toHaveBeenCalledTimes(3); clock.tick(5 * day); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(8); }); // from https://github.com/kelektiv/node-cron/issues/180#issuecomment-154108131 it('should run once not double', () => { - const callback = jest.fn(); const d = new Date(2015, 1, 1, 1, 1, 41, 0); const clock = sinon.useFakeTimers(d.getTime()); @@ -889,7 +829,6 @@ describe('cron', () => { const minute = 60 * 1000; clock.tick(minute); expect(callback).toHaveBeenCalledTimes(1); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); @@ -901,7 +840,6 @@ describe('cron', () => { * source: https://github.com/cronie-crond/cronie/blob/0d669551680f733a4bdd6bab082a0b3d6d7f089c/src/cronnext.c#L401-L403 */ it('should work correctly for max match interval', () => { - const callback = jest.fn(); const d = new Date(2096, 2, 1); const clock = sinon.useFakeTimers(d.getTime()); @@ -918,7 +856,6 @@ describe('cron', () => { // tick by 1 day clock.tick(24 * 60 * 60 * 1000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); @@ -926,7 +863,6 @@ describe('cron', () => { describe('with utcOffset', () => { it('should run a job using cron syntax with number format utcOffset', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); // Current time const t = DateTime.local(); // UTC Offset decreased by an hour @@ -948,14 +884,12 @@ describe('cron', () => { expect(callback).toHaveBeenCalledTimes(0); clock.tick(1); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); it('should run a job using cron syntax with numeric format utcOffset with minute support', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); // Current time const t = DateTime.local(); @@ -977,14 +911,12 @@ describe('cron', () => { expect(callback).toHaveBeenCalledTimes(0); clock.tick(1); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); it('should run a job using cron syntax with number format utcOffset that is 0', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob( '* * * * * *', @@ -1001,7 +933,6 @@ describe('cron', () => { expect(callback).toHaveBeenCalledTimes(0); clock.tick(1); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); @@ -1015,7 +946,6 @@ describe('cron', () => { describe('setTime', () => { it('should start, change time, start again', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const job = new CronJob('* * * * * *', callback); @@ -1028,13 +958,11 @@ describe('cron', () => { clock.tick(4000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(3); }); it('should start, stop, change time, not start again', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const job = new CronJob('* * * * * *', callback); @@ -1048,13 +976,11 @@ describe('cron', () => { clock.tick(4000); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); it('should setTime with invalid object', () => { - const callback = jest.fn(); const job = new CronJob('* * * * * *', callback); expect(() => { // @ts-expect-error time parameter cannot be undefined @@ -1063,7 +989,6 @@ describe('cron', () => { }); it('should start, change time, exception', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const job = new CronJob('* * * * * *', callback); @@ -1078,13 +1003,11 @@ describe('cron', () => { job.setTime(time); }).toThrow(); - clock.restore(); job.stop(); expect(callback).toHaveBeenCalledTimes(1); }); it('should create recurring job, setTime with actual date, start and run once (#739)', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const job = new CronJob('0 0 20 * * *', callback); @@ -1100,8 +1023,6 @@ describe('cron', () => { clock.tick(60000); - clock.restore(); - expect(callback).toHaveBeenCalledTimes(1); expect(job.running).toBe(false); }); @@ -1109,7 +1030,6 @@ describe('cron', () => { describe('nextDate(s)', () => { it('should give the next date to run at', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const job = new CronJob('* * * * * *', callback); const d = Date.now(); @@ -1120,7 +1040,6 @@ describe('cron', () => { }); it('should give the next 5 dates to run at', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const job = new CronJob('* * * * * *', callback); const d = Date.now(); @@ -1137,7 +1056,6 @@ describe('cron', () => { }); it('should give an empty array when called without argument', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const job = new CronJob('* * * * * *', callback); @@ -1148,7 +1066,6 @@ describe('cron', () => { }); it('should automatically setup a new timeout if we roll past the max timeout delay', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const d = new Date(); d.setMilliseconds(2147485647 * 2); // MAXDELAY in `job.js` + 2000. @@ -1159,11 +1076,9 @@ describe('cron', () => { clock.tick(2147489648); expect(callback).toHaveBeenCalledTimes(1); job.stop(); - clock.restore(); }); it('should give the correct last execution date', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const job = new CronJob('* * * * * *', callback); job.start(); @@ -1171,11 +1086,9 @@ describe('cron', () => { expect(callback).toHaveBeenCalledTimes(1); expect(job.lastDate()?.getTime()).toBe(1000); job.stop(); - clock.restore(); }); it('should give the correct last execution date for intervals greater than 25 days (#710)', () => { - const callback = jest.fn(); const clock = sinon.useFakeTimers(); const job = new CronJob('0 0 0 1 * *', callback); // At 00:00 on day-of-month 1. @@ -1188,7 +1101,6 @@ describe('cron', () => { expect(job.lastDate()?.getTime()).toBeUndefined(); job.stop(); - clock.restore(); }); it('should throw when providing both exclusive parameters timeZone and utcOffset', () => { @@ -1221,7 +1133,6 @@ describe('cron', () => { it('should support async callback', () => { const clock = sinon.useFakeTimers(); - const callback = jest.fn(); const job = new CronJob( '* * * * * *', async function () { @@ -1237,7 +1148,152 @@ describe('cron', () => { ); clock.tick(1500); job.stop(); - clock.restore(); expect(callback).toHaveBeenCalledTimes(1); }); + + describe('waitForCompletion and job status tracking', () => { + it('should wait for async job completion when waitForCompletion is true', async () => { + const clock = sinon.useFakeTimers(); + let isJobCompleted = false; + + const job = new CronJob( + '*/2 * * * * *', + async () => { + expect(job.isCallbackRunning).toBe(true); + await new Promise(resolve => { + setTimeout(() => { + callback(); + isJobCompleted = true; + resolve(); + }, 1000); + }); + }, + null, + true, + null, + null, + false, + null, + false, + true // waitForCompletion: true + ); + + expect(job.isCallbackRunning).toBe(false); + + // First execution + await clock.tickAsync(2000); + expect(job.isCallbackRunning).toBe(true); + + // Wait for job completion + await clock.tickAsync(500); + expect(isJobCompleted).toBe(false); + expect(job.isCallbackRunning).toBe(true); + + await clock.tickAsync(1000); + expect(isJobCompleted).toBe(true); + expect(job.isCallbackRunning).toBe(false); + + job.stop(); + }); + + it('should not wait for job completion when waitForCompletion is false', () => { + const clock = sinon.useFakeTimers(); + let isJobCompleted = false; + + const job = new CronJob( + '* * * * * *', + () => { + setTimeout(() => { + callback(); + isJobCompleted = true; + }, 500); + }, + null, + true, + null, + null, + false, + null, + false, + false // waitForCompletion: false + ); + + expect(job.isCallbackRunning).toBe(false); + + // First execution + clock.tick(1000); + expect(isJobCompleted).toBe(false); + expect(job.isCallbackRunning).toBe(false); + + job.stop(); + }); + + it('should track multiple running jobs correctly', async () => { + const clock = sinon.useFakeTimers(); + const executionOrder: number[] = []; + + const job = new CronJob( + '* * * * * *', + async () => { + await new Promise(resolve => { + setTimeout(() => { + callback(); + resolve(); + }, 1500); + }); + const jobNumber = executionOrder.length + 1; + executionOrder.push(jobNumber); + }, + null, + true, + null, + null, + false, + null, + false, + true // waitForCompletion: true + ); + + await clock.tickAsync(3500); + + expect(executionOrder).toEqual([1, 2]); + job.stop(); + }); + + it('should handle stop() correctly while job is running', async () => { + const clock = sinon.useFakeTimers(); + let isJobCompleted = false; + + const job = new CronJob( + '* * * * * *', + async () => { + await new Promise(resolve => { + setTimeout(() => { + callback(); + isJobCompleted = true; + resolve(); + }, 500); + }); + }, + null, + true, + null, + null, + false, + null, + false, + true // waitForCompletion: true + ); + + await clock.tickAsync(1000); + expect(job.isCallbackRunning).toBe(true); + + job.stop(); + await clock.tickAsync(500); + + expect(isJobCompleted).toBe(true); + expect(job.isCallbackRunning).toBe(false); + expect(job.running).toBe(false); + }); + }); });