Skip to main content
Module

x/xstate/test/actor.test.ts

State machines and statecharts for the modern web.
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254
import { Machine, spawn, interpret, ActorRef, ActorRefFrom, Behavior, createMachine, EventObject} from '../src';import { assign, send, sendParent, raise, doneInvoke, sendUpdate, respond, forwardTo, error} from '../src/actions';import { interval, EMPTY } from 'rxjs';import { map } from 'rxjs/operators';import { fromPromise } from '../src/behaviors';
describe('spawning machines', () => { const todoMachine = Machine({ id: 'todo', initial: 'incomplete', states: { incomplete: { on: { SET_COMPLETE: 'complete' } }, complete: { onEntry: sendParent({ type: 'TODO_COMPLETED' }) } } });
const context = { todoRefs: {} as Record<string, ActorRef<any>> };
type TodoEvent = | { type: 'ADD'; id: number; } | { type: 'SET_COMPLETE'; id: number; } | { type: 'TODO_COMPLETED'; };
const todosMachine = Machine<any, TodoEvent>({ id: 'todos', context: context, initial: 'active', states: { active: { on: { TODO_COMPLETED: 'success' } }, success: { type: 'final' } }, on: { ADD: { actions: assign({ todoRefs: (ctx, e) => ({ ...ctx.todoRefs, [e.id]: spawn(todoMachine) }) }) }, SET_COMPLETE: { actions: send('SET_COMPLETE', { to: (ctx, e: Extract<TodoEvent, { type: 'SET_COMPLETE' }>) => { return ctx.todoRefs[e.id]; } }) } } });
// Adaptation: https://github.com/p-org/P/wiki/PingPong-program type PingPongEvent = | { type: 'PING' } | { type: 'PONG' } | { type: 'SUCCESS' };
const serverMachine = Machine<any, PingPongEvent>({ id: 'server', initial: 'waitPing', states: { waitPing: { on: { PING: 'sendPong' } }, sendPong: { entry: [sendParent('PONG'), raise('SUCCESS')], on: { SUCCESS: 'waitPing' } } } });
interface ClientContext { server?: ActorRef<PingPongEvent>; }
const clientMachine = Machine<ClientContext, PingPongEvent>({ id: 'client', initial: 'init', context: { server: undefined }, states: { init: { entry: [ assign({ server: () => spawn(serverMachine) }), raise('SUCCESS') ], on: { SUCCESS: 'sendPing' } }, sendPing: { entry: [send('PING', { to: (ctx) => ctx.server! }), raise('SUCCESS')], on: { SUCCESS: 'waitPong' } }, waitPong: { on: { PONG: 'complete' } }, complete: { type: 'final' } } });
it('should invoke actors', (done) => { const service = interpret(todosMachine) .onDone(() => { done(); }) .start();
service.send('ADD', { id: 42 }); service.send('SET_COMPLETE', { id: 42 }); });
it('should invoke actors (when sending batch)', (done) => { const service = interpret(todosMachine) .onDone(() => { done(); }) .start();
service.send([{ type: 'ADD', id: 42 }]); service.send('SET_COMPLETE', { id: 42 }); });
it('should invoke a null actor if spawned outside of a service', () => { expect(spawn(todoMachine)).toBeTruthy(); });
it('should allow bidirectional communication between parent/child actors', (done) => { interpret(clientMachine) .onDone(() => { done(); }) .start(); });});
describe('spawning promises', () => { const promiseMachine = Machine<any>({ id: 'promise', initial: 'idle', context: { promiseRef: undefined }, states: { idle: { entry: assign({ promiseRef: () => { const ref = spawn( new Promise((res) => { res('response'); }), 'my-promise' );
return ref; } }), on: { [doneInvoke('my-promise')]: { target: 'success', cond: (_, e) => e.data === 'response' } } }, success: { type: 'final' } } });
it('should be able to spawn a promise', (done) => { const promiseService = interpret(promiseMachine).onDone(() => { done(); });
promiseService.start(); });});
describe('spawning callbacks', () => { const callbackMachine = Machine<any>({ id: 'callback', initial: 'idle', context: { callbackRef: undefined }, states: { idle: { entry: assign({ callbackRef: () => spawn((cb, receive) => { receive((event) => { if (event.type === 'START') { setTimeout(() => { cb('SEND_BACK'); }, 10); } }); }) }), on: { START_CB: { actions: send('START', { to: (ctx) => ctx.callbackRef }) }, SEND_BACK: 'success' } }, success: { type: 'final' } } });
it('should be able to spawn an actor from a callback', (done) => { const callbackService = interpret(callbackMachine).onDone(() => { done(); });
callbackService.start(); callbackService.send('START_CB'); });});
describe('spawning observables', () => { interface Events { type: 'INT'; value: number; }
const observableMachine = Machine<any, Events>({ id: 'observable', initial: 'idle', context: { observableRef: undefined }, states: { idle: { entry: assign({ observableRef: () => { const ref = spawn( interval(10).pipe( map((n) => ({ type: 'INT', value: n })) ) );
return ref; } }), on: { INT: { target: 'success', cond: (_, e) => e.value === 5 } } }, success: { type: 'final' } } });
it('should be able to spawn an observable', (done) => { const observableService = interpret(observableMachine).onDone(() => { done(); });
observableService.start(); });});
describe('communicating with spawned actors', () => { it('should treat an interpreter as an actor', (done) => { const existingMachine = Machine({ initial: 'inactive', states: { inactive: { on: { ACTIVATE: 'active' } }, active: { entry: respond('EXISTING.DONE') } } });
const existingService = interpret(existingMachine).start();
const parentMachine = Machine<any>({ initial: 'pending', context: { existingRef: undefined as any }, states: { pending: { entry: assign({ // No need to spawn an existing service: // existingRef: () => spawn(existingService) existingRef: existingService }), on: { 'EXISTING.DONE': 'success' }, after: { 100: { actions: send('ACTIVATE', { to: (ctx) => ctx.existingRef }) } } }, success: { type: 'final' } } });
const parentService = interpret(parentMachine).onDone(() => { done(); });
parentService.start(); });
it('should be able to name existing actors', (done) => { const existingMachine = Machine({ initial: 'inactive', states: { inactive: { on: { ACTIVATE: 'active' } }, active: { entry: respond('EXISTING.DONE') } } });
const existingService = interpret(existingMachine).start();
const parentMachine = createMachine<{ existingRef: ActorRef<any> | undefined; }>({ initial: 'pending', context: { existingRef: undefined }, states: { pending: { entry: assign({ existingRef: () => spawn(existingService, 'existing') }), on: { 'EXISTING.DONE': 'success' }, after: { 100: { actions: send('ACTIVATE', { to: 'existing' }) } } }, success: { type: 'final' } } });
const parentService = interpret(parentMachine).onDone(() => { done(); });
parentService.start(); });
it('should be able to communicate with arbitrary actors if sessionId is known', (done) => { const existingMachine = Machine({ initial: 'inactive', states: { inactive: { on: { ACTIVATE: 'active' } }, active: { entry: respond('EXISTING.DONE') } } });
const existingService = interpret(existingMachine).start();
const parentMachine = Machine<any>({ initial: 'pending', states: { pending: { entry: send('ACTIVATE', { to: existingService.sessionId }), on: { 'EXISTING.DONE': 'success' }, after: { 100: { actions: send('ACTIVATE', { to: (ctx) => ctx.existingRef }) } } }, success: { type: 'final' } } });
const parentService = interpret(parentMachine).onDone(() => { done(); });
parentService.start(); });});
describe('actors', () => { it('should only spawn actors defined on initial state once', () => { let count = 0;
const startMachine = Machine<any>({ id: 'start', initial: 'start', context: { items: [0, 1, 2, 3], refs: [] }, states: { start: { entry: assign({ refs: (ctx) => { count++; const c = ctx.items.map((item: any) => spawn(new Promise((res) => res(item))) );
return c; } }) } } });
interpret(startMachine) .onTransition(() => { expect(count).toEqual(1); }) .start(); });
it('should only spawn an actor in an initial state of a child that gets invoked in the initial state of a parent when the parent gets started', () => { let spawnCounter = 0;
interface TestContext { promise?: ActorRefFrom<Promise<string>>; }
const child = Machine<TestContext>({ initial: 'bar', context: {}, states: { bar: { entry: assign<TestContext>({ promise: () => { return spawn(() => { spawnCounter++; return Promise.resolve('answer'); }); } }) } } });
const parent = Machine({ initial: 'foo', states: { foo: { invoke: { src: child, onDone: 'end' } }, end: { type: 'final' } } }); interpret(parent).start(); expect(spawnCounter).toBe(1); });
// https://github.com/statelyai/xstate/issues/2565 it('should only spawn an initial actor once when it synchronously responds with an event', () => { let spawnCalled = 0; const anotherMachine = createMachine({ initial: 'hello', states: { hello: { entry: sendParent('ping') } } });
const testMachine = createMachine<{ ref: ActorRef<any> }>({ initial: 'testing', context: () => { spawnCalled++; // throw in case of an infinite loop expect(spawnCalled).toBe(1); return { ref: spawn(anotherMachine) }; }, states: { testing: { on: { ping: { target: 'done' } } }, done: {} } });
const service = interpret(testMachine).start(); expect(service.state.value).toEqual('done'); });
it('should spawn null actors if not used within a service', () => { interface TestContext { ref?: ActorRef<any>; }
const nullActorMachine = Machine<TestContext>({ initial: 'foo', context: { ref: undefined }, states: { foo: { entry: assign<TestContext>({ ref: () => spawn(Promise.resolve(42)) }) } } });
const { initialState } = nullActorMachine;
// expect(initialState.context.ref!.id).toBe('null'); // TODO: identify null actors expect(initialState.context.ref!.send).toBeDefined(); });
describe('autoForward option', () => { const pongActorMachine = Machine({ id: 'server', initial: 'waitPing', states: { waitPing: { on: { PING: 'sendPong' } }, sendPong: { entry: [sendParent('PONG'), raise('SUCCESS')], on: { SUCCESS: 'waitPing' } } } });
it('should not forward events to a spawned actor by default', () => { let pongCounter = 0;
const machine = Machine<any>({ id: 'client', context: { counter: 0, serverRef: undefined }, initial: 'initial', states: { initial: { entry: assign(() => ({ serverRef: spawn(pongActorMachine) })), on: { PONG: { actions: () => ++pongCounter } } } } }); const service = interpret(machine); service.start(); service.send('PING'); service.send('PING'); expect(pongCounter).toEqual(0); });
it('should not forward events to a spawned actor when { autoForward: false }', () => { let pongCounter = 0;
const machine = Machine<{ counter: number; serverRef?: ActorRef<any> }>({ id: 'client', context: { counter: 0, serverRef: undefined }, initial: 'initial', states: { initial: { entry: assign((ctx) => ({ ...ctx, serverRef: spawn(pongActorMachine, { autoForward: false }) })), on: { PONG: { actions: () => ++pongCounter } } } } }); const service = interpret(machine); service.start(); service.send('PING'); service.send('PING'); expect(pongCounter).toEqual(0); });
it('should forward events to a spawned actor when { autoForward: true }', () => { let pongCounter = 0;
const machine = Machine<any>({ id: 'client', context: { counter: 0, serverRef: undefined }, initial: 'initial', states: { initial: { entry: assign(() => ({ serverRef: spawn(pongActorMachine, { autoForward: true }) })), on: { PONG: { actions: () => ++pongCounter } } } } }); const service = interpret(machine); service.start(); service.send('PING'); service.send('PING'); expect(pongCounter).toEqual(2); }); });
describe('sync option', () => { const childMachine = Machine({ id: 'child', context: { value: 0 }, initial: 'active', states: { active: { after: { 10: { actions: assign({ value: 42 }), internal: true } } } } });
interface TestContext { ref?: ActorRefFrom<typeof childMachine>; refNoSync?: ActorRefFrom<typeof childMachine>; refNoSyncDefault?: ActorRefFrom<typeof childMachine>; }
const parentMachine = Machine<TestContext>({ id: 'parent', context: { ref: undefined, refNoSync: undefined, refNoSyncDefault: undefined }, initial: 'foo', states: { foo: { entry: assign<TestContext>({ ref: () => spawn(childMachine, { sync: true }), refNoSync: () => spawn(childMachine, { sync: false }), refNoSyncDefault: () => spawn(childMachine) }) }, success: { type: 'final' } } });
it('should sync spawned actor state when { sync: true }', () => { return new Promise<void>((res) => { const service = interpret(parentMachine, { id: 'a-service' }).onTransition((s) => { if (s.context.ref?.getSnapshot()?.context.value === 42) { res(); } }); service.start(); }); });
it('should not sync spawned actor state when { sync: false }', () => { return new Promise<void>((res, rej) => { const service = interpret(parentMachine, { id: 'b-service' }).onTransition((s) => { if (s.context.refNoSync?.getSnapshot()?.context.value === 42) { rej(new Error('value change caused transition')); } }); service.start();
setTimeout(() => { expect( service.state.context.refNoSync?.getSnapshot()?.context.value ).toBe(42); res(); }, 30); }); });
it('should not sync spawned actor state (default)', () => { return new Promise<void>((res, rej) => { const service = interpret(parentMachine, { id: 'c-service' }).onTransition((s) => { if (s.context.refNoSyncDefault?.getSnapshot()?.context.value === 42) { rej(new Error('value change caused transition')); } }); service.start();
setTimeout(() => { expect( service.state.context.refNoSyncDefault?.getSnapshot()?.context.value ).toBe(42); res(); }, 30); }); });
it('parent state should be changed if synced child actor update occurs', (done) => { const syncChildMachine = Machine({ initial: 'active', states: { active: { after: { 500: 'inactive' } }, inactive: {} } });
interface SyncMachineContext { ref?: ActorRefFrom<typeof syncChildMachine>; }
const syncMachine = Machine<SyncMachineContext>({ initial: 'same', context: {}, states: { same: { entry: assign<SyncMachineContext>({ ref: () => spawn(syncChildMachine, { sync: true }) }) } } });
interpret(syncMachine) .onTransition((state) => { if (state.context.ref?.getSnapshot()?.matches('inactive')) { expect(state.changed).toBe(true); done(); } }) .start(); });
const falseSyncOptions = [{}, { sync: false }];
falseSyncOptions.forEach((falseSyncOption) => { it(`parent state should NOT be changed regardless of unsynced child actor update (options: ${JSON.stringify( falseSyncOption )})`, (done) => { const syncChildMachine = Machine({ initial: 'active', states: { active: { after: { 10: 'inactive' } }, inactive: {} } });
interface SyncMachineContext { ref?: ActorRefFrom<typeof syncChildMachine>; }
const syncMachine = Machine<SyncMachineContext>({ initial: 'same', context: {}, states: { same: { entry: assign<SyncMachineContext>({ ref: () => spawn(syncChildMachine, falseSyncOption) }) } } });
const service = interpret(syncMachine) .onTransition((state) => { if ( state.context.ref && state.context.ref.getSnapshot()?.matches('inactive') ) { expect(state.changed).toBe(false); } }) .start();
setTimeout(() => { expect( service.state.context.ref?.getSnapshot()?.matches('inactive') ).toBe(true); done(); }, 20); });
it(`parent state should be changed if unsynced child actor manually sends update event (options: ${JSON.stringify( falseSyncOption )})`, (done) => { const syncChildMachine = Machine({ initial: 'active', states: { active: { after: { 10: 'inactive' } }, inactive: { entry: sendUpdate() } } });
interface SyncMachineContext { ref?: ActorRefFrom<typeof syncChildMachine>; }
const syncMachine = Machine<SyncMachineContext>({ initial: 'same', context: {}, states: { same: { entry: assign<SyncMachineContext>({ ref: () => spawn(syncChildMachine, falseSyncOption) }) } } });
interpret(syncMachine) .onTransition((state) => { if (state.context.ref?.getSnapshot()?.matches('inactive')) { expect(state.changed).toBe(true); done(); } }) .start(); }); }); });
describe('with behaviors', () => { it('should work with a reducer behavior', (done) => { const countBehavior: Behavior<EventObject, number> = { transition: (count, event) => { if (event.type === 'INC') { return count + 1; } else { return count - 1; } }, initialState: 0 };
const countMachine = createMachine<{ count: ActorRefFrom<typeof countBehavior> | undefined; }>({ context: { count: undefined }, entry: assign({ count: () => spawn(countBehavior) }), on: { INC: { actions: forwardTo((ctx) => ctx.count!) } } });
const countService = interpret(countMachine) .onTransition((state) => { if (state.context.count?.getSnapshot() === 2) { done(); } }) .start();
countService.send('INC'); countService.send('INC'); });
it('should work with a promise behavior (fulfill)', (done) => { const promiseBehavior = fromPromise( () => new Promise<number>((res) => { setTimeout(() => res(42)); }) );
const countMachine = createMachine<{ count: ActorRefFrom<typeof promiseBehavior> | undefined; }>({ context: { count: undefined }, entry: assign({ count: () => spawn(promiseBehavior, 'test') }), initial: 'pending', states: { pending: { on: { 'done.invoke.test': { target: 'success', cond: (_, e) => e.data === 42 } } }, success: { type: 'final' } } });
const countService = interpret(countMachine).onDone(() => { done(); }); countService.start(); });
it('should work with a promise behavior (reject)', (done) => { const errorMessage = 'An error occurred'; const promiseBehavior = fromPromise( () => new Promise<number>((_, rej) => { setTimeout(() => rej(errorMessage), 1000); }) );
const countMachine = createMachine<{ count: ActorRefFrom<typeof promiseBehavior>; }>({ context: () => ({ count: spawn(promiseBehavior, 'test') }), initial: 'pending', states: { pending: { on: { [error('test')]: { target: 'success', cond: (_, e) => { return e.data === errorMessage; } } } }, success: { type: 'final' } } });
const countService = interpret(countMachine).onDone(() => { done(); }); countService.start(); });
it('behaviors should have reference to the parent', (done) => { const pongBehavior: Behavior<EventObject, undefined> = { transition: (_, event, { parent }) => { if (event.type === 'PING') { parent?.send({ type: 'PONG' }); }
return undefined; }, initialState: undefined };
const pingMachine = createMachine<{ ponger: ActorRefFrom<typeof pongBehavior> | undefined; }>({ initial: 'waiting', context: { ponger: undefined }, entry: assign({ ponger: () => spawn(pongBehavior) }), states: { waiting: { entry: send('PING', { to: (ctx) => ctx.ponger! }), invoke: { id: 'ponger', src: () => pongBehavior }, on: { PONG: 'success' } }, success: { type: 'final' } } });
const pingService = interpret(pingMachine).onDone(() => { done(); }); pingService.start(); }); });
it('should be able to spawn callback actors in (lazy) initial context', (done) => { const machine = createMachine<{ ref: ActorRef<any> }>({ context: () => ({ ref: spawn((sendBack) => { sendBack('TEST'); }) }), initial: 'waiting', states: { waiting: { on: { TEST: 'success' } }, success: { type: 'final' } } });
interpret(machine) .onDone(() => { done(); }) .start(); });
it('should be able to spawn machines in (lazy) initial context', (done) => { const childMachine = createMachine({ entry: sendParent('TEST') });
const machine = createMachine<{ ref: ActorRef<any> }>({ context: () => ({ ref: spawn(childMachine) }), initial: 'waiting', states: { waiting: { on: { TEST: 'success' } }, success: { type: 'final' } } });
interpret(machine) .onDone(() => { done(); }) .start(); });
// https://github.com/statelyai/xstate/issues/2507 it('should not crash on child machine sync completion during self-initialization', () => { const childMachine = createMachine({ initial: 'idle', states: { idle: { always: [ { target: 'stopped' } ] }, stopped: { type: 'final' } } });
const parentMachine = createMachine<{ child: ActorRefFrom<typeof childMachine> | null; }>( { context: { child: null }, entry: 'setup' }, { actions: { setup: assign({ child: (_) => spawn(childMachine) }) } } ); const service = interpret(parentMachine); expect(() => { service.start(); }).not.toThrow(); });
it('should not crash on child promise-like sync completion during self-initialization', () => { const parentMachine = createMachine<{ child: ActorRef<never, any> | null; }>({ context: { child: null }, entry: assign({ child: () => spawn({ then: (fn: any) => fn(null) } as any) }) }); const service = interpret(parentMachine); expect(() => { service.start(); }).not.toThrow(); });
it('should not crash on child observable sync completion during self-initialization', () => { const createEmptyObservable = (): any => ({ subscribe(_next: () => void, _error: () => void, complete: () => void) { complete(); } }); const parentMachine = createMachine<{ child: ActorRef<never, any> | null; }>({ context: { child: null }, entry: assign({ child: () => spawn(createEmptyObservable()) }) }); const service = interpret(parentMachine); expect(() => { service.start(); }).not.toThrow(); });
it('should receive done event from an immediately completed observable when self-initializing', () => { const parentMachine = createMachine<{ child: ActorRef<EventObject, unknown> | null; }>({ context: { child: null }, entry: assign({ child: () => spawn(EMPTY, 'myactor') }), initial: 'init', states: { init: { on: { 'done.invoke.myactor': 'done' } }, done: {} } }); const service = interpret(parentMachine);
service.start();
expect(service.state.value).toBe('done'); });});