This code contains some helper functions for testing xstate v5+ Actor and Machine behavior using Promise
s and "traditional" assertions (vs state-machine-based testing).
Maybe I'll put some examples here.
/** | |
* Utilities for testing `xstate` (v5) actors in Node.js | |
* | |
* @license Apache-2.0 https://apache.org/licenses/LICENSE-2.0 | |
* @author <[email protected]> | |
*/ | |
import {scheduler} from 'node:timers/promises'; | |
import * as xs from 'xstate'; | |
/** | |
* Any event or emitted-event from an actor | |
*/ | |
export type ActorEvent<T extends xs.AnyActorLogic> = | |
| xs.EventFromLogic<T> | |
| xs.EmittedFrom<T>; | |
/** | |
* A tuple of events emitted by an actor, based on a {@link ActorEventTypeTuple} | |
* | |
* @see {@link AnyActorRunner.runUntilEvent} | |
*/ | |
export type ActorEventTuple< | |
T extends xs.AnyActorLogic, | |
EventTypes extends ActorEventTypeTuple<T>, | |
> = {[K in keyof EventTypes]: EventFromEventType<T, EventTypes[K]>}; | |
/** | |
* The `type` prop of any event or emitted event from an actor | |
*/ | |
export type ActorEventType<T extends xs.AnyActorLogic> = ActorEvent<T>['type']; | |
/** | |
* A tuple of event types (event names) emitted by an actor | |
* | |
* @see {@link AnyActorRunner.runUntilEvent} | |
*/ | |
export type ActorEventTypeTuple<T extends xs.AnyActorLogic> = [ | |
ActorEventType<T>, | |
...ActorEventType<T>, | |
]; | |
/** | |
* Options for methods in {@link AnyActorRunner} | |
*/ | |
export type ActorRunnerOptions = { | |
/** | |
* Default actor ID to use | |
*/ | |
id?: string; | |
/** | |
* Default inspector to use | |
* | |
* @param An {@link xs.InspectionEvent InspectionEvent} | |
*/ | |
inspect?: (evt: xs.InspectionEvent) => void; | |
/** | |
* Default logger to use | |
* | |
* @param Anything To be logged | |
*/ | |
logger?: (...args: any[]) => void; | |
/** | |
* Default timeout for those methods which accept a timeout. | |
*/ | |
timeout?: number; | |
}; | |
/** | |
* Options in {@link ActorRunner} methods where an existing {@link Actor} is | |
* provided instead of input for a new `Actor`. | |
* | |
* These methods cannot and should not overwrite an existing actor's ID. | |
*/ | |
export type ActorRunnerOptionsWithActor = Omit<ActorRunnerOptions, 'id'>; | |
/** | |
* Frankenpromise that is both a `Promise` and an {@link xs.Actor}. | |
* | |
* Returned by some methods in {@link AnyActorRunner} | |
*/ | |
export type ActorThenable< | |
T extends xs.AnyActorLogic, | |
Out = void, | |
> = Promise<Out> & xs.Actor<T>; | |
/** | |
* Lookup for event/emitted-event based on type | |
*/ | |
export type EventFromEventType< | |
T extends xs.AnyActorLogic, | |
K extends ActorEventType<T>, | |
> = xs.ExtractEvent<ActorEvent<T>, K>; | |
/** | |
* Options for methods in {@link AnyActorRunner} which provide their own | |
* inspection callbacks, and thus do allow custom inspectors. | |
*/ | |
export type OptionsWithoutInspect<T extends ActorRunnerOptions> = Omit< | |
T, | |
'inspect' | |
>; | |
export interface ActorRunner<T extends xs.AnyActorLogic> { | |
/** | |
* Default actor logic to use | |
*/ | |
defaultActorLogic: T; | |
/** | |
* Default actor ID to use when creating an actor. | |
*/ | |
defaultId?: string; | |
/** | |
* Default inspector to use. | |
*/ | |
defaultInspector: (evt: xs.InspectionEvent) => void; | |
/** | |
* Default logger | |
*/ | |
defaultLogger: (...args: any[]) => void; | |
/** | |
* Default timeout for those methods which accept a timeout. | |
*/ | |
defaultTimeout: number; | |
/** | |
* Runs an actor to completion (or timeout) and fulfills with its output. | |
* | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param options Options | |
* @returns `Promise` fulfilling with the actor output | |
*/ | |
runUntilDone( | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
): ActorThenable<T, xs.OutputFrom<T>>; | |
/** | |
* Runs an actor (or starts a new one) until it emits or sends one or more | |
* events (in order). | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* Immediately stops the actor thereafter. | |
* | |
* @param events One or more _event names_ (the `type` field) to wait for (in | |
* order) | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param options Options | |
* @returns An {@link ActorThenable} which fulfills with the matching events | |
* (assuming they all occurred in order) | |
*/ | |
runUntilEvent<const EventTypes extends ActorEventTypeTuple<T>>( | |
events: EventTypes, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
): ActorThenable<T, ActorEventTuple<T, EventTypes>>; | |
/** | |
* Runs an actor until the snapshot predicate returns `true`. | |
* | |
* Immediately stops the actor thereafter. | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* @param predicate Snapshot predicate; see {@link xs.waitFor} | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param options Options | |
* @returns {@link ActorThenable} Fulfilling with the snapshot that matches | |
* the predicate | |
*/ | |
runUntilSnapshot( | |
predicate: (snapshot: xs.SnapshotFrom<T>) => boolean, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
): ActorThenable<T, xs.SnapshotFrom<T>>; | |
/** | |
* Starts an actor, applying defaults, and returns the {@link xs.Actor} object. | |
* | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param options Options | |
* @returns The {@link xs.Actor} itself | |
*/ | |
start(input: xs.InputFrom<T> | xs.Actor<T>): xs.Actor<T>; | |
/** | |
* Waits for an actor to be spawned. | |
* | |
* "Actor" here refers to _some other actor_--not the actor provided via | |
* `input` nor created from the input object. | |
* | |
* Does **not** stop the root actor. | |
* | |
* @param actorId A string or RegExp to match against the actor ID | |
* @param input Actor input or an {@link xs.Actor} | |
* @param options Options | |
* @returns The `ActorRef` of the spawned actor | |
*/ | |
waitForActor<SpawnedActor extends xs.AnyActorLogic = xs.AnyActorLogic>( | |
actorId: string | RegExp, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
): ActorThenable<T, xs.ActorRefFrom<SpawnedActor>>; | |
/** | |
* Runs a new or existing actor until the snapshot predicate returns `true`. | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* @param predicate Snapshot predicate; see {@link xs.waitFor} | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param options Options | |
* @returns {@link ActorThenable} Fulfilling with the snapshot that matches | |
* the predicate | |
*/ | |
waitForSnapshot( | |
predicate: (snapshot: xs.SnapshotFrom<T>) => boolean, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
): ActorThenable<T, xs.SnapshotFrom<T>>; | |
} | |
/** | |
* An {@link ActorRunner} which provides additional methods for testing state | |
* machines | |
*/ | |
export interface StateMachineActorRunner<T extends xs.AnyStateMachine> | |
extends ActorRunner<T> { | |
/** | |
* Runs the machine until a transition from the `source` state to the `target` | |
* state occurs. | |
* | |
* Immediately stops the machine thereafter. Returns a combination of a | |
* `Promise` and an {@link xs.Actor} so that events may be sent to the actor. | |
* | |
* @param source Source state ID | |
* @param target Target state ID | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param opts Options | |
* @returns An {@link ActorThenable} that resolves when the specified | |
* transition occurs | |
* @todo Type narrowing for `source` and `target` once xstate supports it | |
*/ | |
runUntilTransition( | |
source: string, | |
target: string, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options?: OptionsWithoutInspect< | |
ActorRunnerOptions | ActorRunnerOptionsWithActor | |
>, | |
): ActorThenable<T>; | |
/** | |
* Runs the machine until a transition from the `source` state to the `target` | |
* state occurs. | |
* | |
* Useful for chaining transitions--but keep in mind that actions are executed | |
* synchronously! | |
* | |
* **Does not stop the machine**. Returns a combination of a `Promise` and an | |
* {@link xs.Actor} so that events may be sent to the actor. | |
* | |
* @param source Source state ID | |
* @param target Target state ID | |
* @param input Machine input | |
* @param opts Options | |
* @returns An {@link ActorThenable} that resolves when the specified | |
* transition occurs | |
* @todo Type narrowing for `source` and `target` once xstate supports it | |
*/ | |
waitForTransition( | |
source: string, | |
target: string, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options?: OptionsWithoutInspect< | |
ActorRunnerOptions | ActorRunnerOptionsWithActor | |
>, | |
): ActorThenable<T>; | |
} | |
/** | |
* An implementation of an {@link ActorRunner} which can help test any actor | |
* logic. | |
*/ | |
export class AnyActorRunner<T extends xs.AnyActorLogic> | |
implements ActorRunner<T> | |
{ | |
/** | |
* Used to generate unique actor IDs when no ID is otherwise provided. | |
* | |
* Incremented when {@link getActorId} cannot otherwise find an ID. | |
* | |
* @internal | |
*/ | |
public static anonymousActorIndex = 0; | |
/** | |
* Default actor logic to use | |
*/ | |
public defaultActorLogic: T; | |
/** | |
* Default actor ID to use when creating an actor. | |
*/ | |
public defaultId?: string; | |
/** | |
* Default inspector to use. | |
*/ | |
public defaultInspector: (evt: xs.InspectionEvent) => void; | |
/** | |
* Default logger | |
*/ | |
public defaultLogger: (...args: any[]) => void; | |
/** | |
* Default timeout for those methods which accept a timeout. | |
*/ | |
public defaultTimeout: number; | |
/** | |
* If `actorLogic` is a state machine, it will use the machine's default ID | |
* (if present) as {@link AnyActorRunner.defaultId} _unless_ | |
* {@link ActorRunnerOptions.id} is provided in `options`. | |
* | |
* @param actorLogic Any actor logic | |
* @param options Options | |
*/ | |
constructor( | |
actorLogic: T, | |
{ | |
id: defaultId, | |
logger: defaultLogger = noop, | |
inspect: defaultInspector = noop, | |
timeout: defaultTimeout = DEFAULT_TIMEOUT, | |
}: ActorRunnerOptions = {}, | |
) { | |
this.defaultActorLogic = actorLogic; | |
this.defaultLogger = defaultLogger; | |
this.defaultInspector = defaultInspector; | |
this.defaultTimeout = defaultTimeout; | |
this.defaultId = defaultId; | |
// State machines _may_ have a default ID | |
if (!this.defaultId && actorLogic instanceof xs.StateMachine) { | |
this.defaultId = actorLogic.id; | |
} | |
} | |
/** | |
* Factory function for creating a {@link AnyActorRunner}. | |
* | |
* @param actorLogic Any actor logic | |
* @param options Options | |
* @returns A new instance of {@link AnyActorRunner} | |
*/ | |
public static create<T extends xs.AnyActorLogic>( | |
actorLogic: T, | |
options?: ActorRunnerOptions, | |
): AnyActorRunner<T> { | |
return new AnyActorRunner(actorLogic, options); | |
} | |
/** | |
* Creates an {@link ActorThenable} from an {@link Actor} and a {@link Promise}. | |
* | |
* @param actor An `Actor` | |
* @param promise Any `Promise` | |
* @returns An `Actor` which is also a thenable | |
* @internal | |
*/ | |
public static createActorThenable<T extends xs.AnyActorLogic, Out = void>( | |
actor: xs.Actor<T>, | |
promise: Promise<Out>, | |
): ActorThenable<T, Out> { | |
// there are myriad ways to do this, and here is one. | |
const pThen = promise.then.bind(promise); | |
const pCatch = promise.catch.bind(promise); | |
const pFinally = promise.finally.bind(promise); | |
return new Proxy(actor, { | |
get: (target, prop, receiver) => { | |
switch (prop) { | |
case 'then': | |
return pThen; | |
case 'catch': | |
return pCatch; | |
case 'finally': | |
return pFinally; | |
default: | |
return Reflect.get(target, prop, receiver); | |
} | |
}, | |
}) as ActorThenable<T, Out>; | |
} | |
/** | |
* Creates a new `Actor` from the actor logic and the provided input. Assigns | |
* default or provided `id`, logger and inspector. | |
* | |
* @param input Input for actor logic | |
* @param options Options for {@link xs.createActor}, mostly | |
* @returns New actor (not started) | |
* @internal | |
*/ | |
public createInstrumentedActor( | |
input: xs.InputFrom<T>, | |
options: ActorRunnerOptions, | |
): xs.Actor<T> { | |
const { | |
logger = this.defaultLogger, | |
inspect = this.defaultInspector, | |
id = this.defaultId, | |
} = options; | |
return xs.createActor(this.defaultActorLogic, { | |
id, | |
input, | |
logger, | |
inspect, | |
}); | |
} | |
/** | |
* Gets an actor ID for a new actor or from an existing actor. | |
* | |
* If one is otherwise unavailable, a unique ID will be generated matching | |
* `^__ActorHelpers-\d+__$`. | |
* | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param id ID, if any | |
* @returns A unique ID | |
* @internal | |
*/ | |
public getActorId<T extends xs.AnyActorLogic>( | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
id = this.defaultId, | |
): string { | |
return input instanceof xs.Actor | |
? input.id | |
: id ?? `__ActorHelpers-${AnyActorRunner.anonymousActorIndex++}__`; | |
} | |
/** | |
* Sets up an existing `Actor` with a logger and inspector. | |
* | |
* @param actor Actor | |
* @param options Options for instrumentation | |
* @returns Instrumented actor | |
* @internal | |
*/ | |
public instrumentActor( | |
actor: xs.Actor<T>, | |
options: ActorRunnerOptionsWithActor, | |
): xs.Actor<T> { | |
const {logger = this.defaultLogger, inspect = this.defaultInspector} = | |
options; | |
if (inspect !== this.defaultInspector) { | |
actor.system.inspect(xs.toObserver(inspect)); | |
} | |
if (logger !== this.defaultLogger) { | |
// @ts-expect-error private | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access | |
actor.logger = actor._actorScope.logger = logger; | |
} | |
return actor; | |
} | |
/** | |
* Runs an actor to completion (or timeout) and fulfills with its output. | |
* | |
* @param input Actor input | |
* @param options Options | |
* @returns `Promise` fulfilling with the actor output | |
*/ | |
public runUntilDone( | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options?: ActorRunnerOptions, | |
): ActorThenable<T, xs.OutputFrom<T>>; | |
/** | |
* Runs an actor to completion (or timeout) and fulfills with its output. | |
* | |
* @param actor Actor | |
* @param options Options | |
* @returns `Promise` fulfilling with the actor output | |
*/ | |
public runUntilDone( | |
actor: xs.Actor<T>, | |
options?: ActorRunnerOptionsWithActor, | |
): ActorThenable<T, xs.OutputFrom<T>>; | |
/** | |
* Runs an actor to completion (or timeout) and fulfills with its output. | |
* | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param options Options | |
* @returns `Promise` fulfilling with the actor output | |
*/ | |
@bind() | |
public runUntilDone( | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options: ActorRunnerOptions | ActorRunnerOptionsWithActor = {}, | |
): ActorThenable<T, xs.OutputFrom<T>> { | |
const {timeout = this.defaultTimeout} = options; | |
const actor = | |
input instanceof xs.Actor | |
? this.instrumentActor(input, options as ActorRunnerOptionsWithActor) | |
: this.createInstrumentedActor(input, options as ActorRunnerOptions); | |
// order is important: create promise, then start. | |
const p = xs.toPromise(actor); | |
actor.start(); | |
const ac = new AbortController(); | |
return AnyActorRunner.createActorThenable( | |
actor, | |
Promise.race([ | |
p.finally(() => { | |
ac.abort(); | |
}), | |
scheduler.wait(timeout, {signal: ac.signal}).then(() => { | |
throw new Error(`Actor did not complete in ${timeout}ms`); | |
}), | |
]), | |
); | |
} | |
/** | |
* Runs an actor until it emits or sends one or more events (in order). | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* Immediately stops the actor thereafter. | |
* | |
* @param events One or more _event names_ (the `type` field) to wait for (in | |
* order) | |
* @param input Actor input | |
* @param options Options | |
* @returns An {@link ActorThenable} which fulfills with the matching events | |
* (assuming they all occurred in order) | |
*/ | |
public runUntilEvent<const EventTypes extends ActorEventTypeTuple<T>>( | |
events: EventTypes, | |
input: xs.InputFrom<T>, | |
options?: OptionsWithoutInspect<ActorRunnerOptions>, | |
): ActorThenable<T, ActorEventTuple<T, EventTypes>>; | |
/** | |
* Runs an actor until it emits or sends one or more events (in order). | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* Immediately stops the actor thereafter. | |
* | |
* @param events One or more _event names_ (the `type` field) to wait for (in | |
* order) | |
* @param actor Actor | |
* @param options Options | |
* @returns An {@link ActorThenable} which fulfills with the matching events | |
* (assuming they all occurred in order) | |
* @todo See if we cannot distinguish between emitted events, sent events, | |
* etc., at runtime. This would prevent the need to blindly subscribe and | |
* use the inspector at the same time. | |
*/ | |
public runUntilEvent<const EventTypes extends ActorEventTypeTuple<T>>( | |
events: EventTypes, | |
actor: xs.Actor<T>, | |
options?: OptionsWithoutInspect<ActorRunnerOptionsWithActor>, | |
): ActorThenable<T, ActorEventTuple<T, EventTypes>>; | |
/** | |
* Runs an actor (or starts a new one) until it emits or sends one or more | |
* events (in order). | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* Immediately stops the actor thereafter. | |
* | |
* @param events One or more _event names_ (the `type` field) to wait for (in | |
* order) | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param options Options | |
* @returns An {@link ActorThenable} which fulfills with the matching events | |
* (assuming they all occurred in order) | |
*/ | |
@bind() | |
public runUntilEvent<const EventTypes extends ActorEventTypeTuple<T>>( | |
events: EventTypes, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options: OptionsWithoutInspect< | |
ActorRunnerOptions | ActorRunnerOptionsWithActor | |
> = {}, | |
): ActorThenable<T, ActorEventTuple<T, EventTypes>> { | |
const expectedEventQueue = [...events]; | |
if (!expectedEventQueue.length) { | |
throw new TypeError('Expected one or more event types'); | |
} | |
const id = this.getActorId( | |
input, | |
(options as OptionsWithoutInspect<ActorRunnerOptions>).id, | |
); | |
// inspector fields events sent to another actor | |
const runUntilEventInspector = (evt: xs.InspectionEvent) => { | |
const type = expectedEventQueue[0]; | |
if (evt.type === '@xstate.event' && type === evt.event.type) { | |
if (evt.sourceRef?.id === id) { | |
emitted.push(evt.event as EventFromEventType<T, typeof type>); | |
expectedEventQueue.shift(); | |
if (!expectedEventQueue.length) { | |
evt.sourceRef.stop(); | |
} | |
subscription?.unsubscribe(); | |
} | |
} | |
}; | |
const actor = | |
input instanceof xs.Actor | |
? this.instrumentActor(input, { | |
...options, | |
inspect: runUntilEventInspector, | |
} as ActorRunnerOptionsWithActor) | |
: this.createInstrumentedActor(input, { | |
...options, | |
inspect: runUntilEventInspector, | |
} as ActorRunnerOptions); | |
const emitted: ActorEventTuple<T, EventTypes> = [] as any; | |
const {timeout = this.defaultTimeout} = options; | |
let subscription: xs.Subscription | undefined; | |
// subscription fields emitted events | |
const subscribe = (type: EventTypes[number]) => { | |
subscription = actor.on(type, (evt) => { | |
subscription?.unsubscribe(); | |
emitted.push(evt.event as EventFromEventType<T, typeof type>); | |
expectedEventQueue.shift(); | |
if (!expectedEventQueue.length) { | |
actor.stop(); | |
} else { | |
subscription = subscribe(expectedEventQueue[0]); | |
} | |
}); | |
return subscription; | |
}; | |
subscription = subscribe(expectedEventQueue[0]); | |
const p = xs.toPromise(actor); | |
actor.start(); | |
const ac = new AbortController(); | |
return AnyActorRunner.createActorThenable( | |
actor, | |
Promise.race([ | |
p.finally(() => { | |
ac.abort(); | |
}), | |
scheduler.wait(timeout, {signal: ac.signal}).then(() => { | |
const event = expectedEventQueue[0]; | |
if (event) { | |
throw new Error(`Event not sent in ${timeout} ms: ${event}`); | |
} | |
throw new Error( | |
`All events sent in order, but actor timed out in ${timeout}ms`, | |
); | |
}), | |
]) | |
.then(() => { | |
if (expectedEventQueue.length) { | |
throw new Error( | |
`Event(s) not sent nor emitted: ${expectedEventQueue.join(', ')}`, | |
); | |
} | |
return emitted; | |
}) | |
.finally(() => { | |
subscription?.unsubscribe(); | |
actor.stop(); | |
}), | |
); | |
} | |
/** | |
* Runs a actor until the snapshot predicate returns `true`. | |
* | |
* Immediately stops the actor thereafter. | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* @param predicate Snapshot predicate; see {@link xs.waitFor} | |
* @param input Actor input | |
* @param options Options | |
* @returns {@link ActorThenable} Fulfilling with the snapshot that matches | |
* the predicate | |
*/ | |
public runUntilSnapshot( | |
predicate: (snapshot: xs.SnapshotFrom<T>) => boolean, | |
input: xs.InputFrom<T>, | |
options?: ActorRunnerOptions, | |
): ActorThenable<T, xs.SnapshotFrom<T>>; | |
/** | |
* Runs a actor until the snapshot predicate returns `true`. | |
* | |
* Immediately stops the actor thereafter. | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* @param predicate Snapshot predicate; see {@link xs.waitFor} | |
* @param input Actor | |
* @param options Options | |
* @returns {@link ActorThenable} Fulfilling with the snapshot that matches | |
* the predicate | |
*/ | |
public runUntilSnapshot( | |
predicate: (snapshot: xs.SnapshotFrom<T>) => boolean, | |
actor: xs.Actor<T>, | |
options?: ActorRunnerOptionsWithActor, | |
): ActorThenable<T, xs.SnapshotFrom<T>>; | |
/** | |
* Runs an actor until the snapshot predicate returns `true`. | |
* | |
* Immediately stops the actor thereafter. | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* @param predicate Snapshot predicate; see {@link xs.waitFor} | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param options Options | |
* @returns {@link ActorThenable} Fulfilling with the snapshot that matches | |
* the predicate | |
*/ | |
@bind() | |
public runUntilSnapshot( | |
predicate: (snapshot: xs.SnapshotFrom<T>) => boolean, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options: ActorRunnerOptions | ActorRunnerOptionsWithActor = {}, | |
): ActorThenable<T, xs.SnapshotFrom<T>> { | |
const {timeout = this.defaultTimeout} = options; | |
const actor = | |
input instanceof xs.Actor | |
? this.instrumentActor(input, options as ActorRunnerOptionsWithActor) | |
: this.createInstrumentedActor(input, options as ActorRunnerOptions); | |
actor.start(); | |
return AnyActorRunner.createActorThenable( | |
actor, | |
xs | |
.waitFor(actor, predicate, {timeout}) | |
.catch((err) => { | |
if (err instanceof Error) { | |
if (err.message.startsWith('Timeout of')) { | |
throw new Error( | |
`Snapshot did not match predicate in ${timeout}ms`, | |
); | |
} else if ( | |
err.message.startsWith( | |
'Actor terminated without satisfying predicate', | |
) | |
) { | |
throw new Error(`Actor stopped without satisfying predicate`); | |
} | |
} | |
throw err; | |
}) | |
.finally(() => { | |
actor.stop(); | |
}), | |
); | |
} | |
/** | |
* Starts the actor and returns the {@link xs.Actor} object. | |
* | |
* @param input Actor input | |
* @param options Options | |
* @returns The {@link xs.Actor} itself | |
*/ | |
public start( | |
input: xs.InputFrom<T>, | |
options?: Omit<ActorRunnerOptions, 'timeout'>, | |
): xs.Actor<T>; | |
public start( | |
actor: xs.Actor<T>, | |
options?: Omit<ActorRunnerOptionsWithActor, 'timeout'>, | |
): xs.Actor<T>; | |
@bind() | |
public start( | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options: Omit< | |
ActorRunnerOptions | ActorRunnerOptionsWithActor, | |
'timeout' | |
> = {}, | |
): xs.Actor<T> { | |
const actor = | |
input instanceof xs.Actor | |
? this.instrumentActor(input, options as ActorRunnerOptionsWithActor) | |
: this.createInstrumentedActor(input, options as ActorRunnerOptions); | |
return actor.start(); | |
} | |
/** | |
* A function that waits for an actor to be spawned. | |
* | |
* Does **not** stop the root actor. | |
* | |
* @param actorId A string or RegExp to match against the actor ID | |
* @param input Actor input or an {@link xs.Actor} | |
* @param options Options | |
* @returns The `ActorRef` of the spawned actor | |
*/ | |
@bind() | |
public waitForActor<SpawnedActor extends xs.AnyActorLogic = xs.AnyActorLogic>( | |
actorId: string | RegExp, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options: ActorRunnerOptions | ActorRunnerOptionsWithActor = {}, | |
): ActorThenable<T, xs.ActorRefFrom<SpawnedActor>> { | |
const predicate = | |
typeof actorId === 'string' | |
? (id: string) => id === actorId | |
: (id: string) => actorId.test(id); | |
const {timeout = this.defaultTimeout} = options; | |
const actor = | |
input instanceof xs.Actor | |
? this.instrumentActor(input, options as ActorRunnerOptionsWithActor) | |
: this.createInstrumentedActor(input, options as ActorRunnerOptions); | |
actor.start(); | |
const ac = new AbortController(); | |
return Object.assign( | |
Promise.race([ | |
new Promise<xs.ActorRefFrom<SpawnedActor>>((resolve) => { | |
actor.system.inspect( | |
xs.toObserver((evt) => { | |
if (evt.type === '@xstate.actor' && predicate(evt.actorRef.id)) { | |
resolve(evt.actorRef as xs.ActorRefFrom<SpawnedActor>); | |
} | |
}), | |
); | |
}).finally(() => { | |
ac.abort(); | |
}), | |
scheduler.wait(timeout, {signal: ac.signal}).then(() => { | |
throw new Error( | |
`Failed to detect an spawned actor matching ${actorId} in ${timeout}ms`, | |
); | |
}), | |
]), | |
actor, | |
); | |
} | |
/** | |
* Runs a actor until the snapshot predicate returns `true`. | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* @param predicate Snapshot predicate; see {@link xs.waitFor} | |
* @param input Actor input | |
* @param options Options | |
* @returns {@link ActorThenable} Fulfilling with the snapshot that matches | |
* the predicate | |
*/ | |
public waitForSnapshot( | |
predicate: (snapshot: xs.SnapshotFrom<T>) => boolean, | |
input: xs.InputFrom<T>, | |
options?: ActorRunnerOptions, | |
): ActorThenable<T, xs.SnapshotFrom<T>>; | |
/** | |
* Runs a actor until the snapshot predicate returns `true`. | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* @param predicate Snapshot predicate; see {@link xs.waitFor} | |
* @param input Actor | |
* @param options Options | |
* @returns {@link ActorThenable} Fulfilling with the snapshot that matches | |
* the predicate | |
*/ | |
public waitForSnapshot( | |
predicate: (snapshot: xs.SnapshotFrom<T>) => boolean, | |
actor: xs.Actor<T>, | |
options?: ActorRunnerOptionsWithActor, | |
): ActorThenable<T, xs.SnapshotFrom<T>>; | |
/** | |
* Runs a new or existing actor until the snapshot predicate returns `true`. | |
* | |
* Returns a combination of a `Promise` and an {@link xs.Actor} so that events | |
* may be sent to the actor. | |
* | |
* @param predicate Snapshot predicate; see {@link xs.waitFor} | |
* @param input Input for {@link defaultActorLogic} or an existing {@link Actor} | |
* @param options Options | |
* @returns {@link ActorThenable} Fulfilling with the snapshot that matches | |
* the predicate | |
*/ | |
@bind() | |
public waitForSnapshot( | |
predicate: (snapshot: xs.SnapshotFrom<T>) => boolean, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options: ActorRunnerOptions | ActorRunnerOptionsWithActor = {}, | |
): ActorThenable<T, xs.SnapshotFrom<T>> { | |
const {timeout = this.defaultTimeout} = options; | |
const actor = | |
input instanceof xs.Actor | |
? this.instrumentActor(input, options as ActorRunnerOptionsWithActor) | |
: this.createInstrumentedActor(input, options as ActorRunnerOptions); | |
actor.start(); | |
return AnyActorRunner.createActorThenable( | |
actor, | |
xs.waitFor(actor, predicate, {timeout}).catch((err) => { | |
if (err instanceof Error) { | |
if (err.message.startsWith('Timeout of')) { | |
throw new Error(`Snapshot did not match predicate in ${timeout}ms`); | |
} else if ( | |
err.message.startsWith( | |
'Actor terminated without satisfying predicate', | |
) | |
) { | |
throw new Error(`Actor stopped before satisfying predicate`); | |
} | |
} | |
throw err; | |
}), | |
); | |
} | |
} | |
/** | |
* Helpers for testing state machine behavior | |
* | |
* @remarks | |
* Just a wrapper around {@link AnyActorRunner} | |
* @template T `StateMachine` actor logic | |
*/ | |
export class StateMachineRunner<T extends xs.AnyStateMachine> | |
implements StateMachineActorRunner<T> | |
{ | |
constructor(public readonly runner: AnyActorRunner<T>) {} | |
/** | |
* {@inheritDoc AnyActorRunner.defaultActorLogic} | |
*/ | |
public get defaultActorLogic() { | |
return this.runner.defaultActorLogic; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.defaultId} | |
*/ | |
public get defaultId() { | |
return this.runner.defaultId; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.defaultInspector} | |
*/ | |
public get defaultInspector() { | |
return this.runner.defaultInspector; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.defaultLogger} | |
*/ | |
public get defaultLogger() { | |
return this.runner.defaultLogger; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.defaultTimeout} | |
*/ | |
public get defaultTimeout() { | |
return this.runner.defaultTimeout; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.runUntilDone} | |
*/ | |
public get runUntilDone() { | |
return this.runner.runUntilDone; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.runUntilEvent} | |
*/ | |
public get runUntilEvent() { | |
return this.runner.runUntilEvent; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.runUntilSnapshot} | |
*/ | |
public get runUntilSnapshot() { | |
return this.runner.runUntilSnapshot; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.start} | |
*/ | |
public get start() { | |
return this.runner.start; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.waitForActor} | |
*/ | |
public get waitForActor() { | |
return this.runner.waitForActor; | |
} | |
/** | |
* {@inheritDoc AnyActorRunner.waitForSnapshot} | |
*/ | |
public get waitForSnapshot() { | |
return this.runner.waitForSnapshot; | |
} | |
/** | |
* Runs the machine until a transition from the `source` state to the `target` | |
* state occurs. | |
* | |
* Immediately stops the machine thereafter. Returns a combination of a | |
* `Promise` and an {@link xs.Actor} so that events may be sent to the actor. | |
* | |
* @param source Source state ID | |
* @param target Target state ID | |
* @param input Machine input | |
* @param opts Options | |
* @returns An {@link ActorThenable} that resolves when the specified | |
* transition occurs | |
* @todo Type narrowing for `source` and `target` once xstate supports it | |
* | |
* @todo Attempt to reuse {@link waitUntilTransition} | |
*/ | |
@bind() | |
public runUntilTransition( | |
source: string, | |
target: string, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options: OptionsWithoutInspect< | |
ActorRunnerOptions | ActorRunnerOptionsWithActor | |
> = {}, | |
): ActorThenable<T> { | |
const {timeout = this.defaultTimeout} = options; | |
let sawTransition = false; | |
/** | |
* We need the actor ID here so we can match against it in the inspector. | |
* However, we don't necessarily have an actor yet, and the inspector may | |
* fire _before_ we do (think: initial transition). But we also don't want | |
* to miss anything, so we need to generate an ID unless one is otherwise | |
* provided. | |
*/ | |
const id = this.runner.getActorId( | |
input, | |
(options as OptionsWithoutInspect<ActorRunnerOptions>).id, | |
); | |
const transitionInspector = (evt: xs.InspectionEvent) => { | |
if (evt.type === '@xstate.microstep') { | |
if (evt.actorRef.id === id) { | |
if ( | |
evt._transitions.some( | |
(tDef) => | |
tDef.source.id === source && | |
tDef.target?.some((t) => t.id === target), | |
) | |
) { | |
sawTransition = true; | |
} | |
} | |
} | |
}; | |
const actor = | |
input instanceof xs.Actor | |
? this.runner.instrumentActor(input, { | |
...options, | |
inspect: transitionInspector, | |
} as ActorRunnerOptionsWithActor) | |
: this.runner.createInstrumentedActor(input, { | |
...options, | |
id, | |
inspect: transitionInspector, | |
} as ActorRunnerOptions); | |
// @ts-expect-error internal | |
const {idMap} = this.runner.defaultActorLogic; | |
if (!idMap.has(source)) { | |
throw new Error(`Unknown state ID (source): ${source}`); | |
} | |
if (!idMap.has(target)) { | |
throw new Error(`Unknown state ID (target): ${target}`); | |
} | |
const p = xs.toPromise(actor); | |
const ac = new AbortController(); | |
actor.start(); | |
return AnyActorRunner.createActorThenable( | |
actor, | |
Promise.race([ | |
p.then(noop, noop).finally(() => { | |
ac.abort(); | |
}), | |
scheduler.wait(timeout, {signal: ac.signal}).then(() => { | |
throw new Error( | |
`Failed to detect a transition from ${source} to ${target} in ${timeout}ms`, | |
); | |
}), | |
]) | |
.then(() => { | |
if (!sawTransition) { | |
throw new Error( | |
`Transition from ${source} to ${target} not detected`, | |
); | |
} | |
}) | |
.finally(() => { | |
actor.stop(); | |
}), | |
); | |
} | |
/** | |
* Runs the machine until a transition from the `source` state to the `target` | |
* state occurs. | |
* | |
* Useful for chaining transitions--but keep in mind that actions are executed | |
* synchronously! | |
* | |
* **Does not stop the machine**. Returns a combination of a `Promise` and an | |
* {@link xs.Actor} so that events may be sent to the actor. | |
* | |
* @param source Source state ID | |
* @param target Target state ID | |
* @param input Machine input | |
* @param opts Options | |
* @returns An {@link ActorThenable} that resolves when the specified | |
* transition occurs | |
* @todo Type narrowing for `source` and `target` once xstate supports it | |
*/ | |
@bind() | |
public waitForTransition( | |
source: string, | |
target: string, | |
input: xs.InputFrom<T> | xs.Actor<T>, | |
options: OptionsWithoutInspect< | |
ActorRunnerOptions | ActorRunnerOptionsWithActor | |
> = {}, | |
): ActorThenable<T> { | |
const {timeout = this.defaultTimeout} = options; | |
let sawTransition = false; | |
/** | |
* We need the actor ID here so we can match against it in the inspector. | |
* However, we don't necessarily have an actor yet, and the inspector may | |
* fire _before_ we do (think: initial transition). But we also don't want | |
* to miss anything, so we need to generate an ID unless one is otherwise | |
* provided. | |
*/ | |
const id = this.runner.getActorId( | |
input, | |
(options as OptionsWithoutInspect<ActorRunnerOptions>).id, | |
); | |
const transitionInspector = (evt: xs.InspectionEvent) => { | |
if (evt.type === '@xstate.microstep') { | |
if (evt.actorRef.id === id) { | |
if ( | |
evt._transitions.some( | |
(tDef) => | |
tDef.source.id === source && | |
tDef.target?.some((t) => t.id === target), | |
) | |
) { | |
sawTransition = true; | |
evt.actorRef.stop(); | |
} | |
} | |
} | |
}; | |
const actor = | |
input instanceof xs.Actor | |
? this.runner.instrumentActor(input, { | |
...options, | |
inspect: transitionInspector, | |
} as ActorRunnerOptionsWithActor) | |
: this.runner.createInstrumentedActor(input, { | |
...options, | |
id, | |
inspect: transitionInspector, | |
} as ActorRunnerOptions); | |
// @ts-expect-error internal | |
const {idMap} = this.runner.defaultActorLogic; | |
if (!idMap.has(source)) { | |
throw new Error(`Unknown state ID (source): ${source}`); | |
} | |
if (!idMap.has(target)) { | |
throw new Error(`Unknown state ID (target): ${target}`); | |
} | |
const p = xs.toPromise(actor); | |
const ac = new AbortController(); | |
actor.start(); | |
return AnyActorRunner.createActorThenable( | |
actor, | |
Promise.race([ | |
p.then(noop, noop).finally(() => { | |
ac.abort(); | |
}), | |
scheduler.wait(timeout, {signal: ac.signal}).then(() => { | |
throw new Error( | |
`Failed to detect a transition from ${source} to ${target} in ${timeout}ms`, | |
); | |
}), | |
]).then(() => { | |
if (!sawTransition) { | |
throw new Error( | |
`Transition from ${source} to ${target} not detected`, | |
); | |
} | |
}), | |
); | |
} | |
} | |
/** | |
* Decorator to bind a class method to a context (defaulting to `this`) | |
* | |
* @param ctx Alternate context, if needed | |
*/ | |
export function bind< | |
TThis extends object, | |
TArgs extends any[] = unknown[], | |
TReturn = unknown, | |
TContext extends object = TThis, | |
>(ctx?: TContext) { | |
return function ( | |
target: (this: TThis, ...args: TArgs) => TReturn, | |
context: ClassMethodDecoratorContext< | |
TThis, | |
(this: TThis, ...args: TArgs) => TReturn | |
>, | |
) { | |
context.addInitializer(function (this: TThis) { | |
const func = context.access.get(this); | |
// @ts-expect-error FIXME | |
this[context.name] = func.bind(ctx ?? this); | |
}); | |
}; | |
} | |
export function createActorRunner<T extends xs.AnyStateMachine>( | |
stateMachine: T, | |
options?: ActorRunnerOptions, | |
): StateMachineRunner<T>; | |
export function createActorRunner<T extends xs.AnyActorLogic>( | |
actorLogic: T, | |
options?: ActorRunnerOptions, | |
): AnyActorRunner<T>; | |
export function createActorRunner< | |
T extends xs.AnyActorLogic | xs.AnyStateMachine, | |
>(actorLogic: T, options?: ActorRunnerOptions) { | |
if (isStateMachine(actorLogic)) { | |
const runner = AnyActorRunner.create(actorLogic, options); | |
return new StateMachineRunner(runner); | |
} | |
return AnyActorRunner.create(actorLogic, options); | |
} | |
/** | |
* Type guard to determine if some actor logic is a state machine | |
* | |
* @param actorLogic Any actor logic | |
* @returns `true` if `actorLogic` is a state machine | |
*/ | |
export function isStateMachine<T extends xs.AnyActorLogic>( | |
actorLogic: T, | |
): actorLogic is T & xs.AnyStateMachine { | |
return actorLogic instanceof xs.StateMachine; | |
} | |
/** | |
* That's a no-op, folks | |
*/ | |
const noop = () => {}; | |
/** | |
* Default timeout (in ms) for any of the "run until" methods in | |
* {@link AnyActorRunner} | |
* | |
* This must be set to a lower value than the default timeout for the test | |
* runner. | |
*/ | |
const DEFAULT_TIMEOUT = 1000; |