@serenity-js/core
Version:
The core Serenity/JS framework, providing the Screenplay Pattern interfaces, as well as the test reporting and integration infrastructure
207 lines (170 loc) • 5.66 kB
text/typescript
import type { JSONObject } from 'tiny-types';
import { OperationInterruptedError, TimeoutExpiredError } from '../../../errors';
import type { Clock } from './Clock';
import type { DelayedCallback } from './DelayedCallback';
import { Duration } from './Duration';
import type { RepeatUntilLimits } from './RepeatUntilLimits';
import type { Timestamp } from './Timestamp';
/**
* @group Time
*/
export class Scheduler {
private scheduledOperations: Array<ScheduledOperation<unknown>> = [];
/**
* @param clock
* @param interactionTimeout
* The maximum amount of time to give to a callback to complete before throwing an error
*/
constructor(
private readonly clock: Clock,
private readonly interactionTimeout: Duration,
) {
}
toJSON(): JSONObject {
return {
clock: this.clock.toJSON(),
interactionTimeout: this.interactionTimeout.toJSON(),
}
}
/**
* Schedules a callback function to be invoked after a delay
*
* @param delay
* @param callback
*/
after<Result>(delay: Duration, callback: DelayedCallback<Result>): Promise<Result> {
return this.repeatUntil<Result>(
callback,
{
maxInvocations: 1,
delayBetweenInvocations: () => delay,
timeout: this.interactionTimeout.plus(delay),
},
);
}
/**
* Returns a `Promise` to be resolved after a `delay`
*
* @param delay
*/
waitFor(delay: Duration): Promise<void> {
return this.repeatUntil<void>(
() => void 0,
{
maxInvocations: 1,
delayBetweenInvocations: () => delay,
// make sure waitFor doesn't get terminated before it's resolved
timeout: this.interactionTimeout.plus(delay),
},
);
}
/**
* Schedules a callback function to be repeated, according to configured limits.
*
* @param callback
* @param limits
*/
async repeatUntil<Result>(
callback: DelayedCallback<Result>,
limits: RepeatUntilLimits<Result> = {},
): Promise<Result> {
const {
maxInvocations = Number.POSITIVE_INFINITY,
delayBetweenInvocations = noDelay,
timeout = this.interactionTimeout,
exitCondition = noEarlyExit,
errorHandler = rethrowErrors,
} = limits;
const operation = new ScheduledOperation(
this.clock,
callback,
{
exitCondition,
maxInvocations,
delayBetweenInvocations,
timeout,
errorHandler,
}
);
this.scheduledOperations.push(operation);
return operation.start()
}
stop(): void {
for (const operation of this.scheduledOperations) {
operation.cancel();
}
}
}
class ScheduledOperation<Result> {
private currentInvocation = 0;
private invocationsLeft = 0;
private startedAt: Timestamp;
private lastResult: Result;
private isCancelled = false;
constructor(
private readonly clock: Clock,
private readonly callback: DelayedCallback<Result>,
private readonly limits: RepeatUntilLimits<Result> = {},
) {
}
async start(): Promise<Result> {
this.currentInvocation = 0;
this.invocationsLeft = this.limits.maxInvocations;
this.startedAt = this.clock.now();
return await this.poll();
}
private async poll(): Promise<Result> {
await this.clock.waitFor(this.limits.delayBetweenInvocations(this.currentInvocation));
if (this.isCancelled) {
throw new OperationInterruptedError('Scheduler stopped before executing callback');
}
const receipt = await this.invoke();
if (receipt.hasCompleted) {
return receipt.result;
}
this.currentInvocation++;
this.invocationsLeft--;
return await this.poll();
}
private async invoke(): Promise<{ result?: Result, error?: Error, hasCompleted: boolean }> {
const timeoutExpired = this.startedAt.plus(this.limits.timeout).isBefore(this.clock.now());
const isLastInvocation = this.invocationsLeft === 1;
if (this.invocationsLeft === 0) {
return {
result: this.lastResult,
hasCompleted: true,
};
}
try {
if (timeoutExpired) {
throw new TimeoutExpiredError(`Timeout of ${ this.limits.timeout } has expired`);
}
this.lastResult = await this.callback({ currentTime: this.clock.now(), i: this.currentInvocation });
return {
result: this.lastResult,
hasCompleted: this.limits.exitCondition(this.lastResult) || isLastInvocation,
}
}
catch(error) {
this.limits.errorHandler(error, this.lastResult);
// if the errorHandler didn't throw, it's a recoverable error
return {
result: this.lastResult,
error,
hasCompleted: isLastInvocation,
}
}
}
cancel(): void {
this.isCancelled = true;
}
}
function noDelay() {
return Duration.ofMilliseconds(0);
}
function noEarlyExit() {
return false;
}
function rethrowErrors(error: Error) {
throw error;
}