Skip to content

Instantly share code, notes, and snippets.

@boneskull
Last active August 28, 2024 21:25
Show Gist options
  • Save boneskull/598ebeba54571edd33b215ab287ac5aa to your computer and use it in GitHub Desktop.
Save boneskull/598ebeba54571edd33b215ab287ac5aa to your computer and use it in GitHub Desktop.
Test helpers for xstate & Node.js

This code contains some helper functions for testing xstate v5+ Actor and Machine behavior using Promises and "traditional" assertions (vs state-machine-based testing).

Maybe I'll put some examples here.


Update Aug 28 2024

Instead of this, see xstate-audition which I built upon this idea (with less OOP).

/**
* 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;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment