diff --git a/packages/core/package.json b/packages/core/package.json index 5a510abe1a..922ef5c103 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -123,7 +123,8 @@ "ajv": "^8.12.0", "pkg-up": "^3.1.0", "rxjs": "^7.8.1", - "xml-js": "^1.6.11" + "xml-js": "^1.6.11", + "zod": "^3.25.51" }, "preconstruct": { "umdName": "XState", diff --git a/packages/core/src/State.ts b/packages/core/src/State.ts index ba002fcc08..c6d4caa1bb 100644 --- a/packages/core/src/State.ts +++ b/packages/core/src/State.ts @@ -2,7 +2,11 @@ import isDevelopment from '#is-development'; import { $$ACTOR_TYPE } from './createActor.ts'; import type { StateNode } from './StateNode.ts'; import type { StateMachine } from './StateMachine.ts'; -import { getStateValue } from './stateUtils.ts'; +import { + getStateValue, + getTransitionResult, + getTransitionActions +} from './stateUtils.ts'; import type { ProvidedActor, AnyMachineSnapshot, @@ -317,7 +321,15 @@ const machineSnapshotCan = function can( return ( !!transitionData?.length && // Check that at least one transition is not forbidden - transitionData.some((t) => t.target !== undefined || t.actions.length) + transitionData.some((t) => { + const res = getTransitionResult(t, this, event); + return ( + t.target !== undefined || + res.targets?.length || + res.context || + getTransitionActions(t, this, event, { self: {} }).length + ); + }) ); }; diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index 21303abe7f..d3d3c56652 100644 --- a/packages/core/src/StateMachine.ts +++ b/packages/core/src/StateMachine.ts @@ -440,8 +440,7 @@ export class StateMachine< source: this.root, reenter: true, actions: [], - eventType: null as any, - toJSON: null as any // TODO: fix + eventType: null as any } ], preInitialState, diff --git a/packages/core/src/StateNode.ts b/packages/core/src/StateNode.ts index 9c7c764cdc..ca6621cd48 100644 --- a/packages/core/src/StateNode.ts +++ b/packages/core/src/StateNode.ts @@ -1,10 +1,10 @@ import { MachineSnapshot } from './State.ts'; import type { StateMachine } from './StateMachine.ts'; import { NULL_EVENT, STATE_DELIMITER } from './constants.ts'; -import { evaluateGuard } from './guards.ts'; import { memo } from './memo.ts'; import { BuiltinAction, + evaluateCandidate, formatInitialTransition, formatTransition, formatTransitions, @@ -31,7 +31,8 @@ import type { AnyStateNodeConfig, ProvidedActor, NonReducibleUnknown, - EventDescriptor + EventDescriptor, + Action2 } from './types.ts'; import { createInvokeId, @@ -100,8 +101,10 @@ export class StateNode< public history: false | 'shallow' | 'deep'; /** The action(s) to be executed upon entering the state node. */ public entry: UnknownAction[]; + public entry2: Action2 | undefined; /** The action(s) to be executed upon exiting the state node. */ public exit: UnknownAction[]; + public exit2: Action2 | undefined; /** The parent state node. */ public parent?: StateNode; /** The root machine node. */ @@ -211,8 +214,17 @@ export class StateNode< this.config.history === true ? 'shallow' : this.config.history || false; this.entry = toArray(this.config.entry).slice(); + this.entry2 = this.config.entry2; + if (this.entry2) { + // @ts-ignore + this.entry2._special = true; + } this.exit = toArray(this.config.exit).slice(); - + this.exit2 = this.config.exit2; + if (this.exit2) { + // @ts-ignore + this.exit2._special = true; + } this.meta = this.config.meta; this.output = this.type === 'final' || !this.parent ? this.config.output : undefined; @@ -224,7 +236,7 @@ export class StateNode< this.transitions = formatTransitions(this); if (this.config.always) { this.always = toTransitionConfigArray(this.config.always).map((t) => - formatTransition(this, NULL_EVENT, t) + typeof t === 'function' ? t : formatTransition(this, NULL_EVENT, t) ); } @@ -246,13 +258,7 @@ export class StateNode< source: this, actions: this.initial.actions.map(toSerializableAction), eventType: null as any, - reenter: false, - toJSON: () => ({ - target: this.initial.target.map((t) => `#${t.id}`), - source: `#${this.id}`, - actions: this.initial.actions.map(toSerializableAction), - eventType: null as any - }) + reenter: false } : undefined, history: this.history, @@ -388,35 +394,15 @@ export class StateNode< ); for (const candidate of candidates) { - const { guard } = candidate; const resolvedContext = snapshot.context; - let guardPassed = false; - - try { - guardPassed = - !guard || - evaluateGuard( - guard, - resolvedContext, - event, - snapshot - ); - } catch (err: any) { - const guardType = - typeof guard === 'string' - ? guard - : typeof guard === 'object' - ? guard.type - : undefined; - throw new Error( - `Unable to evaluate guard ${ - guardType ? `'${guardType}' ` : '' - }in transition for event '${eventType}' in state node '${ - this.id - }':\n${err.message}` - ); - } + const guardPassed = evaluateCandidate( + candidate, + resolvedContext, + event, + snapshot, + this + ); if (guardPassed) { actions.push(...candidate.actions); diff --git a/packages/core/src/actions/assign.ts b/packages/core/src/actions/assign.ts index 7144d6e22f..efeb07dc7f 100644 --- a/packages/core/src/actions/assign.ts +++ b/packages/core/src/actions/assign.ts @@ -58,7 +58,8 @@ function resolveAssign( spawnedChildren ), self: actorScope.self, - system: actorScope.system + system: actorScope.system, + children: snapshot.children }; let partialUpdate: Record = {}; if (typeof assignment === 'function') { diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 30e1268b3a..50bfa72659 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -219,7 +219,11 @@ export class Actor const saveExecutingCustomAction = executingCustomAction; try { executingCustomAction = true; - action.exec(action.info, action.params); + // v6 + const actionArgs = action.args.length + ? [] // already bound + : [action.info, action.params]; + action.exec(...actionArgs); } finally { executingCustomAction = saveExecutingCustomAction; } diff --git a/packages/core/src/createMachine.ts b/packages/core/src/createMachine.ts index cacd45ff5f..fd1d6cb165 100644 --- a/packages/core/src/createMachine.ts +++ b/packages/core/src/createMachine.ts @@ -16,6 +16,7 @@ import { ToChildren, MetaObject } from './types.ts'; +import { Next_MachineConfig } from './types.v6.ts'; type TestValue = | string @@ -162,3 +163,78 @@ export function createMachine< any // TStateSchema >(config as any, implementations as any); } + +export function next_createMachine< + TContext extends MachineContext, + TEvent extends AnyEventObject, // TODO: consider using a stricter `EventObject` here + TActor extends ProvidedActor, + TAction extends ParameterizedObject, + TGuard extends ParameterizedObject, + TDelay extends string, + TTag extends string, + TInput, + TOutput extends NonReducibleUnknown, + TEmitted extends EventObject, + TMeta extends MetaObject, + // it's important to have at least one default type parameter here + // it allows us to benefit from contextual type instantiation as it makes us to pass the hasInferenceCandidatesOrDefault check in the compiler + // we should be able to remove this when we start inferring TConfig, with it we'll always have an inference candidate + _ = any +>( + config: { + schemas?: unknown; + } & Next_MachineConfig< + TContext, + TEvent, + TDelay, + TTag, + TInput, + TOutput, + TEmitted, + TMeta + >, + implementations?: InternalMachineImplementations< + ResolvedStateMachineTypes< + TContext, + TEvent, + TActor, + TAction, + TGuard, + TDelay, + TTag, + TEmitted + > + > +): StateMachine< + TContext, + TEvent, + Cast, Record>, + TActor, + TAction, + TGuard, + TDelay, + StateValue, + TTag & string, + TInput, + TOutput, + TEmitted, + TMeta, // TMeta + TODO // TStateSchema +> { + return new StateMachine< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, // TEmitted + any, // TMeta + any // TStateSchema + >(config as any, implementations as any); +} diff --git a/packages/core/src/createMachine2.ts b/packages/core/src/createMachine2.ts new file mode 100644 index 0000000000..f7f0069c11 --- /dev/null +++ b/packages/core/src/createMachine2.ts @@ -0,0 +1,119 @@ +// @ts-nocheck +import { EventObject, MachineContext, MetaObject } from './types'; + +type EnqueueObj = { + context: TContext; + event: TEvent; + enqueue: (fn: any) => void; +}; + +type StateTransition< + TContext extends MachineContext, + TEvent extends EventObject, + TStateMap extends Record +> = (obj: EnqueueObj) => { + target: keyof TStateMap; + context: TContext; +}; + +type StateConfig< + TContext extends MachineContext, + TEvent extends EventObject, + TStateMap extends Record +> = { + entry?: (obj: EnqueueObj) => void; + exit?: (obj: EnqueueObj) => void; + on?: { + [K in TEvent['type']]?: StateTransition; + }; + after?: { + [K in string | number]: StateTransition; + }; + always?: StateTransition; + meta?: MetaObject; + id?: string; + tags?: string[]; + description?: string; +} & ( + | { + type: 'parallel'; + initial?: never; + states: States; + } + | { + type: 'final'; + initial?: never; + states?: never; + } + | { + type: 'history'; + history?: 'shallow' | 'deep'; + default?: keyof TStateMap; + } + | { + type?: 'compound'; + initial: NoInfer; + states: States; + } + | { + type?: 'atomic'; + initial?: never; + states?: never; + } +); + +type States< + TContext extends MachineContext, + TEvent extends EventObject, + TStateMap extends Record +> = { + [K in keyof TStateMap]: StateConfig; +}; + +export function createMachine2< + TContext extends MachineContext, + TEvent extends EventObject, + TStateMap extends Record +>( + config: { + context: TContext; + version?: string; + } & StateConfig +) {} + +const light = createMachine2({ + context: {}, + initial: 'green', + states: { + green: { + on: { + timer: () => ({ + target: 'yellow', + context: {} + }) + } + }, + yellow: { + on: { + timer: () => ({ + target: 'red', + context: {} + }) + } + }, + red: { + on: { + timer: () => ({ + target: 'green', + context: {} + }) + } + }, + hist: { + type: 'history', + history: 'shallow' + } + } +}); + +light; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3690d330e6..da0662001d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,7 +8,7 @@ export { type Interpreter, type RequiredActorOptionsKeys as RequiredActorOptionsKeys } from './createActor.ts'; -export { createMachine } from './createMachine.ts'; +export { createMachine, next_createMachine } from './createMachine.ts'; export { getInitialSnapshot, getNextSnapshot } from './getNextSnapshot.ts'; export { and, not, or, stateIn } from './guards.ts'; export type { diff --git a/packages/core/src/setup.ts b/packages/core/src/setup.ts index 8f18d398a6..e58823d7f0 100644 --- a/packages/core/src/setup.ts +++ b/packages/core/src/setup.ts @@ -18,10 +18,12 @@ import { ParameterizedObject, SetupTypes, ToChildren, + TODO, ToStateValue, UnknownActorLogic, Values } from './types'; +import { Next_MachineConfig, Next_SetupTypes } from './types.v6'; type ToParameterizedObject< TParameterizedMap extends Record< @@ -66,9 +68,8 @@ type ToProvidedActor< }; }>; -type RequiredSetupKeys = IsNever extends true - ? never - : 'actors'; +type RequiredSetupKeys = + IsNever extends true ? never : 'actors'; export function setup< TContext extends MachineContext, @@ -195,3 +196,71 @@ export function setup< ) }; } + +export function next_setup< + TContext extends MachineContext, + TEvent extends AnyEventObject, // TODO: consider using a stricter `EventObject` here + TDelay extends string = never, + TTag extends string = string, + TInput = NonReducibleUnknown, + TOutput extends NonReducibleUnknown = NonReducibleUnknown, + TEmitted extends EventObject = EventObject, + TMeta extends MetaObject = MetaObject +>({ + schemas, + delays +}: { + schemas?: unknown; + types?: Next_SetupTypes< + TContext, + TEvent, + TTag, + TInput, + TOutput, + TEmitted, + TMeta + >; + delays?: { + [K in TDelay]: TODO; + }; +}): { + createMachine: < + const TConfig extends Next_MachineConfig< + TContext, + TEvent, + TDelay, + TTag, + TInput, + TOutput, + TEmitted, + TMeta + > + >( + config: TConfig + ) => StateMachine< + TContext, + TEvent, + TODO, + TODO, + TODO, + TODO, + TDelay, + ToStateValue, + TTag, + TInput, + TOutput, + TEmitted, + TMeta, + TConfig + >; +} { + return { + createMachine: (config) => + (createMachine as any)( + { ...config, schemas }, + { + delays + } + ) + }; +} diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index 3b01a64baf..844bf7e240 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -1,7 +1,7 @@ import isDevelopment from '#is-development'; import { MachineSnapshot, cloneMachineSnapshot } from './State.ts'; import type { StateNode } from './StateNode.ts'; -import { raise } from './actions.ts'; +import { assign, log, raise, sendTo } from './actions.ts'; import { createAfterEvent, createDoneStateEvent } from './eventUtils.ts'; import { cancel } from './actions/cancel.ts'; import { spawnChild } from './actions/spawnChild.ts'; @@ -37,7 +37,11 @@ import { AnyTransitionConfig, AnyActorScope, ActionExecutor, - AnyStateMachine + AnyStateMachine, + EnqueueObj, + Action2, + AnyActorRef, + TransitionConfigFunction } from './types.ts'; import { resolveOutput, @@ -294,7 +298,9 @@ export function getDelayedTransitions( const resolvedTransition = typeof configTransition === 'string' ? { target: configTransition } - : configTransition; + : typeof configTransition === 'function' + ? { fn: configTransition } + : configTransition; const resolvedDelay = Number.isNaN(+delay) ? delay : +delay; const eventType = mutateEntryExit(resolvedDelay); return toArray(resolvedTransition).map((transition) => ({ @@ -371,7 +377,9 @@ export function formatTransitions< transitions.set( descriptor, toTransitionConfigArray(transitionsConfig).map((t) => - formatTransition(stateNode, descriptor, t) + typeof t === 'function' + ? t + : formatTransition(stateNode, descriptor, t) ) ); } @@ -381,7 +389,7 @@ export function formatTransitions< transitions.set( descriptor, toTransitionConfigArray(stateNode.config.onDone).map((t) => - formatTransition(stateNode, descriptor, t) + typeof t === 'function' ? t : formatTransition(stateNode, descriptor, t) ) ); } @@ -391,7 +399,9 @@ export function formatTransitions< transitions.set( descriptor, toTransitionConfigArray(invokeDef.onDone).map((t) => - formatTransition(stateNode, descriptor, t) + typeof t === 'function' + ? t + : formatTransition(stateNode, descriptor, t) ) ); } @@ -400,7 +410,9 @@ export function formatTransitions< transitions.set( descriptor, toTransitionConfigArray(invokeDef.onError).map((t) => - formatTransition(stateNode, descriptor, t) + typeof t === 'function' + ? t + : formatTransition(stateNode, descriptor, t) ) ); } @@ -409,7 +421,9 @@ export function formatTransitions< transitions.set( descriptor, toTransitionConfigArray(invokeDef.onSnapshot).map((t) => - formatTransition(stateNode, descriptor, t) + typeof t === 'function' + ? t + : formatTransition(stateNode, descriptor, t) ) ); } @@ -453,12 +467,7 @@ export function formatInitialTransition< !_target || typeof _target === 'string' ? [] : toArray(_target.actions), eventType: null as any, reenter: false, - target: resolvedTarget ? [resolvedTarget] : [], - toJSON: () => ({ - ...transition, - source: `#${stateNode.id}`, - target: resolvedTarget ? [`#${resolvedTarget.id}`] : [] - }) + target: resolvedTarget ? [resolvedTarget] : [] }; return transition; @@ -520,7 +529,8 @@ function resolveHistoryDefaultTransition< return { target: normalizedTarget.map((t) => typeof t === 'string' ? getStateNodeByPath(stateNode.parent!, t) : t - ) + ), + source: stateNode }; } @@ -828,7 +838,9 @@ function hasIntersection(s1: Iterable, s2: Iterable): boolean { function removeConflictingTransitions( enabledTransitions: Array, stateNodeSet: Set, - historyValue: AnyHistoryValue + historyValue: AnyHistoryValue, + snapshot: AnyMachineSnapshot, + event: AnyEventObject ): Array { const filteredTransitions = new Set(); @@ -838,8 +850,8 @@ function removeConflictingTransitions( for (const t2 of filteredTransitions) { if ( hasIntersection( - computeExitSet([t1], stateNodeSet, historyValue), - computeExitSet([t2], stateNodeSet, historyValue) + computeExitSet([t1], stateNodeSet, historyValue, snapshot, event), + computeExitSet([t2], stateNodeSet, historyValue, snapshot, event) ) ) { if (isDescendant(t1.source, t2.source)) { @@ -873,49 +885,63 @@ function findLeastCommonAncestor( } function getEffectiveTargetStates( - transition: Pick, - historyValue: AnyHistoryValue + transition: Pick, + historyValue: AnyHistoryValue, + snapshot: AnyMachineSnapshot, + event: AnyEventObject ): Array { - if (!transition.target) { + const { targets } = getTransitionResult(transition, snapshot, event); + if (!targets) { return []; } - const targets = new Set(); + const targetSet = new Set(); - for (const targetNode of transition.target) { + for (const targetNode of targets) { if (isHistoryNode(targetNode)) { if (historyValue[targetNode.id]) { for (const node of historyValue[targetNode.id]) { - targets.add(node); + targetSet.add(node); } } else { for (const node of getEffectiveTargetStates( resolveHistoryDefaultTransition(targetNode), - historyValue + historyValue, + snapshot, + event )) { - targets.add(node); + targetSet.add(node); } } } else { - targets.add(targetNode); + targetSet.add(targetNode); } } - return [...targets]; + return [...targetSet]; } function getTransitionDomain( transition: AnyTransitionDefinition, - historyValue: AnyHistoryValue + historyValue: AnyHistoryValue, + snapshot: AnyMachineSnapshot, + event: AnyEventObject ): AnyStateNode | undefined { - const targetStates = getEffectiveTargetStates(transition, historyValue); + const targetStates = getEffectiveTargetStates( + transition, + historyValue, + snapshot, + event + ); if (!targetStates) { return; } + const { reenter } = getTransitionResult(transition, snapshot, event); + if ( - !transition.reenter && + !reenter && targetStates.every( (target) => target === transition.source || isDescendant(target, transition.source) @@ -931,7 +957,7 @@ function getTransitionDomain( } // at this point we know that it's a root transition since LCA couldn't be found - if (transition.reenter) { + if (reenter) { return; } @@ -939,15 +965,19 @@ function getTransitionDomain( } function computeExitSet( - transitions: AnyTransitionDefinition[], + transitions: Array, stateNodeSet: Set, - historyValue: AnyHistoryValue + historyValue: AnyHistoryValue, + snapshot: AnyMachineSnapshot, + event: AnyEventObject ): Array { const statesToExit = new Set(); for (const t of transitions) { - if (t.target?.length) { - const domain = getTransitionDomain(t, historyValue); + const { targets } = getTransitionResult(t, snapshot, event); + + if (targets?.length) { + const domain = getTransitionDomain(t, historyValue, snapshot, event); if (t.reenter && t.source === domain) { statesToExit.add(domain); @@ -997,7 +1027,9 @@ export function microstep( const filteredTransitions = removeConflictingTransitions( transitions, mutStateNodeSet, - historyValue + historyValue, + currentSnapshot, + event ); let nextState = currentSnapshot; @@ -1021,11 +1053,37 @@ export function microstep( nextState, event, actorScope, - filteredTransitions.flatMap((t) => t.actions), + filteredTransitions.flatMap((t) => + getTransitionActions(t, currentSnapshot, event, actorScope) + ), internalQueue, undefined ); + // Get context + const context = nextState.context; + for (const transitionDef of filteredTransitions) { + if (transitionDef.fn) { + const res = transitionDef.fn( + { + context, + event, + value: nextState.value, + children: nextState.children, + parent: actorScope.self._parent + }, + emptyEnqueueObj + ); + + if (res?.context) { + nextState = { + ...nextState, + context: res.context + }; + } + } + } + // Enter states nextState = enterStates( nextState, @@ -1047,7 +1105,19 @@ export function microstep( actorScope, nextStateNodes .sort((a, b) => b.order - a.order) - .flatMap((state) => state.exit), + .flatMap((stateNode) => { + if (stateNode.exit2) { + const actions = getActionsFromAction2(stateNode.exit2, { + context: nextState.context, + event, + self: actorScope.self, + parent: actorScope.self._parent, + children: actorScope.self.getSnapshot().children + }); + return [...stateNode.exit, ...actions]; + } + return stateNode.exit; + }), internalQueue, undefined ); @@ -1121,7 +1191,9 @@ function enterStates( filteredTransitions, historyValue, statesForDefaultEntry, - statesToEnter + statesToEnter, + currentSnapshot, + event ); // In the initial state, the root state node is "entered". @@ -1149,6 +1221,18 @@ function enterStates( ); } + if (stateNodeToEnter.entry2) { + actions.push( + ...getActionsFromAction2(stateNodeToEnter.entry2, { + context: nextSnapshot.context, + event, + self: actorScope.self, + parent: actorScope.self._parent, + children: currentSnapshot.children + }) + ); + } + if (statesForDefaultEntry.has(stateNodeToEnter)) { const initialActions = stateNodeToEnter.initial.actions; actions.push(...initialActions); @@ -1215,16 +1299,147 @@ function enterStates( return nextSnapshot; } +/** + * Gets the transition result for a given transition without executing the + * transition. + */ +export function getTransitionResult( + transition: Pick & { + reenter?: AnyTransitionDefinition['reenter']; + }, + snapshot: AnyMachineSnapshot, + event: AnyEventObject +): { + targets: Readonly | undefined; + context: MachineContext | undefined; + actions: UnknownAction[]; + reenter?: boolean; +} { + if (transition.fn) { + const actions: UnknownAction[] = []; + const res = transition.fn( + { + context: snapshot.context, + event, + value: snapshot.value, + children: snapshot.children, + parent: undefined + }, + { + action: (fn, ...args) => { + actions.push({ + action: fn, + args + }); + }, + cancel: (id) => { + actions.push(cancel(id)); + }, + raise: (event, options) => { + actions.push(raise(event, options)); + }, + emit: (emittedEvent) => { + actions.push(emittedEvent); + }, + log: (...args) => { + actions.push(log(...args)); + }, + spawn: (src, options) => { + actions.push(spawnChild(src, options)); + return {} as any; + }, + sendTo: (actorRef, event, options) => { + actions.push(sendTo(actorRef, event, options)); + } + } + ); + + return { + targets: res?.target + ? resolveTarget(transition.source, [res.target]) + : undefined, + context: res?.context, + reenter: res?.reenter, + actions + }; + } + + return { + targets: transition.target as AnyStateNode[] | undefined, + context: undefined, + reenter: transition.reenter, + actions: [] + }; +} + +export function getTransitionActions( + transition: Pick< + AnyTransitionDefinition, + 'target' | 'fn' | 'source' | 'actions' + >, + snapshot: AnyMachineSnapshot, + event: AnyEventObject, + actorScope: AnyActorScope +): Readonly { + if (transition.fn) { + const actions: UnknownAction[] = []; + transition.fn( + { + context: snapshot.context, + event, + value: snapshot.value, + children: snapshot.children, + parent: actorScope.self._parent + }, + { + action: (fn, ...args) => { + actions.push({ + action: fn, + args + }); + }, + cancel: (id) => { + actions.push(cancel(id)); + }, + raise: (event, options) => { + actions.push(raise(event, options)); + }, + emit: (emittedEvent) => { + actions.push(emittedEvent); + }, + log: (...args) => { + actions.push(log(...args)); + }, + spawn: (src, options) => { + actions.push(spawnChild(src, options)); + return {} as any; + }, + sendTo: (actorRef, event, options) => { + actions.push(sendTo(actorRef, event, options)); + } + } + ); + + return actions; + } + + return transition.actions; +} + function computeEntrySet( transitions: Array, historyValue: HistoryValue, statesForDefaultEntry: Set, - statesToEnter: Set + statesToEnter: Set, + snapshot: AnyMachineSnapshot, + event: AnyEventObject ) { for (const t of transitions) { - const domain = getTransitionDomain(t, historyValue); + const domain = getTransitionDomain(t, historyValue, snapshot, event); - for (const s of t.target || []) { + const { targets, reenter } = getTransitionResult(t, snapshot, event); + + for (const s of targets ?? []) { if ( !isHistoryNode(s) && // if the target is different than the source then it will *definitely* be entered @@ -1233,7 +1448,7 @@ function computeEntrySet( // if it's different than the source then it's outside of it and it means that the target has to be entered as well t.source !== domain || // reentering transitions always enter the target, even if it's the source itself - t.reenter) + reenter) ) { statesToEnter.add(s); statesForDefaultEntry.add(s); @@ -1242,10 +1457,17 @@ function computeEntrySet( s, historyValue, statesForDefaultEntry, - statesToEnter + statesToEnter, + snapshot, + event ); } - const targetStates = getEffectiveTargetStates(t, historyValue); + const targetStates = getEffectiveTargetStates( + t, + historyValue, + snapshot, + event + ); for (const s of targetStates) { const ancestors = getProperAncestors(s, domain); if (domain?.type === 'parallel') { @@ -1256,7 +1478,9 @@ function computeEntrySet( historyValue, statesForDefaultEntry, ancestors, - !t.source.parent && t.reenter ? undefined : domain + !t.source.parent && reenter ? undefined : domain, + snapshot, + event ); } } @@ -1269,7 +1493,9 @@ function addDescendantStatesToEnter< stateNode: AnyStateNode, historyValue: HistoryValue, statesForDefaultEntry: Set, - statesToEnter: Set + statesToEnter: Set, + snapshot: AnyMachineSnapshot, + event: AnyEventObject ) { if (isHistoryNode(stateNode)) { if (historyValue[stateNode.id]) { @@ -1281,7 +1507,9 @@ function addDescendantStatesToEnter< s, historyValue, statesForDefaultEntry, - statesToEnter + statesToEnter, + snapshot, + event ); } for (const s of historyStateNodes) { @@ -1290,7 +1518,9 @@ function addDescendantStatesToEnter< stateNode.parent, statesToEnter, historyValue, - statesForDefaultEntry + statesForDefaultEntry, + snapshot, + event ); } } else { @@ -1298,7 +1528,12 @@ function addDescendantStatesToEnter< TContext, TEvent >(stateNode); - for (const s of historyDefaultTransition.target) { + const { targets } = getTransitionResult( + historyDefaultTransition, + snapshot, + event + ); + for (const s of targets ?? []) { statesToEnter.add(s); if (historyDefaultTransition === stateNode.parent?.initial) { @@ -1309,17 +1544,21 @@ function addDescendantStatesToEnter< s, historyValue, statesForDefaultEntry, - statesToEnter + statesToEnter, + snapshot, + event ); } - for (const s of historyDefaultTransition.target) { + for (const s of targets ?? []) { addProperAncestorStatesToEnter( s, stateNode.parent, statesToEnter, historyValue, - statesForDefaultEntry + statesForDefaultEntry, + snapshot, + event ); } } @@ -1335,7 +1574,9 @@ function addDescendantStatesToEnter< initialState, historyValue, statesForDefaultEntry, - statesToEnter + statesToEnter, + snapshot, + event ); addProperAncestorStatesToEnter( @@ -1343,7 +1584,9 @@ function addDescendantStatesToEnter< stateNode, statesToEnter, historyValue, - statesForDefaultEntry + statesForDefaultEntry, + snapshot, + event ); } else { if (stateNode.type === 'parallel') { @@ -1359,7 +1602,9 @@ function addDescendantStatesToEnter< child, historyValue, statesForDefaultEntry, - statesToEnter + statesToEnter, + snapshot, + event ); } } @@ -1373,7 +1618,9 @@ function addAncestorStatesToEnter( historyValue: HistoryValue, statesForDefaultEntry: Set, ancestors: AnyStateNode[], - reentrancyDomain?: AnyStateNode + reentrancyDomain: AnyStateNode | undefined, + snapshot: AnyMachineSnapshot, + event: AnyEventObject ) { for (const anc of ancestors) { if (!reentrancyDomain || isDescendant(anc, reentrancyDomain)) { @@ -1387,7 +1634,9 @@ function addAncestorStatesToEnter( child, historyValue, statesForDefaultEntry, - statesToEnter + statesToEnter, + snapshot, + event ); } } @@ -1400,13 +1649,18 @@ function addProperAncestorStatesToEnter( toStateNode: AnyStateNode | undefined, statesToEnter: Set, historyValue: HistoryValue, - statesForDefaultEntry: Set + statesForDefaultEntry: Set, + snapshot: AnyMachineSnapshot, + event: AnyEventObject ) { addAncestorStatesToEnter( statesToEnter, historyValue, statesForDefaultEntry, - getProperAncestors(stateNode, toStateNode) + getProperAncestors(stateNode, toStateNode), + undefined, + snapshot, + event ); } @@ -1424,7 +1678,9 @@ function exitStates( const statesToExit = computeExitSet( transitions, mutStateNodeSet, - historyValue + historyValue, + currentSnapshot, + event ); statesToExit.sort((a, b) => b.order - a.order); @@ -1450,11 +1706,20 @@ function exitStates( } for (const s of statesToExit) { + const exitActions = s.exit2 + ? getActionsFromAction2(s.exit2, { + context: nextSnapshot.context, + event, + self: actorScope.self, + parent: actorScope.self._parent, + children: actorScope.self.getSnapshot().children + }) + : []; nextSnapshot = resolveActionsAndContext( nextSnapshot, event, actorScope, - [...s.exit, ...s.invoke.map((def) => stopChild(def.id))], + [...s.exit, ...s.invoke.map((def) => stopChild(def.id)), ...exitActions], internalQueue, undefined ); @@ -1508,26 +1773,58 @@ function resolveAndExecuteActionsWithContext( const isInline = typeof action === 'function'; const resolvedAction = isInline ? action - : // the existing type of `.actions` assumes non-nullable `TExpressionAction` - // it's fine to cast this here to get a common type and lack of errors in the rest of the code - // our logic below makes sure that we call those 2 "variants" correctly + : typeof action === 'object' && 'action' in action + ? action.action.bind(null, ...action.args) + : // the existing type of `.actions` assumes non-nullable `TExpressionAction` + // it's fine to cast this here to get a common type and lack of errors in the rest of the code + // our logic below makes sure that we call those 2 "variants" correctly + + getAction(machine, typeof action === 'string' ? action : action.type); + + // if no action, emit it! + if (!resolvedAction && typeof action === 'object' && action !== null) { + actorScope.defer(() => { + actorScope.emit(action); + }); + } - getAction(machine, typeof action === 'string' ? action : action.type); const actionArgs = { context: intermediateSnapshot.context, event, self: actorScope.self, - system: actorScope.system + system: actorScope.system, + children: intermediateSnapshot.children, + parent: actorScope.self._parent }; - const actionParams = + let actionParams = isInline || typeof action === 'string' ? undefined : 'params' in action ? typeof action.params === 'function' ? action.params({ context: intermediateSnapshot.context, event }) : action.params - : undefined; + : // Emitted event + undefined; + + // Emitted events + if (!actionParams && typeof action === 'object' && action !== null) { + const { type: _, ...emittedEventParams } = action as any; + actionParams = emittedEventParams; + } + + if (resolvedAction && '_special' in resolvedAction) { + const specialAction = resolvedAction as unknown as Action2; + + const res = specialAction(actionArgs, emptyEnqueueObj); + + if (res?.context) { + intermediateSnapshot = cloneMachineSnapshot(intermediateSnapshot, { + context: res.context + }); + } + continue; + } if (!resolvedAction || !('resolve' in resolvedAction)) { actorScope.actionExecutor({ @@ -1535,10 +1832,14 @@ function resolveAndExecuteActionsWithContext( typeof action === 'string' ? action : typeof action === 'object' - ? action.type + ? 'action' in action + ? (action.action.name ?? '(anonymous)') + : action.type : action.name || '(anonymous)', info: actionArgs, params: actionParams, + args: + typeof action === 'object' && 'action' in action ? action.args : [], exec: resolvedAction }); continue; @@ -1565,6 +1866,7 @@ function resolveAndExecuteActionsWithContext( type: builtinAction.type, info: actionArgs, params, + args: [], exec: builtinAction.execute.bind(null, actorScope, params) }); } @@ -1770,8 +2072,7 @@ function selectEventlessTransitions( } for (const transition of s.always) { if ( - transition.guard === undefined || - evaluateGuard(transition.guard, nextState.context, event, nextState) + evaluateCandidate(transition, nextState.context, event, nextState, s) ) { enabledTransitionSet.add(transition); break loop; @@ -1783,7 +2084,9 @@ function selectEventlessTransitions( return removeConflictingTransitions( Array.from(enabledTransitionSet), new Set(nextState._nodes), - nextState.historyValue + nextState.historyValue, + nextState, + event ); } @@ -1800,3 +2103,150 @@ export function resolveStateValue( const allStateNodes = getAllStateNodes(getStateNodes(rootNode, stateValue)); return getStateValue(rootNode, [...allStateNodes]); } + +export const emptyEnqueueObj: EnqueueObj = { + action: () => {}, + cancel: () => {}, + emit: () => {}, + log: () => {}, + raise: () => {}, + spawn: () => ({}) as any, + sendTo: () => {} +}; + +function getActionsFromAction2( + action2: Action2, + { + context, + event, + parent, + self, + children + }: { + context: MachineContext; + event: EventObject; + self: AnyActorRef; + parent: AnyActorRef | undefined; + children: Record; + } +) { + if (action2.length === 2) { + // enqueue action; retrieve + const actions: any[] = []; + + const res = action2( + { + context, + event, + parent, + self, + children + }, + { + action: (action, ...args) => { + actions.push({ + action, + args + }); + }, + cancel: (id: string) => { + actions.push(cancel(id)); + }, + emit: (emittedEvent) => { + actions.push(emittedEvent); + }, + log: (...args) => { + actions.push(log(...args)); + }, + raise: (raisedEvent, options) => { + actions.push(raise(raisedEvent, options)); + }, + spawn: (logic, options) => { + actions.push(spawnChild(logic, options)); + return {} as any; // TODO + }, + sendTo: (actorRef, event, options) => { + actions.push(sendTo(actorRef, event, options)); + } + } + ); + + if (res?.context) { + actions.push(assign(res.context)); + } + + return actions; + } + + return [action2]; +} + +export function evaluateCandidate( + candidate: TransitionDefinition, + context: MachineContext, + event: EventObject, + snapshot: AnyMachineSnapshot, + stateNode: AnyStateNode +): boolean { + if (candidate.fn) { + let hasEffect = false; + let res; + + try { + const triggerEffect = () => { + hasEffect = true; + throw new Error('Effect triggered'); + }; + res = candidate.fn( + { + context, + event, + parent: { + send: triggerEffect + }, + value: snapshot.value, + children: snapshot.children + }, + { + action: triggerEffect, + emit: triggerEffect, + cancel: triggerEffect, + log: triggerEffect, + raise: triggerEffect, + spawn: triggerEffect, + sendTo: triggerEffect + } + ); + } catch (err) { + if (hasEffect) { + return true; + } + throw err; + } + + return res !== undefined; + } + + const { guard } = candidate; + + let result: boolean; + try { + result = !guard || evaluateGuard(guard, context, event, snapshot); + } catch (err: any) { + const guardType = + typeof guard === 'string' + ? guard + : typeof guard === 'object' + ? guard.type + : undefined; + throw new Error( + `Unable to evaluate guard ${ + guardType ? `'${guardType}' ` : '' + }in transition for event '${event.type}' in state node '${ + stateNode.id + }':\n${err.message}` + ); + } + + return result; +} diff --git a/packages/core/src/system.ts b/packages/core/src/system.ts index 1ff355cc05..d209ee3b18 100644 --- a/packages/core/src/system.ts +++ b/packages/core/src/system.ts @@ -86,7 +86,6 @@ export interface ActorSystem { export type AnyActorSystem = ActorSystem; -let idCounter = 0; export function createSystem( rootActor: AnyActorRef, options: { @@ -95,6 +94,7 @@ export function createSystem( snapshot?: unknown; } ): ActorSystem { + let idCounter = 0; const children = new Map(); const keyedActors = new Map(); const reverseKeyedActors = new WeakMap(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 733f92574e..331735b138 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -133,7 +133,9 @@ export interface ActionArgs< TContext extends MachineContext, TExpressionEvent extends EventObject, TEvent extends EventObject -> extends UnifiedArg {} +> extends UnifiedArg { + children: Record; +} export type InputFrom = T extends StateMachine< @@ -254,7 +256,11 @@ export type Action< TGuard, TDelay, TEmitted - >; + > + | { + action: (...args: any[]) => any; + args: unknown[]; + }; export type UnknownAction = Action< MachineContext, @@ -336,6 +342,7 @@ export interface TransitionConfig< >; reenter?: boolean; target?: TransitionTarget | undefined; + fn?: TransitionConfigFunction; meta?: TMeta; description?: string; } @@ -486,6 +493,12 @@ export type DelayedTransitions< TODO, // TEmitted TODO // TMeta > + > + | TransitionConfigFunction< + TContext, + TEvent, + TEvent, + TODO // TEmitted >; }; @@ -564,6 +577,34 @@ export type TransitionConfigOrTarget< TEmitted, TMeta > + | TransitionConfigFunction +>; + +export type TransitionConfigFunction< + TContext extends MachineContext, + TCurrentEvent extends EventObject, + TEvent extends EventObject, + TEmitted extends EventObject +> = ( + obj: { + context: TContext; + event: TCurrentEvent; + parent: UnknownActorRef | undefined; + value: StateValue; + children: Record; + }, + enq: EnqueueObj +) => { + target?: string; + context?: TContext; + reenter?: boolean; +} | void; + +export type AnyTransitionConfigFunction = TransitionConfigFunction< + any, + any, + any, + any >; export type TransitionsConfig< @@ -872,6 +913,7 @@ export interface StateNodeConfig< /** The initial state transition. */ initial?: | InitialTransitionConfig + | TransitionConfigFunction | string | undefined; /** @@ -923,6 +965,16 @@ export interface StateNodeConfig< TMeta > >; + invoke2?: InvokeConfig< + TContext, + TEvent, + TActor, + TAction, + TGuard, + TDelay, + TEmitted, + TMeta + >; /** The mapping of event types to their potential transition(s). */ on?: TransitionsConfig< TContext, @@ -946,6 +998,7 @@ export interface StateNodeConfig< TDelay, TEmitted >; + entry2?: Action2; /** The action(s) to be executed upon exiting the state node. */ exit?: Actions< TContext, @@ -958,6 +1011,7 @@ export interface StateNodeConfig< TDelay, TEmitted >; + exit2?: Action2; /** * The potential transition(s) to be taken upon reaching a final child state * node. @@ -1308,7 +1362,7 @@ export type InternalMachineImplementations = { guards?: MachineImplementationsGuards; }; -type InitialContext< +export type InitialContext< TContext extends MachineContext, TActor extends ProvidedActor, TInput, @@ -1679,14 +1733,6 @@ export interface TransitionDefinition< reenter: boolean; guard?: UnknownGuard; eventType: EventDescriptor; - toJSON: () => { - target: string[] | undefined; - source: string; - actions: readonly UnknownAction[]; - guard?: UnknownGuard; - eventType: EventDescriptor; - meta?: Record; - }; } export type AnyTransitionDefinition = TransitionDefinition; @@ -2626,6 +2672,7 @@ export interface ExecutableActionObject { type: string; info: ActionArgs; params: NonReducibleUnknown; + args: unknown[]; exec: | ((info: ActionArgs, params: unknown) => void) | undefined; @@ -2683,3 +2730,45 @@ export type BuiltinActionResolution = [ NonReducibleUnknown, // params UnknownAction[] | undefined ]; + +export type EnqueueObj< + TEvent extends EventObject, + TEmittedEvent extends EventObject +> = { + cancel: (id: string) => void; + raise: (ev: TEvent, options?: { id?: string; delay?: number }) => void; + spawn: ( + logic: T, + options?: { + input?: InputFrom; + id?: string; + syncSnapshot?: boolean; + } + ) => AnyActorRef; + emit: (emittedEvent: TEmittedEvent) => void; + action: any>( + fn: T, + ...args: Parameters + ) => void; + log: (...args: any[]) => void; + sendTo: ( + actorRef: T, + event: EventFrom, + options?: { delay?: number } + ) => void; +}; + +export type Action2< + TContext extends MachineContext, + TEvent extends EventObject, + TEmittedEvent extends EventObject +> = ( + _: { + context: TContext; + event: TEvent; + parent: AnyActorRef | undefined; + self: AnyActorRef; + children: Record; + }, + enqueue: EnqueueObj +) => { context: TContext } | void; diff --git a/packages/core/src/types.v6.ts b/packages/core/src/types.v6.ts new file mode 100644 index 0000000000..823e502584 --- /dev/null +++ b/packages/core/src/types.v6.ts @@ -0,0 +1,236 @@ +import { StateNode } from './StateNode'; +import { + Action2, + DoNotInfer, + EventDescriptor, + EventObject, + ExtractEvent, + InitialContext, + MetaObject, + NonReducibleUnknown, + ParameterizedObject, + ProvidedActor, + TODO, + TransitionConfigFunction +} from './types'; +import { MachineContext, Mapper } from './types'; +import { LowInfer } from './types'; +import { DoneStateEvent } from './types'; + +export type Next_MachineConfig< + TContext extends MachineContext, + TEvent extends EventObject, + TDelay extends string = string, + TTag extends string = string, + TInput = any, + TOutput = unknown, + TEmitted extends EventObject = EventObject, + TMeta extends MetaObject = MetaObject +> = (Omit< + Next_StateNodeConfig< + DoNotInfer, + DoNotInfer, + DoNotInfer, + DoNotInfer, + DoNotInfer, + DoNotInfer, + DoNotInfer + >, + 'output' +> & { + /** The initial context (extended state) */ + /** The machine's own version. */ + version?: string; + // TODO: make it conditionally required + output?: Mapper | TOutput; +}) & + (MachineContext extends TContext + ? { context?: InitialContext, TODO, TInput, TEvent> } + : { context: InitialContext, TODO, TInput, TEvent> }); + +export interface Next_StateNodeConfig< + TContext extends MachineContext, + TEvent extends EventObject, + TDelay extends string, + TTag extends string, + _TOutput, + TEmitted extends EventObject, + TMeta extends MetaObject +> { + /** The initial state transition. */ + initial?: + | Next_InitialTransitionConfig + | TransitionConfigFunction + | string + | undefined; + /** + * The type of this state node: + * + * - `'atomic'` - no child state nodes + * - `'compound'` - nested child state nodes (XOR) + * - `'parallel'` - orthogonal nested child state nodes (AND) + * - `'history'` - history state node + * - `'final'` - final state node + */ + type?: 'atomic' | 'compound' | 'parallel' | 'final' | 'history'; + /** + * Indicates whether the state node is a history state node, and what type of + * history: shallow, deep, true (shallow), false (none), undefined (none) + */ + history?: 'shallow' | 'deep' | boolean | undefined; + /** + * The mapping of state node keys to their state node configurations + * (recursive). + */ + states?: { + [K in string]: Next_StateNodeConfig< + TContext, + TEvent, + TDelay, + TTag, + any, // TOutput, + TEmitted, + TMeta + >; + }; + /** + * The services to invoke upon entering this state node. These services will + * be stopped upon exiting this state node. + */ + invoke?: TODO; + /** The mapping of event types to their potential transition(s). */ + on?: { + [K in EventDescriptor]?: Next_TransitionConfigOrTarget< + TContext, + ExtractEvent, + TEvent, + TEmitted + >; + }; + entry2?: Action2; + exit2?: Action2; + /** + * The potential transition(s) to be taken upon reaching a final child state + * node. + * + * This is equivalent to defining a `[done(id)]` transition on this state + * node's `on` property. + */ + onDone?: + | string + | TransitionConfigFunction + | undefined; + /** + * The mapping (or array) of delays (in milliseconds) to their potential + * transition(s). The delayed transitions are taken after the specified delay + * in an interpreter. + */ + after?: { + [K in TDelay | number]?: + | string + | { target: string } + | TransitionConfigFunction< + TContext, + TEvent, + TEvent, + TODO // TEmitted + >; + }; + + /** + * An eventless transition that is always taken when this state node is + * active. + */ + always?: Next_TransitionConfigOrTarget; + parent?: StateNode; + /** + * The meta data associated with this state node, which will be returned in + * State instances. + */ + meta?: TMeta; + /** + * The output data sent with the "xstate.done.state._id_" event if this is a + * final state node. + * + * The output data will be evaluated with the current `context` and placed on + * the `.data` property of the event. + */ + output?: Mapper | NonReducibleUnknown; + /** + * The unique ID of the state node, which can be referenced as a transition + * target via the `#id` syntax. + */ + id?: string | undefined; + /** + * The order this state node appears. Corresponds to the implicit document + * order. + */ + order?: number; + + /** + * The tags for this state node, which are accumulated into the `state.tags` + * property. + */ + tags?: TTag[]; + /** A text description of the state node */ + description?: string; + + /** A default target for a history state */ + target?: string | undefined; // `| undefined` makes `HistoryStateNodeConfig` compatible with this interface (it extends it) under `exactOptionalPropertyTypes` +} + +export type Next_InitialTransitionConfig< + TContext extends MachineContext, + TEvent extends EventObject, + TEmitted extends EventObject +> = TransitionConfigFunction; + +export type Next_TransitionConfigOrTarget< + TContext extends MachineContext, + TExpressionEvent extends EventObject, + TEvent extends EventObject, + TEmitted extends EventObject +> = + | string + | undefined + | { target: string } + | TransitionConfigFunction; + +export interface Next_MachineTypes< + TContext extends MachineContext, + TEvent extends EventObject, + TDelay extends string, + TTag extends string, + TInput, + TOutput, + TEmitted extends EventObject, + TMeta extends MetaObject +> { + context?: TContext; + events?: TEvent; + children?: any; // TODO + tags?: TTag; + input?: TInput; + output?: TOutput; + emitted?: TEmitted; + delays?: TDelay; + meta?: TMeta; +} + +export interface Next_SetupTypes< + TContext extends MachineContext, + TEvent extends EventObject, + TTag extends string, + TInput, + TOutput, + TEmitted extends EventObject, + TMeta extends MetaObject +> { + context?: TContext; + events?: TEvent; + tags?: TTag; + input?: TInput; + output?: TOutput; + emitted?: TEmitted; + meta?: TMeta; +} diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index a7152562dc..7eba3615ba 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -8,6 +8,7 @@ import type { AnyMachineSnapshot, AnyStateMachine, AnyTransitionConfig, + AnyTransitionConfigFunction, ErrorActorEvent, EventObject, InvokeConfig, @@ -205,7 +206,9 @@ export function isErrorActorEvent( } export function toTransitionConfigArray( - configLike: SingleOrArray + configLike: SingleOrArray< + AnyTransitionConfig | TransitionConfigTarget | AnyTransitionConfigFunction + > ): Array { return toArrayStrict(configLike).map((transitionLike) => { if ( @@ -215,6 +218,10 @@ export function toTransitionConfigArray( return { target: transitionLike }; } + if (typeof transitionLike === 'function') { + return { fn: transitionLike }; + } + return transitionLike; }); } diff --git a/packages/core/test/actions.test.ts b/packages/core/test/actions.test.ts index 3a8213337b..ce281de5ea 100644 --- a/packages/core/test/actions.test.ts +++ b/packages/core/test/actions.test.ts @@ -3284,7 +3284,7 @@ describe('sendTo', () => { expect(console.warn).toMatchMockCallsInlineSnapshot(` [ [ - "Event "PING" was sent to stopped actor "myChild (x:113)". This actor has already reached its final state, and will not transition. + "Event "PING" was sent to stopped actor "myChild (x:1)". This actor has already reached its final state, and will not transition. Event: {"type":"PING"}", ], ] @@ -3357,7 +3357,7 @@ Event: {"type":"PING"}", expect(console.warn).toMatchMockCallsInlineSnapshot(` [ [ - "Event "PING" was sent to stopped actor "myChild (x:116)". This actor has already reached its final state, and will not transition. + "Event "PING" was sent to stopped actor "myChild (x:1)". This actor has already reached its final state, and will not transition. Event: {"type":"PING"}", ], ] diff --git a/packages/core/test/actions.v6.test.ts b/packages/core/test/actions.v6.test.ts new file mode 100644 index 0000000000..024f6637dc --- /dev/null +++ b/packages/core/test/actions.v6.test.ts @@ -0,0 +1,4338 @@ +import { sleep } from '@xstate-repo/jest-utils'; +import { + cancel, + emit, + enqueueActions, + log, + raise, + sendParent, + sendTo, + spawnChild, + stopChild +} from '../src/actions.ts'; +import { CallbackActorRef, fromCallback } from '../src/actors/callback.ts'; +import { + ActorRef, + ActorRefFromLogic, + AnyActorRef, + EventObject, + Snapshot, + assign, + createActor, + createMachine, + forwardTo, + setup +} from '../src/index.ts'; +import { trackEntries } from './utils.ts'; + +const originalConsoleLog = console.log; + +afterEach(() => { + console.log = originalConsoleLog; +}); + +describe('entry/exit actions', () => { + describe('State.actions', () => { + it('should return the entry actions of an initial state', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: {} + } + }); + const flushTracked = trackEntries(machine); + createActor(machine).start(); + + expect(flushTracked()).toEqual(['enter: __root__', 'enter: green']); + }); + + it('should return the entry actions of an initial state (deep)', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + NEXT: 'a2' + } + }, + a2: {} + }, + on: { CHANGE: 'b' } + }, + b: {} + } + }); + + const flushTracked = trackEntries(machine); + createActor(machine).start(); + + expect(flushTracked()).toEqual([ + 'enter: __root__', + 'enter: a', + 'enter: a.a1' + ]); + }); + + it('should return the entry actions of an initial state (parallel)', () => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'a1', + states: { + a1: {} + } + }, + b: { + initial: 'b1', + states: { + b1: {} + } + } + } + }); + + const flushTracked = trackEntries(machine); + createActor(machine).start(); + + expect(flushTracked()).toEqual([ + 'enter: __root__', + 'enter: a', + 'enter: a.a1', + 'enter: b', + 'enter: b.b1' + ]); + }); + + it('should return the entry and exit actions of a transition', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + on: { + TIMER: 'yellow' + } + }, + yellow: {} + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'TIMER' }); + + expect(flushTracked()).toEqual(['exit: green', 'enter: yellow']); + }); + + it('should return the entry and exit actions of a deep transition', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + on: { + TIMER: 'yellow' + } + }, + yellow: { + initial: 'speed_up', + states: { + speed_up: {} + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'TIMER' }); + + expect(flushTracked()).toEqual([ + 'exit: green', + 'enter: yellow', + 'enter: yellow.speed_up' + ]); + }); + + it('should return the entry and exit actions of a nested transition', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + initial: 'walk', + states: { + walk: { + on: { + PED_COUNTDOWN: 'wait' + } + }, + wait: {} + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'PED_COUNTDOWN' }); + + expect(flushTracked()).toEqual(['exit: green.walk', 'enter: green.wait']); + }); + + it('should not have actions for unhandled events (shallow)', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: {} + } + }); + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'FAKE' }); + + expect(flushTracked()).toEqual([]); + }); + + it('should not have actions for unhandled events (deep)', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + initial: 'walk', + states: { + walk: {}, + wait: {}, + stop: {} + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'FAKE' }); + + expect(flushTracked()).toEqual([]); + }); + + it('should exit and enter the state for reentering self-transitions (shallow)', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + on: { + RESTART: { + target: 'green', + reenter: true + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'RESTART' }); + + expect(flushTracked()).toEqual(['exit: green', 'enter: green']); + }); + + it('should exit and enter the state for reentering self-transitions (deep)', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + on: { + RESTART: { + target: 'green', + reenter: true + } + }, + initial: 'walk', + states: { + walk: {}, + wait: {}, + stop: {} + } + } + } + }); + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + + flushTracked(); + actor.send({ type: 'RESTART' }); + + expect(flushTracked()).toEqual([ + 'exit: green.walk', + 'exit: green', + 'enter: green', + 'enter: green.walk' + ]); + }); + + it('should return actions for parallel machines', () => { + const actual: string[] = []; + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + CHANGE: { + target: 'a2', + actions: [ + () => actual.push('do_a2'), + () => actual.push('another_do_a2') + ] + } + }, + entry: () => actual.push('enter_a1'), + exit: () => actual.push('exit_a1') + }, + a2: { + entry: () => actual.push('enter_a2'), + exit: () => actual.push('exit_a2') + } + }, + entry: () => actual.push('enter_a'), + exit: () => actual.push('exit_a') + }, + b: { + initial: 'b1', + states: { + b1: { + on: { + CHANGE: { target: 'b2', actions: () => actual.push('do_b2') } + }, + entry: () => actual.push('enter_b1'), + exit: () => actual.push('exit_b1') + }, + b2: { + entry: () => actual.push('enter_b2'), + exit: () => actual.push('exit_b2') + } + }, + entry: () => actual.push('enter_b'), + exit: () => actual.push('exit_b') + } + } + }); + + const actor = createActor(machine).start(); + actual.length = 0; + + actor.send({ type: 'CHANGE' }); + + expect(actual).toEqual([ + 'exit_b1', // reverse document order + 'exit_a1', + 'do_a2', + 'another_do_a2', + 'do_b2', + 'enter_a2', + 'enter_b2' + ]); + }); + + it('should return nested actions in the correct (child to parent) order', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + initial: 'a1', + states: { + a1: {} + }, + on: { CHANGE: 'b' } + }, + b: { + initial: 'b1', + states: { + b1: {} + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + + flushTracked(); + actor.send({ type: 'CHANGE' }); + + expect(flushTracked()).toEqual([ + 'exit: a.a1', + 'exit: a', + 'enter: b', + 'enter: b.b1' + ]); + }); + + it('should ignore parent state actions for same-parent substates', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + NEXT: 'a2' + } + }, + a2: {} + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + + flushTracked(); + actor.send({ type: 'NEXT' }); + + expect(flushTracked()).toEqual(['exit: a.a1', 'enter: a.a2']); + }); + + it('should work with function actions', () => { + const entrySpy = jest.fn(); + const exitSpy = jest.fn(); + const transitionSpy = jest.fn(); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + NEXT_FN: 'a3' + } + }, + a2: {}, + a3: { + on: { + NEXT: { + target: 'a2', + actions: [transitionSpy] + } + }, + entry: entrySpy, + exit: exitSpy + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'NEXT_FN' }); + + expect(flushTracked()).toEqual(['exit: a.a1', 'enter: a.a3']); + expect(entrySpy).toHaveBeenCalled(); + + actor.send({ type: 'NEXT' }); + + expect(flushTracked()).toEqual(['exit: a.a3', 'enter: a.a2']); + expect(exitSpy).toHaveBeenCalled(); + expect(transitionSpy).toHaveBeenCalled(); + }); + + it('should exit children of parallel state nodes', () => { + const machine = createMachine({ + initial: 'B', + states: { + A: { + on: { + 'to-B': 'B' + } + }, + B: { + type: 'parallel', + on: { + 'to-A': 'A' + }, + states: { + C: { + initial: 'C1', + states: { + C1: {} + } + }, + D: { + initial: 'D1', + states: { + D1: {} + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + + flushTracked(); + actor.send({ type: 'to-A' }); + + expect(flushTracked()).toEqual([ + 'exit: B.D.D1', + 'exit: B.D', + 'exit: B.C.C1', + 'exit: B.C', + 'exit: B', + 'enter: A' + ]); + }); + + it("should reenter targeted ancestor (as it's a descendant of the transition domain)", () => { + const machine = createMachine({ + initial: 'loaded', + states: { + loaded: { + id: 'loaded', + initial: 'idle', + states: { + idle: { + on: { + UPDATE: '#loaded' + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + + flushTracked(); + actor.send({ type: 'UPDATE' }); + + expect(flushTracked()).toEqual([ + 'exit: loaded.idle', + 'exit: loaded', + 'enter: loaded', + 'enter: loaded.idle' + ]); + }); + + it("shouldn't use a referenced custom action over a builtin one when there is a naming conflict", () => { + const spy = jest.fn(); + const machine = createMachine( + { + context: { + assigned: false + }, + on: { + EV: { + actions: assign({ assigned: true }) + } + } + }, + { + actions: { + 'xstate.assign': spy + } + } + ); + + const actor = createActor(machine).start(); + actor.send({ type: 'EV' }); + + expect(spy).not.toHaveBeenCalled(); + expect(actor.getSnapshot().context.assigned).toBe(true); + }); + + it("shouldn't use a referenced custom action over an inline one when there is a naming conflict", () => { + const spy = jest.fn(); + let called = false; + + const machine = createMachine( + { + on: { + EV: { + // it's important for this test to use a named function + actions: function myFn() { + called = true; + } + } + } + }, + { + actions: { + myFn: spy + } + } + ); + + const actor = createActor(machine).start(); + actor.send({ type: 'EV' }); + + expect(spy).not.toHaveBeenCalled(); + expect(called).toBe(true); + }); + + it('root entry/exit actions should be called on root reentering transitions', () => { + let entrySpy = jest.fn(); + let exitSpy = jest.fn(); + + const machine = createMachine({ + id: 'root', + entry: entrySpy, + exit: exitSpy, + on: { + EVENT: { + target: '#two', + reenter: true + } + }, + initial: 'one', + states: { + one: {}, + two: { + id: 'two' + } + } + }); + + const service = createActor(machine).start(); + + entrySpy.mockClear(); + exitSpy.mockClear(); + + service.send({ type: 'EVENT' }); + + expect(entrySpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalled(); + }); + + describe('should ignore same-parent state actions (sparse)', () => { + it('with a relative transition', () => { + const machine = createMachine({ + initial: 'ping', + states: { + ping: { + initial: 'foo', + states: { + foo: { + on: { + TACK: 'bar' + } + }, + bar: {} + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'TACK' }); + + expect(flushTracked()).toEqual(['exit: ping.foo', 'enter: ping.bar']); + }); + + it('with an absolute transition', () => { + const machine = createMachine({ + id: 'root', + initial: 'ping', + states: { + ping: { + initial: 'foo', + states: { + foo: { + on: { + ABSOLUTE_TACK: '#root.ping.bar' + } + }, + bar: {} + } + }, + pong: {} + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'ABSOLUTE_TACK' }); + + expect(flushTracked()).toEqual(['exit: ping.foo', 'enter: ping.bar']); + }); + }); + }); + + describe('entry/exit actions', () => { + it('should return the entry actions of an initial state', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: {} + } + }); + const flushTracked = trackEntries(machine); + createActor(machine).start(); + + expect(flushTracked()).toEqual(['enter: __root__', 'enter: green']); + }); + + it('should return the entry and exit actions of a transition', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + on: { + TIMER: 'yellow' + } + }, + yellow: {} + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'TIMER' }); + + expect(flushTracked()).toEqual(['exit: green', 'enter: yellow']); + }); + + it('should return the entry and exit actions of a deep transition', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + on: { + TIMER: 'yellow' + } + }, + yellow: { + initial: 'speed_up', + states: { + speed_up: {} + } + } + } + }); + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'TIMER' }); + + expect(flushTracked()).toEqual([ + 'exit: green', + 'enter: yellow', + 'enter: yellow.speed_up' + ]); + }); + + it('should return the entry and exit actions of a nested transition', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + initial: 'walk', + states: { + walk: { + on: { + PED_COUNTDOWN: 'wait' + } + }, + wait: {} + } + } + } + }); + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'PED_COUNTDOWN' }); + + expect(flushTracked()).toEqual(['exit: green.walk', 'enter: green.wait']); + }); + + it('should keep the same state for unhandled events (shallow)', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: {} + } + }); + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'FAKE' }); + + expect(flushTracked()).toEqual([]); + }); + + it('should keep the same state for unhandled events (deep)', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + initial: 'walk', + states: { + walk: {} + } + } + } + }); + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'FAKE' }); + + expect(flushTracked()).toEqual([]); + }); + + it('should exit and enter the state for reentering self-transitions (shallow)', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + on: { + RESTART: { + target: 'green', + reenter: true + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'RESTART' }); + + expect(flushTracked()).toEqual(['exit: green', 'enter: green']); + }); + + it('should exit and enter the state for reentering self-transitions (deep)', () => { + const machine = createMachine({ + initial: 'green', + states: { + green: { + on: { + RESTART: { + target: 'green', + reenter: true + } + }, + initial: 'walk', + states: { + walk: {} + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'RESTART' }); + expect(flushTracked()).toEqual([ + 'exit: green.walk', + 'exit: green', + 'enter: green', + 'enter: green.walk' + ]); + }); + + it('should exit current node and enter target node when target is not a descendent or ancestor of current', () => { + const machine = createMachine({ + initial: 'A', + states: { + A: { + initial: 'A1', + states: { + A1: { + on: { + NEXT: '#sibling_descendant' + } + }, + A2: { + initial: 'A2_child', + states: { + A2_child: { + id: 'sibling_descendant' + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const service = createActor(machine).start(); + flushTracked(); + service.send({ type: 'NEXT' }); + + expect(flushTracked()).toEqual([ + 'exit: A.A1', + 'enter: A.A2', + 'enter: A.A2.A2_child' + ]); + }); + + it('should exit current node and reenter target node when target is ancestor of current', () => { + const machine = createMachine({ + initial: 'A', + states: { + A: { + id: 'ancestor', + initial: 'A1', + states: { + A1: { + on: { + NEXT: 'A2' + } + }, + A2: { + initial: 'A2_child', + states: { + A2_child: { + on: { + NEXT: '#ancestor' + } + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + + flushTracked(); + service.send({ type: 'NEXT' }); + + expect(flushTracked()).toEqual([ + 'exit: A.A2.A2_child', + 'exit: A.A2', + 'exit: A', + 'enter: A', + 'enter: A.A1' + ]); + }); + + it('should enter all descendents when target is a descendent of the source when using an reentering transition', () => { + const machine = createMachine({ + initial: 'A', + states: { + A: { + initial: 'A1', + on: { + NEXT: { + reenter: true, + target: '.A2' + } + }, + states: { + A1: {}, + A2: { + initial: 'A2a', + states: { + A2a: {} + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const service = createActor(machine).start(); + flushTracked(); + service.send({ type: 'NEXT' }); + + expect(flushTracked()).toEqual([ + 'exit: A.A1', + 'exit: A', + 'enter: A', + 'enter: A.A2', + 'enter: A.A2.A2a' + ]); + }); + + it('should exit deep descendant during a default self-transition', () => { + const m = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: 'a' + }, + initial: 'a1', + states: { + a1: { + initial: 'a11', + states: { + a11: {} + } + } + } + } + } + }); + + const flushTracked = trackEntries(m); + + const service = createActor(m).start(); + + flushTracked(); + service.send({ type: 'EV' }); + + expect(flushTracked()).toEqual([ + 'exit: a.a1.a11', + 'exit: a.a1', + 'enter: a.a1', + 'enter: a.a1.a11' + ]); + }); + + it('should exit deep descendant during a reentering self-transition', () => { + const m = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: { + target: 'a', + reenter: true + } + }, + initial: 'a1', + states: { + a1: { + initial: 'a11', + states: { + a11: {} + } + } + } + } + } + }); + + const flushTracked = trackEntries(m); + + const service = createActor(m).start(); + + flushTracked(); + service.send({ type: 'EV' }); + + expect(flushTracked()).toEqual([ + 'exit: a.a1.a11', + 'exit: a.a1', + 'exit: a', + 'enter: a', + 'enter: a.a1', + 'enter: a.a1.a11' + ]); + }); + + it('should not reenter leaf state during its default self-transition', () => { + const m = createMachine({ + initial: 'a', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + EV: 'a1' + } + } + } + } + } + }); + + const flushTracked = trackEntries(m); + + const service = createActor(m).start(); + + flushTracked(); + service.send({ type: 'EV' }); + + expect(flushTracked()).toEqual([]); + }); + + it('should reenter leaf state during its reentering self-transition', () => { + const m = createMachine({ + initial: 'a', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + EV: { + target: 'a1', + reenter: true + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(m); + + const service = createActor(m).start(); + + flushTracked(); + service.send({ type: 'EV' }); + + expect(flushTracked()).toEqual(['exit: a.a1', 'enter: a.a1']); + }); + + it('should not enter exited state when targeting its ancestor and when its former descendant gets selected through initial state', () => { + const m = createMachine({ + initial: 'a', + states: { + a: { + id: 'parent', + initial: 'a1', + states: { + a1: { + on: { + EV: 'a2' + } + }, + a2: { + on: { + EV: '#parent' + } + } + } + } + } + }); + + const flushTracked = trackEntries(m); + + const service = createActor(m).start(); + service.send({ type: 'EV' }); + + flushTracked(); + service.send({ type: 'EV' }); + + expect(flushTracked()).toEqual([ + 'exit: a.a2', + 'exit: a', + 'enter: a', + 'enter: a.a1' + ]); + }); + + it('should not enter exited state when targeting its ancestor and when its latter descendant gets selected through initial state', () => { + const m = createMachine({ + initial: 'a', + states: { + a: { + id: 'parent', + initial: 'a2', + states: { + a1: { + on: { + EV: '#parent' + } + }, + a2: { + on: { + EV: 'a1' + } + } + } + } + } + }); + + const flushTracked = trackEntries(m); + + const service = createActor(m).start(); + service.send({ type: 'EV' }); + + flushTracked(); + service.send({ type: 'EV' }); + + expect(flushTracked()).toEqual([ + 'exit: a.a1', + 'exit: a', + 'enter: a', + 'enter: a.a2' + ]); + }); + }); + + describe('parallel states', () => { + it('should return entry action defined on parallel state', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { ENTER_PARALLEL: 'p1' } + }, + p1: { + type: 'parallel', + states: { + nested: { + initial: 'inner', + states: { + inner: {} + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + + flushTracked(); + actor.send({ type: 'ENTER_PARALLEL' }); + + expect(flushTracked()).toEqual([ + 'exit: start', + 'enter: p1', + 'enter: p1.nested', + 'enter: p1.nested.inner' + ]); + }); + + it('should reenter parallel region when a parallel state gets reentered while targeting another region', () => { + const machine = createMachine({ + initial: 'ready', + states: { + ready: { + type: 'parallel', + on: { + FOO: { + target: '#cameraOff', + reenter: true + } + }, + states: { + devicesInfo: {}, + camera: { + initial: 'on', + states: { + on: {}, + off: { + id: 'cameraOff' + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const service = createActor(machine).start(); + + flushTracked(); + service.send({ type: 'FOO' }); + + expect(flushTracked()).toEqual([ + 'exit: ready.camera.on', + 'exit: ready.camera', + 'exit: ready.devicesInfo', + 'exit: ready', + 'enter: ready', + 'enter: ready.devicesInfo', + 'enter: ready.camera', + 'enter: ready.camera.off' + ]); + }); + + it('should reenter parallel region when a parallel state is reentered while targeting another region', () => { + const machine = createMachine({ + initial: 'ready', + states: { + ready: { + type: 'parallel', + on: { + FOO: { + target: '#cameraOff', + reenter: true + } + }, + states: { + devicesInfo: {}, + camera: { + initial: 'on', + states: { + on: {}, + off: { + id: 'cameraOff' + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const service = createActor(machine).start(); + + flushTracked(); + service.send({ type: 'FOO' }); + + expect(flushTracked()).toEqual([ + 'exit: ready.camera.on', + 'exit: ready.camera', + 'exit: ready.devicesInfo', + 'exit: ready', + 'enter: ready', + 'enter: ready.devicesInfo', + 'enter: ready.camera', + 'enter: ready.camera.off' + ]); + }); + }); + + describe('targetless transitions', () => { + it("shouldn't exit a state on a parent's targetless transition", () => { + const parent = createMachine({ + initial: 'one', + on: { + WHATEVER: { + actions: () => {} + } + }, + states: { + one: {} + } + }); + + const flushTracked = trackEntries(parent); + + const service = createActor(parent).start(); + + flushTracked(); + service.send({ type: 'WHATEVER' }); + + expect(flushTracked()).toEqual([]); + }); + + it("shouldn't exit (and reenter) state on targetless delayed transition", (done) => { + const machine = createMachine({ + initial: 'one', + states: { + one: { + after: { + 10: { + actions: () => { + // do smth + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + createActor(machine).start(); + flushTracked(); + + setTimeout(() => { + expect(flushTracked()).toEqual([]); + done(); + }, 50); + }); + }); + + describe('when reaching a final state', () => { + // https://github.com/statelyai/xstate/issues/1109 + it('exit actions should be called when invoked machine reaches its final state', (done) => { + let exitCalled = false; + let childExitCalled = false; + const childMachine = createMachine({ + exit: () => { + exitCalled = true; + }, + initial: 'a', + states: { + a: { + type: 'final', + exit: () => { + childExitCalled = true; + } + } + } + }); + + const parentMachine = createMachine({ + initial: 'active', + states: { + active: { + invoke: { + src: childMachine, + onDone: 'finished' + } + }, + finished: { + type: 'final' + } + } + }); + + const actor = createActor(parentMachine); + actor.subscribe({ + complete: () => { + expect(exitCalled).toBeTruthy(); + expect(childExitCalled).toBeTruthy(); + done(); + } + }); + actor.start(); + }); + }); + + describe('when stopped', () => { + it('exit actions should not be called when stopping a machine', () => { + const rootSpy = jest.fn(); + const childSpy = jest.fn(); + + const machine = createMachine({ + exit: rootSpy, + initial: 'a', + states: { + a: { + exit: childSpy + } + } + }); + + const service = createActor(machine).start(); + service.stop(); + + expect(rootSpy).not.toHaveBeenCalled(); + expect(childSpy).not.toHaveBeenCalled(); + }); + + it('an exit action executed when an interpreter reaches its final state should be called with the last received event', () => { + let receivedEvent; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + type: 'final' + } + }, + exit: ({ event }) => { + receivedEvent = event; + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + + expect(receivedEvent).toEqual({ type: 'NEXT' }); + }); + + // https://github.com/statelyai/xstate/issues/2880 + it('stopping an interpreter that receives events from its children exit handlers should not throw', () => { + const child = createMachine({ + id: 'child', + initial: 'idle', + states: { + idle: { + exit: sendParent({ type: 'EXIT' }) + } + } + }); + + const parent = createMachine({ + id: 'parent', + invoke: { + src: child + } + }); + + const interpreter = createActor(parent); + interpreter.start(); + + expect(() => interpreter.stop()).not.toThrow(); + }); + + // TODO: determine if the sendParent action should execute when the child actor is stopped. + // If it shouldn't be, we need to clarify whether exit actions in general should be executed on machine stop, + // since this is contradictory to other tests. + it.skip('sent events from exit handlers of a stopped child should not be received by the parent', () => { + const child = createMachine({ + id: 'child', + initial: 'idle', + states: { + idle: { + exit: sendParent({ type: 'EXIT' }) + } + } + }); + + const parent = createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + }; + }, + id: 'parent', + context: ({ spawn }) => ({ + child: spawn(child) + }), + on: { + STOP_CHILD: { + actions: stopChild(({ context }) => context.child) + }, + EXIT: { + actions: () => { + throw new Error('This should not be called.'); + } + } + } + }); + + const interpreter = createActor(parent).start(); + interpreter.send({ type: 'STOP_CHILD' }); + }); + + it('sent events from exit handlers of a done child should be received by the parent ', () => { + let eventReceived = false; + + const child = createMachine({ + id: 'child', + initial: 'active', + states: { + active: { + on: { + FINISH: 'done' + } + }, + done: { + type: 'final' + } + }, + exit: sendParent({ type: 'CHILD_DONE' }) + }); + + const parent = createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + }; + }, + id: 'parent', + context: ({ spawn }) => ({ + child: spawn(child) + }), + on: { + FINISH_CHILD: { + actions: sendTo(({ context }) => context.child, { type: 'FINISH' }) + }, + CHILD_DONE: { + actions: () => { + eventReceived = true; + } + } + } + }); + + const interpreter = createActor(parent).start(); + interpreter.send({ type: 'FINISH_CHILD' }); + + expect(eventReceived).toBe(true); + }); + + it('sent events from exit handlers of a stopped child should not be received by its children', () => { + const spy = jest.fn(); + + const grandchild = createMachine({ + id: 'grandchild', + on: { + STOPPED: { + actions: spy + } + } + }); + + const child = createMachine({ + id: 'child', + invoke: { + id: 'myChild', + src: grandchild + }, + exit: sendTo('myChild', { type: 'STOPPED' }) + }); + + const parent = createMachine({ + id: 'parent', + initial: 'a', + states: { + a: { + invoke: { + src: child + }, + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + const interpreter = createActor(parent).start(); + interpreter.send({ type: 'NEXT' }); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('sent events from exit handlers of a done child should be received by its children', () => { + const spy = jest.fn(); + + const grandchild = createMachine({ + id: 'grandchild', + on: { + STOPPED: { + actions: spy + } + } + }); + + const child = createMachine({ + id: 'child', + initial: 'a', + invoke: { + id: 'myChild', + src: grandchild + }, + states: { + a: { + on: { + FINISH: 'b' + } + }, + b: { + type: 'final' + } + }, + exit: sendTo('myChild', { type: 'STOPPED' }) + }); + + const parent = createMachine({ + id: 'parent', + invoke: { + id: 'myChild', + src: child + }, + on: { + NEXT: { + actions: sendTo('myChild', { type: 'FINISH' }) + } + } + }); + + const interpreter = createActor(parent).start(); + interpreter.send({ type: 'NEXT' }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('actors spawned in exit handlers of a stopped child should not be started', () => { + const grandchild = createMachine({ + id: 'grandchild', + entry: () => { + throw new Error('This should not be called.'); + } + }); + + const parent = createMachine({ + id: 'parent', + context: {}, + exit: assign({ + actorRef: ({ spawn }) => spawn(grandchild) + }) + }); + + const interpreter = createActor(parent).start(); + interpreter.stop(); + }); + + it('should note execute referenced custom actions correctly when stopping an interpreter', () => { + const spy = jest.fn(); + const parent = createMachine( + { + id: 'parent', + context: {}, + exit: 'referencedAction' + }, + { + actions: { + referencedAction: spy + } + } + ); + + const interpreter = createActor(parent).start(); + interpreter.stop(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not execute builtin actions when stopping an interpreter', () => { + const machine = createMachine( + { + context: { + executedAssigns: [] as string[] + }, + exit: [ + 'referencedAction', + assign({ + executedAssigns: ({ context }) => [ + ...context.executedAssigns, + 'inline' + ] + }) + ] + }, + { + actions: { + referencedAction: assign({ + executedAssigns: ({ context }) => [ + ...context.executedAssigns, + 'referenced' + ] + }) + } + } + ); + + const interpreter = createActor(machine).start(); + interpreter.stop(); + + expect(interpreter.getSnapshot().context.executedAssigns).toEqual([]); + }); + + it('should clear all scheduled events when the interpreter gets stopped', () => { + const machine = createMachine({ + on: { + INITIALIZE_SYNC_SEQUENCE: { + actions: () => { + // schedule those 2 events + service.send({ type: 'SOME_EVENT' }); + service.send({ type: 'SOME_EVENT' }); + // but also immediately stop *while* the `INITIALIZE_SYNC_SEQUENCE` is still being processed + service.stop(); + } + }, + SOME_EVENT: { + actions: () => { + throw new Error('This should not be called.'); + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'INITIALIZE_SYNC_SEQUENCE' }); + }); + + it('should execute exit actions of the settled state of the last initiated microstep', () => { + const exitActions: string[] = []; + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + exit: () => { + exitActions.push('foo action'); + }, + on: { + INITIALIZE_SYNC_SEQUENCE: { + target: 'bar', + actions: [ + () => { + // immediately stop *while* the `INITIALIZE_SYNC_SEQUENCE` is still being processed + service.stop(); + }, + () => {} + ] + } + } + }, + bar: { + exit: () => { + exitActions.push('bar action'); + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'INITIALIZE_SYNC_SEQUENCE' }); + + expect(exitActions).toEqual(['foo action']); + }); + + it('should not execute exit actions of the settled state of the last initiated microstep after executing all actions from that microstep', () => { + const executedActions: string[] = []; + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + exit: () => { + executedActions.push('foo exit action'); + }, + on: { + INITIALIZE_SYNC_SEQUENCE: { + target: 'bar', + actions: [ + () => { + // immediately stop *while* the `INITIALIZE_SYNC_SEQUENCE` is still being processed + service.stop(); + }, + () => { + executedActions.push('foo transition action'); + } + ] + } + } + }, + bar: { + exit: () => { + executedActions.push('bar exit action'); + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'INITIALIZE_SYNC_SEQUENCE' }); + + expect(executedActions).toEqual([ + 'foo exit action', + 'foo transition action' + ]); + }); + }); +}); + +describe('initial actions', () => { + it('should support initial actions', () => { + const actual: string[] = []; + const machine = createMachine({ + initial: { + target: 'a', + actions: () => actual.push('initialA') + }, + states: { + a: { + entry: () => actual.push('entryA') + } + } + }); + createActor(machine).start(); + expect(actual).toEqual(['initialA', 'entryA']); + }); + + it('should support initial actions from transition', () => { + const actual: string[] = []; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + entry: () => actual.push('entryB'), + initial: { + target: 'foo', + actions: () => actual.push('initialFoo') + }, + states: { + foo: { + entry: () => actual.push('entryFoo') + } + } + } + } + }); + + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + + expect(actual).toEqual(['entryB', 'initialFoo', 'entryFoo']); + }); + + it('should execute actions of initial transitions only once when taking an explicit transition', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + initial: { + target: 'b_child', + actions: () => spy('initial in b') + }, + states: { + b_child: { + initial: { + target: 'b_granchild', + actions: () => spy('initial in b_child') + }, + states: { + b_granchild: {} + } + } + } + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ + type: 'NEXT' + }); + + expect(spy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "initial in b", + ], + [ + "initial in b_child", + ], + ] + `); + }); + + it('should execute actions of all initial transitions resolving to the initial state value', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: { + target: 'a', + actions: () => spy('root') + }, + states: { + a: { + initial: { + target: 'a1', + actions: () => spy('inner') + }, + states: { + a1: {} + } + } + } + }); + + createActor(machine).start(); + + expect(spy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "root", + ], + [ + "inner", + ], + ] + `); + }); + + it('should execute actions of the initial transition when taking a root reentering self-transition', () => { + const spy = jest.fn(); + const machine = createMachine({ + id: 'root', + initial: { + target: 'a', + actions: spy + }, + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: {} + }, + on: { + REENTER: { + target: '#root', + reenter: true + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'NEXT' }); + spy.mockClear(); + + actorRef.send({ type: 'REENTER' }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(actorRef.getSnapshot().value).toEqual('a'); + }); +}); + +describe('actions on invalid transition', () => { + it('should not recall previous actions', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + STOP: { + target: 'stop', + actions: [spy] + } + } + }, + stop: {} + } + }); + const actor = createActor(machine).start(); + + actor.send({ type: 'STOP' }); + expect(spy).toHaveBeenCalledTimes(1); + + actor.send({ type: 'INVALID' }); + expect(spy).toHaveBeenCalledTimes(1); + }); +}); + +describe('actions config', () => { + type EventType = + | { type: 'definedAction' } + | { type: 'updateContext' } + | { type: 'EVENT' } + | { type: 'E' }; + interface Context { + count: number; + } + + const definedAction = () => {}; + + it('should reference actions defined in actions parameter of machine options (entry actions)', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: 'b' + } + }, + b: { + entry: ['definedAction', { type: 'definedAction' }, 'undefinedAction'] + } + }, + on: { + E: '.a' + } + }).provide({ + actions: { + definedAction: spy + } + }); + + const actor = createActor(machine).start(); + actor.send({ type: 'EVENT' }); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should reference actions defined in actions parameter of machine options (initial state)', () => { + const spy = jest.fn(); + const machine = createMachine( + { + entry: ['definedAction', { type: 'definedAction' }, 'undefinedAction'] + }, + { + actions: { + definedAction: spy + } + } + ); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should be able to reference action implementations from action objects', () => { + const machine = createMachine( + { + types: {} as { context: Context; events: EventType }, + initial: 'a', + context: { + count: 0 + }, + states: { + a: { + entry: [ + 'definedAction', + { type: 'definedAction' }, + 'undefinedAction' + ], + on: { + EVENT: { + target: 'b', + actions: [{ type: 'definedAction' }, { type: 'updateContext' }] + } + } + }, + b: {} + } + }, + { + actions: { + definedAction, + updateContext: assign({ count: 10 }) + } + } + ); + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + const snapshot = actorRef.getSnapshot(); + + // expect(snapshot.actions).toEqual([ + // expect.objectContaining({ + // type: 'definedAction' + // }), + // expect.objectContaining({ + // type: 'updateContext' + // }) + // ]); + // TODO: specify which actions other actions came from + + expect(snapshot.context).toEqual({ count: 10 }); + }); + + it('should work with anonymous functions (with warning)', () => { + let entryCalled = false; + let actionCalled = false; + let exitCalled = false; + + const anonMachine = createMachine({ + id: 'anon', + initial: 'active', + states: { + active: { + entry: () => (entryCalled = true), + exit: () => (exitCalled = true), + on: { + EVENT: { + target: 'inactive', + actions: [() => (actionCalled = true)] + } + } + }, + inactive: {} + } + }); + + const actor = createActor(anonMachine).start(); + + expect(entryCalled).toBe(true); + + actor.send({ type: 'EVENT' }); + + expect(exitCalled).toBe(true); + expect(actionCalled).toBe(true); + }); +}); + +describe('action meta', () => { + it('should provide the original params', () => { + const spy = jest.fn(); + + const testMachine = createMachine( + { + id: 'test', + initial: 'foo', + states: { + foo: { + entry: { + type: 'entryAction', + params: { + value: 'something' + } + } + } + } + }, + { + actions: { + entryAction: (_, params) => { + spy(params); + } + } + } + ); + + createActor(testMachine).start(); + + expect(spy).toHaveBeenCalledWith({ + value: 'something' + }); + }); + + it('should provide undefined params when it was configured as string', () => { + const spy = jest.fn(); + + const testMachine = createMachine( + { + id: 'test', + initial: 'foo', + states: { + foo: { + entry: 'entryAction' + } + } + }, + { + actions: { + entryAction: (_, params) => { + spy(params); + } + } + } + ); + + createActor(testMachine).start(); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should provide the action with resolved params when they are dynamic', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + entry: { + type: 'entryAction', + params: () => ({ stuff: 100 }) + } + }, + { + actions: { + entryAction: (_, params) => { + spy(params); + } + } + } + ); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledWith({ + stuff: 100 + }); + }); + + it('should resolve dynamic params using context value', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + context: { + secret: 42 + }, + entry: { + type: 'entryAction', + params: ({ context }) => ({ secret: context.secret }) + } + }, + { + actions: { + entryAction: (_, params) => { + spy(params); + } + } + } + ); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledWith({ + secret: 42 + }); + }); + + it('should resolve dynamic params using event value', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + on: { + FOO: { + actions: { + type: 'myAction', + params: ({ event }) => ({ secret: event.secret }) + } + } + } + }, + { + actions: { + myAction: (_, params) => { + spy(params); + } + } + } + ); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'FOO', secret: 77 }); + + expect(spy).toHaveBeenCalledWith({ + secret: 77 + }); + }); +}); + +describe('forwardTo()', () => { + it('should forward an event to a service', (done) => { + const child = createMachine({ + types: {} as { + events: { + type: 'EVENT'; + value: number; + }; + }, + id: 'child', + initial: 'active', + states: { + active: { + on: { + EVENT: { + actions: sendParent({ type: 'SUCCESS' }), + guard: ({ event }) => event.value === 42 + } + } + } + } + }); + + const parent = createMachine({ + types: {} as { + events: + | { + type: 'EVENT'; + value: number; + } + | { + type: 'SUCCESS'; + }; + }, + id: 'parent', + initial: 'first', + states: { + first: { + invoke: { src: child, id: 'myChild' }, + on: { + EVENT: { + actions: forwardTo('myChild') + }, + SUCCESS: 'last' + } + }, + last: { + type: 'final' + } + } + }); + + const service = createActor(parent); + service.subscribe({ complete: () => done() }); + service.start(); + + service.send({ type: 'EVENT', value: 42 }); + }); + + it('should forward an event to a service (dynamic)', (done) => { + const child = createMachine({ + types: {} as { + events: { + type: 'EVENT'; + value: number; + }; + }, + id: 'child', + initial: 'active', + states: { + active: { + on: { + EVENT: { + actions: sendParent({ type: 'SUCCESS' }), + guard: ({ event }) => event.value === 42 + } + } + } + } + }); + + const parent = createMachine({ + types: {} as { + context: { child?: AnyActorRef }; + events: { type: 'EVENT'; value: number } | { type: 'SUCCESS' }; + }, + id: 'parent', + initial: 'first', + context: { + child: undefined + }, + states: { + first: { + entry: assign({ + child: ({ spawn }) => spawn(child, { id: 'x' }) + }), + on: { + EVENT: { + actions: forwardTo(({ context }) => context.child!) + }, + SUCCESS: 'last' + } + }, + last: { + type: 'final' + } + } + }); + + const service = createActor(parent); + service.subscribe({ complete: () => done() }); + service.start(); + + service.send({ type: 'EVENT', value: 42 }); + }); + + it('should not cause an infinite loop when forwarding to undefined', () => { + const machine = createMachine({ + on: { + '*': { guard: () => true, actions: forwardTo(undefined as any) } + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + actorRef.send({ type: 'TEST' }); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Attempted to forward event to undefined actor. This risks an infinite loop in the sender.], + ], + ] + `); + }); +}); + +describe('log()', () => { + it('should log a string', () => { + const consoleSpy = jest.fn(); + console.log = consoleSpy; + const machine = createMachine({ + entry: log('some string', 'string label') + }); + createActor(machine, { logger: consoleSpy }).start(); + + expect(consoleSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "string label", + "some string", + ], + ] + `); + }); + + it('should log an expression', () => { + const consoleSpy = jest.fn(); + console.log = consoleSpy; + const machine = createMachine({ + context: { + count: 42 + }, + entry: log(({ context }) => `expr ${context.count}`, 'expr label') + }); + createActor(machine, { logger: consoleSpy }).start(); + + expect(consoleSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "expr label", + "expr 42", + ], + ] + `); + }); +}); + +describe('enqueueActions', () => { + it('should execute a simple referenced action', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + entry: enqueueActions(({ enqueue }) => { + enqueue('someAction'); + }) + }, + { + actions: { + someAction: spy + } + } + ); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should execute multiple different referenced actions', () => { + const spy1 = jest.fn(); + const spy2 = jest.fn(); + + const machine = createMachine( + { + entry: enqueueActions(({ enqueue }) => { + enqueue('someAction'); + enqueue('otherAction'); + }) + }, + { + actions: { + someAction: spy1, + otherAction: spy2 + } + } + ); + + createActor(machine).start(); + + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy2).toHaveBeenCalledTimes(1); + }); + + it('should execute multiple same referenced actions', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + entry: enqueueActions(({ enqueue }) => { + enqueue('someAction'); + enqueue('someAction'); + }) + }, + { + actions: { + someAction: spy + } + } + ); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should execute a parameterized action', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + entry: enqueueActions(({ enqueue }) => { + enqueue({ + type: 'someAction', + params: { answer: 42 } + }); + }) + }, + { + actions: { + someAction: (_, params) => spy(params) + } + } + ); + + createActor(machine).start(); + + expect(spy).toMatchMockCallsInlineSnapshot(` + [ + [ + { + "answer": 42, + }, + ], + ] + `); + }); + + it('should execute a function', () => { + const spy = jest.fn(); + + const machine = createMachine({ + entry: enqueueActions(({ enqueue }) => { + enqueue(spy); + }) + }); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should execute a builtin action using its own action creator', () => { + const spy = jest.fn(); + + const machine = createMachine({ + on: { + FOO: { + actions: enqueueActions(({ enqueue }) => { + enqueue( + raise({ + type: 'RAISED' + }) + ); + }) + }, + RAISED: { + actions: spy + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should execute a builtin action using its bound action creator', () => { + const spy = jest.fn(); + + const machine = createMachine({ + on: { + FOO: { + actions: enqueueActions(({ enqueue }) => { + enqueue.raise({ + type: 'RAISED' + }); + }) + }, + RAISED: { + actions: spy + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should execute assigns when resolving the initial snapshot', () => { + const machine = createMachine({ + context: { + count: 0 + }, + entry: enqueueActions(({ enqueue }) => { + enqueue.assign({ + count: 42 + }); + }) + }); + + const snapshot = createActor(machine).getSnapshot(); + + expect(snapshot.context).toEqual({ count: 42 }); + }); + + it('should be able to check a simple referenced guard', () => { + const spy = jest.fn().mockImplementation(() => true); + const machine = createMachine( + { + context: { + count: 0 + }, + entry: enqueueActions(({ check }) => { + check('alwaysTrue'); + }) + }, + { + guards: { + alwaysTrue: spy + } + } + ); + + createActor(machine); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should be able to check a parameterized guard', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + context: { + count: 0 + }, + entry: enqueueActions(({ check }) => { + check({ + type: 'alwaysTrue', + params: { + max: 100 + } + }); + }) + }, + { + guards: { + alwaysTrue: (_, params) => { + spy(params); + return true; + } + } + } + ); + + createActor(machine); + + expect(spy).toMatchMockCallsInlineSnapshot(` + [ + [ + { + "max": 100, + }, + ], + ] + `); + }); + + it('should provide self', () => { + expect.assertions(1); + const machine = createMachine({ + entry: enqueueActions(({ self }) => { + expect(self.send).toBeDefined(); + }) + }); + + createActor(machine).start(); + }); + + it('should be able to communicate with the parent using params', () => { + type ParentEvent = { type: 'FOO' }; + + const childMachine = setup({ + types: {} as { + input: { + parent?: ActorRef, ParentEvent>; + }; + context: { + parent?: ActorRef, ParentEvent>; + }; + }, + actions: { + mySendParent: enqueueActions( + ({ context, enqueue }, event: ParentEvent) => { + if (!context.parent) { + // it's here just for illustration purposes + console.log( + 'WARN: an attempt to send an event to a non-existent parent' + ); + return; + } + enqueue.sendTo(context.parent, event); + } + ) + } + }).createMachine({ + context: ({ input }) => ({ parent: input.parent }), + entry: { + type: 'mySendParent', + params: { + type: 'FOO' + } + } + }); + + const spy = jest.fn(); + + const parentMachine = setup({ + types: {} as { events: ParentEvent }, + actors: { + child: childMachine + } + }).createMachine({ + on: { + FOO: { + actions: spy + } + }, + invoke: { + src: 'child', + input: ({ self }) => ({ parent: self }) + } + }); + + const actorRef = createActor(parentMachine).start(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should enqueue.sendParent', () => { + interface ChildEvent { + type: 'CHILD_EVENT'; + } + + interface ParentEvent { + type: 'PARENT_EVENT'; + } + + const childMachine = setup({ + types: {} as { + events: ChildEvent; + }, + actions: { + sendToParent: enqueueActions(({ context, enqueue }) => { + enqueue.sendParent({ type: 'PARENT_EVENT' }); + }) + } + }).createMachine({ + entry: 'sendToParent' + }); + + const parentSpy = jest.fn(); + + const parentMachine = setup({ + types: {} as { events: ParentEvent }, + actors: { + child: childMachine + } + }).createMachine({ + on: { + PARENT_EVENT: { + actions: parentSpy + } + }, + invoke: { + src: 'child' + } + }); + + const actorRef = createActor(parentMachine).start(); + + expect(parentSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('sendParent', () => { + // https://github.com/statelyai/xstate/issues/711 + it('TS: should compile for any event', () => { + interface ChildEvent { + type: 'CHILD'; + } + + const child = createMachine({ + types: {} as { + events: ChildEvent; + }, + id: 'child', + initial: 'start', + states: { + start: { + // This should not be a TypeScript error + entry: [sendParent({ type: 'PARENT' })] + } + } + }); + + expect(child).toBeTruthy(); + }); +}); + +describe('sendTo', () => { + it('should be able to send an event to an actor', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine) + }), + entry: sendTo(({ context }) => context.child, { type: 'EVENT' }) + }); + + createActor(parentMachine).start(); + }); + + it('should be able to send an event from expression to an actor', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT'; count: number }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + count: number; + }; + }, + context: ({ spawn }) => { + return { + child: spawn(childMachine, { id: 'child' }), + count: 42 + }; + }, + entry: sendTo( + ({ context }) => context.child, + ({ context }) => ({ type: 'EVENT', count: context.count }) + ) + }); + + createActor(parentMachine).start(); + }); + + it('should report a type error for an invalid event', () => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: {} + } + } + } + }); + + createMachine({ + types: {} as { + context: { + child: ActorRefFromLogic; + }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine) + }), + entry: sendTo(({ context }) => context.child, { + // @ts-expect-error + type: 'UNKNOWN' + }) + }); + }); + + it('should be able to send an event to a named actor', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { child: ActorRefFromLogic }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine, { id: 'child' }) + }), + // No type-safety for the event yet + entry: sendTo('child', { type: 'EVENT' }) + }); + + createActor(parentMachine).start(); + }); + + it('should be able to send an event directly to an ActorRef', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'EVENT' }; + }, + initial: 'waiting', + states: { + waiting: { + on: { + EVENT: { + actions: () => done() + } + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + context: { child: ActorRefFromLogic }; + }, + context: ({ spawn }) => ({ + child: spawn(childMachine) + }), + entry: sendTo(({ context }) => context.child, { type: 'EVENT' }) + }); + + createActor(parentMachine).start(); + }); + + it('should be able to read from event', () => { + expect.assertions(1); + const machine = createMachine({ + types: {} as { + context: Record>; + events: { type: 'EVENT'; value: string }; + }, + initial: 'a', + context: ({ spawn }) => ({ + foo: spawn( + fromCallback(({ receive }) => { + receive((event) => { + expect(event).toEqual({ type: 'EVENT' }); + }); + }) + ) + }), + states: { + a: { + on: { + EVENT: { + actions: sendTo(({ context, event }) => context[event.value], { + type: 'EVENT' + }) + } + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'EVENT', value: 'foo' }); + }); + + it('should error if given a string', () => { + const machine = createMachine({ + invoke: { + id: 'child', + src: fromCallback(() => {}) + }, + entry: sendTo('child', 'a string') + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Only event objects may be used with sendTo; use sendTo({ type: "a string" }) instead], + ], + ] + `); + }); + + it('a self-event "handler" of an event sent using sendTo should be able to read updated snapshot of self', () => { + const spy = jest.fn(); + const machine = createMachine({ + context: { + counter: 0 + }, + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: { + entry: [ + assign({ counter: 1 }), + sendTo(({ self }) => self, { type: 'EVENT' }) + ], + on: { + EVENT: { + actions: ({ self }) => spy(self.getSnapshot().context), + target: 'c' + } + } + }, + c: {} + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'NEXT' }); + actorRef.send({ type: 'EVENT' }); + + expect(spy).toMatchMockCallsInlineSnapshot(` +[ + [ + { + "counter": 1, + }, + ], +] +`); + }); + + it("should not attempt to deliver a delayed event to the spawned actor's ID that was stopped since the event was scheduled", async () => { + const spy1 = jest.fn(); + + const child1 = createMachine({ + on: { + PING: { + actions: spy1 + } + } + }); + + const spy2 = jest.fn(); + + const child2 = createMachine({ + on: { + PING: { + actions: spy2 + } + } + }); + + const machine = setup({ + actors: { + child1, + child2 + } + }).createMachine({ + initial: 'a', + states: { + a: { + on: { + START: 'b' + } + }, + b: { + entry: [ + spawnChild('child1', { + id: 'myChild' + }), + sendTo('myChild', { type: 'PING' }, { delay: 1 }), + stopChild('myChild'), + spawnChild('child2', { + id: 'myChild' + }) + ] + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'START' }); + + await sleep(10); + + expect(spy1).toHaveBeenCalledTimes(0); + expect(spy2).toHaveBeenCalledTimes(0); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` +[ + [ + "Event "PING" was sent to stopped actor "myChild (x:1)". This actor has already reached its final state, and will not transition. +Event: {"type":"PING"}", + ], +] +`); + }); + + it("should not attempt to deliver a delayed event to the invoked actor's ID that was stopped since the event was scheduled", async () => { + const spy1 = jest.fn(); + + const child1 = createMachine({ + on: { + PING: { + actions: spy1 + } + } + }); + + const spy2 = jest.fn(); + + const child2 = createMachine({ + on: { + PING: { + actions: spy2 + } + } + }); + + const machine = setup({ + actors: { + child1, + child2 + } + }).createMachine({ + initial: 'a', + states: { + a: { + on: { + START: 'b' + } + }, + b: { + entry: sendTo('myChild', { type: 'PING' }, { delay: 1 }), + invoke: { + src: 'child1', + id: 'myChild' + }, + on: { + NEXT: 'c' + } + }, + c: { + invoke: { + src: 'child2', + id: 'myChild' + } + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'START' }); + actorRef.send({ type: 'NEXT' }); + + await sleep(10); + + expect(spy1).toHaveBeenCalledTimes(0); + expect(spy2).toHaveBeenCalledTimes(0); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` +[ + [ + "Event "PING" was sent to stopped actor "myChild (x:1)". This actor has already reached its final state, and will not transition. +Event: {"type":"PING"}", + ], +] +`); + }); +}); + +describe('raise', () => { + it('should be able to send a delayed event to itself', (done) => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: raise( + { type: 'EVENT' }, + { + delay: 1 + } + ), + on: { + TO_B: 'b' + } + }, + b: { + on: { + EVENT: 'c' + } + }, + c: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + service.subscribe({ complete: () => done() }); + + // Ensures that the delayed self-event is sent when in the `b` state + service.send({ type: 'TO_B' }); + }); + + it('should be able to send a delayed event to itself with delay = 0', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: raise( + { type: 'EVENT' }, + { + delay: 0 + } + ), + on: { + EVENT: 'b' + } + }, + b: {} + } + }); + + const service = createActor(machine).start(); + + // The state should not be changed yet; `delay: 0` is equivalent to `setTimeout(..., 0)` + expect(service.getSnapshot().value).toEqual('a'); + + await sleep(0); + // The state should be changed now + expect(service.getSnapshot().value).toEqual('b'); + }); + + it('should be able to raise an event and respond to it in the same state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry2: (_, enq) => { + enq.raise({ type: 'TO_B' }); + }, + on: { + TO_B: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + expect(service.getSnapshot().value).toEqual('b'); + }); + + it('should be able to raise a delayed event and respond to it in the same state', (done) => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry2: (_, enq) => { + enq.raise({ type: 'TO_B' }, { delay: 100 }); + }, + on: { + TO_B: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + service.subscribe({ complete: () => done() }); + + setTimeout(() => { + // didn't transition yet + expect(service.getSnapshot().value).toEqual('a'); + }, 50); + }); + + it('should accept event expression', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: (_, enq) => { + enq.raise({ type: 'RAISED' }); + }, + RAISED: 'b' + } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + + expect(actor.getSnapshot().value).toBe('b'); + }); + + it('should be possible to access context in the event expression', () => { + type MachineEvent = + | { + type: 'RAISED'; + } + | { + type: 'NEXT'; + }; + interface MachineContext { + eventType: MachineEvent['type']; + } + const machine = createMachine({ + types: {} as { context: MachineContext; events: MachineEvent }, + initial: 'a', + context: { + eventType: 'RAISED' + }, + states: { + a: { + on: { + NEXT: ({ context }, enq) => { + enq.raise({ type: context.eventType }); + }, + RAISED: 'b' + } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + actor.send({ type: 'NEXT' }); + + expect(actor.getSnapshot().value).toBe('b'); + }); + + it('should error if given a string', () => { + const machine = createMachine({ + entry: raise( + // @ts-ignore + 'a string' + ) + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Only event objects may be used with raise; use raise({ type: "a string" }) instead], + ], + ] + `); + }); +}); + +describe('cancel', () => { + it('should be possible to cancel a raised delayed event', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: { + actions: raise({ type: 'RAISED' }, { delay: 1, id: 'myId' }) + }, + RAISED: 'b', + CANCEL: { + actions: cancel('myId') + } + } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + // This should raise the 'RAISED' event after 1ms + actor.send({ type: 'NEXT' }); + + // This should cancel the 'RAISED' event + actor.send({ type: 'CANCEL' }); + + await new Promise((res) => { + setTimeout(() => { + expect(actor.getSnapshot().value).toBe('a'); + res(); + }, 10); + }); + }); + + it('should cancel only the delayed event in the machine that scheduled it when canceling the event with the same ID in the machine that sent it first', async () => { + const fooSpy = jest.fn(); + const barSpy = jest.fn(); + + const machine = createMachine({ + invoke: [ + { + id: 'foo', + src: createMachine({ + id: 'foo', + entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), + on: { + event: { actions: fooSpy }, + cancel: { actions: cancel('sameId') } + } + }) + }, + { + id: 'bar', + src: createMachine({ + id: 'bar', + entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), + on: { + event: { actions: barSpy } + } + }) + } + ], + on: { + cancelFoo: { + actions: sendTo('foo', { type: 'cancel' }) + } + } + }); + const actor = createActor(machine).start(); + + await sleep(50); + + // This will cause the foo actor to cancel its 'sameId' delayed event + // This should NOT cancel the 'sameId' delayed event in the other actor + actor.send({ type: 'cancelFoo' }); + + await sleep(55); + + expect(fooSpy).not.toHaveBeenCalled(); + expect(barSpy).toHaveBeenCalledTimes(1); + }); + + it('should cancel only the delayed event in the machine that scheduled it when canceling the event with the same ID in the machine that sent it second', async () => { + const fooSpy = jest.fn(); + const barSpy = jest.fn(); + + const machine = createMachine({ + invoke: [ + { + id: 'foo', + src: createMachine({ + id: 'foo', + entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), + on: { + event: { actions: fooSpy } + } + }) + }, + { + id: 'bar', + src: createMachine({ + id: 'bar', + entry: raise({ type: 'event' }, { id: 'sameId', delay: 100 }), + on: { + event: { actions: barSpy }, + cancel: { actions: cancel('sameId') } + } + }) + } + ], + on: { + cancelBar: { + actions: sendTo('bar', { type: 'cancel' }) + } + } + }); + const actor = createActor(machine).start(); + + await sleep(50); + + // This will cause the bar actor to cancel its 'sameId' delayed event + // This should NOT cancel the 'sameId' delayed event in the other actor + actor.send({ type: 'cancelBar' }); + + await sleep(55); + + expect(fooSpy).toHaveBeenCalledTimes(1); + expect(barSpy).not.toHaveBeenCalled(); + }); + + it('should not try to clear an undefined timeout when canceling an unscheduled timer', async () => { + const spy = jest.fn(); + + const machine = createMachine({ + on: { + FOO: { + actions: cancel('foo') + } + } + }); + + const actorRef = createActor(machine, { + clock: { + setTimeout, + clearTimeout: spy + } + }).start(); + + actorRef.send({ + type: 'FOO' + }); + + expect(spy.mock.calls.length).toBe(0); + }); + + it('should be able to cancel a just scheduled delayed event to a just invoked child', async () => { + const spy = jest.fn(); + + const child = createMachine({ + on: { + PING: { + actions: spy + } + } + }); + + const machine = setup({ + actors: { + child + } + }).createMachine({ + initial: 'a', + states: { + a: { + on: { + START: 'b' + } + }, + b: { + entry: [ + sendTo('myChild', { type: 'PING' }, { id: 'myEvent', delay: 0 }), + cancel('myEvent') + ], + invoke: { + src: 'child', + id: 'myChild' + } + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ + type: 'START' + }); + + await sleep(10); + expect(spy.mock.calls.length).toBe(0); + }); + + it('should not be able to cancel a just scheduled non-delayed event to a just invoked child', async () => { + const spy = jest.fn(); + + const child = createMachine({ + on: { + PING: { + actions: spy + } + } + }); + + const machine = setup({ + actors: { + child + } + }).createMachine({ + initial: 'a', + states: { + a: { + on: { + START: 'b' + } + }, + b: { + entry: [ + sendTo('myChild', { type: 'PING' }, { id: 'myEvent' }), + cancel('myEvent') + ], + invoke: { + src: 'child', + id: 'myChild' + } + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ + type: 'START' + }); + + expect(spy.mock.calls.length).toBe(1); + }); +}); + +describe('assign action order', () => { + it('should preserve action order', () => { + const captured: number[] = []; + + const machine = createMachine({ + types: {} as { + context: { count: number }; + }, + context: { count: 0 }, + entry: [ + ({ context }) => captured.push(context.count), // 0 + assign({ count: ({ context }) => context.count + 1 }), + ({ context }) => captured.push(context.count), // 1 + assign({ count: ({ context }) => context.count + 1 }), + ({ context }) => captured.push(context.count) // 2 + ] + }); + + const actor = createActor(machine).start(); + + expect(actor.getSnapshot().context).toEqual({ count: 2 }); + + expect(captured).toEqual([0, 1, 2]); + }); + + it('should deeply preserve action order', () => { + const captured: number[] = []; + + interface CountCtx { + count: number; + } + + const machine = createMachine( + { + types: {} as { + context: CountCtx; + }, + context: { count: 0 }, + entry: [ + ({ context }) => captured.push(context.count), // 0 + enqueueActions(({ enqueue }) => { + enqueue(assign({ count: ({ context }) => context.count + 1 })); + enqueue({ type: 'capture' }); + enqueue(assign({ count: ({ context }) => context.count + 1 })); + }), + ({ context }) => captured.push(context.count) // 2 + ] + }, + { + actions: { + capture: ({ context }) => captured.push(context.count) + } + } + ); + + createActor(machine).start(); + + expect(captured).toEqual([0, 1, 2]); + }); + + it('should capture correct context values on subsequent transitions', () => { + let captured: number[] = []; + + const machine = createMachine({ + types: {} as { + context: { counter: number }; + }, + context: { + counter: 0 + }, + on: { + EV: { + actions: [ + assign({ counter: ({ context }) => context.counter + 1 }), + ({ context }) => captured.push(context.counter) + ] + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'EV' }); + service.send({ type: 'EV' }); + + expect(captured).toEqual([1, 2]); + }); +}); + +describe('types', () => { + it('assign actions should be inferred correctly', () => { + createMachine({ + types: {} as { + context: { count: number; text: string }; + events: { type: 'inc'; value: number } | { type: 'say'; value: string }; + }, + context: { + count: 0, + text: 'hello' + }, + entry: [ + assign({ count: 31 }), + // @ts-expect-error + assign({ count: 'string' }), + + assign({ count: () => 31 }), + // @ts-expect-error + assign({ count: () => 'string' }), + + assign({ count: ({ context }) => context.count + 31 }), + // @ts-expect-error + assign({ count: ({ context }) => context.text + 31 }), + + assign(() => ({ count: 31 })), + // @ts-expect-error + assign(() => ({ count: 'string' })), + + assign(({ context }) => ({ count: context.count + 31 })), + // @ts-expect-error + assign(({ context }) => ({ count: context.text + 31 })) + ], + on: { + say: { + actions: [ + assign({ text: ({ event }) => event.value }), + // @ts-expect-error + assign({ count: ({ event }) => event.value }), + + assign(({ event }) => ({ text: event.value })), + // @ts-expect-error + assign(({ event }) => ({ count: event.value })) + ] + } + } + }); + }); +}); + +describe('action meta', () => { + it.todo( + 'base action objects should have meta.action as the same base action object' + ); + + it('should provide self', () => { + expect.assertions(1); + + const machine = createMachine({ + entry: ({ self }) => { + expect(self.send).toBeDefined(); + } + }); + + createActor(machine).start(); + }); +}); + +describe('actions', () => { + it('should call transition actions in document order for same-level parallel regions', () => { + const actual: string[] = []; + + const machine = createMachine({ + type: 'parallel', + states: { + a: { + on: { + FOO: { + actions: () => actual.push('a') + } + } + }, + b: { + on: { + FOO: { + actions: () => actual.push('b') + } + } + } + } + }); + const service = createActor(machine).start(); + service.send({ type: 'FOO' }); + + expect(actual).toEqual(['a', 'b']); + }); + + it('should call transition actions in document order for states at different levels of parallel regions', () => { + const actual: string[] = []; + + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + FOO: { + actions: () => actual.push('a1') + } + } + } + } + }, + b: { + on: { + FOO: { + actions: () => actual.push('b') + } + } + } + } + }); + const service = createActor(machine).start(); + service.send({ type: 'FOO' }); + + expect(actual).toEqual(['a1', 'b']); + }); + + it('should call an inline action responding to an initial raise with the raised event', () => { + const spy = jest.fn(); + + const machine = createMachine({ + entry: raise({ type: 'HELLO' }), + on: { + HELLO: { + actions: ({ event }) => { + spy(event); + } + } + } + }); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledWith({ type: 'HELLO' }); + }); + + it('should call a referenced action responding to an initial raise with the raised event', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + entry: raise({ type: 'HELLO' }), + on: { + HELLO: { + actions: 'foo' + } + } + }, + { + actions: { + foo: ({ event }) => { + spy(event); + } + } + } + ); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledWith({ type: 'HELLO' }); + }); + + it('should call an inline action responding to an initial raise with updated (non-initial) context', () => { + const spy = jest.fn(); + + const machine = createMachine({ + context: { count: 0 }, + entry: [assign({ count: 42 }), raise({ type: 'HELLO' })], + on: { + HELLO: { + actions: ({ context }) => { + spy(context); + } + } + } + }); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledWith({ count: 42 }); + }); + + it('should call a referenced action responding to an initial raise with updated (non-initial) context', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + context: { count: 0 }, + entry: [assign({ count: 42 }), raise({ type: 'HELLO' })], + on: { + HELLO: { + actions: 'foo' + } + } + }, + { + actions: { + foo: ({ context }) => { + spy(context); + } + } + } + ); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledWith({ count: 42 }); + }); + + it('should call inline entry custom action with undefined parametrized action object', () => { + const spy = jest.fn(); + createActor( + createMachine({ + entry: (_, params) => { + spy(params); + } + }) + ).start(); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should call inline entry builtin action with undefined parametrized action object', () => { + const spy = jest.fn(); + createActor( + createMachine({ + entry: assign((_, params) => { + spy(params); + return {}; + }) + }) + ).start(); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should call inline transition custom action with undefined parametrized action object', () => { + const spy = jest.fn(); + + const actorRef = createActor( + createMachine({ + on: { + FOO: { + actions: (_, params) => { + spy(params); + } + } + } + }) + ).start(); + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should call inline transition builtin action with undefined parameters', () => { + const spy = jest.fn(); + + const actorRef = createActor( + createMachine({ + on: { + FOO: { + actions: assign((_, params) => { + spy(params); + return {}; + }) + } + } + }) + ).start(); + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should call a referenced custom action with undefined params when it has no params and it is referenced using a string', () => { + const spy = jest.fn(); + + createActor( + createMachine( + { + entry: 'myAction' + }, + { + actions: { + myAction: (_, params) => { + spy(params); + } + } + } + ) + ).start(); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should call a referenced builtin action with undefined params when it has no params and it is referenced using a string', () => { + const spy = jest.fn(); + + createActor( + createMachine( + { + entry: 'myAction' + }, + { + actions: { + myAction: assign((_, params) => { + spy(params); + return {}; + }) + } + } + ) + ).start(); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should call a referenced custom action with the provided parametrized action object', () => { + const spy = jest.fn(); + + createActor( + createMachine( + { + entry: { + type: 'myAction', + params: { + foo: 'bar' + } + } + }, + { + actions: { + myAction: (_, params) => { + spy(params); + } + } + } + ) + ).start(); + + expect(spy).toHaveBeenCalledWith({ + foo: 'bar' + }); + }); + + it('should call a referenced builtin action with the provided parametrized action object', () => { + const spy = jest.fn(); + + createActor( + createMachine( + { + entry: { + type: 'myAction', + params: { + foo: 'bar' + } + } + }, + { + actions: { + myAction: assign((_, params) => { + spy(params); + return {}; + }) + } + } + ) + ).start(); + + expect(spy).toHaveBeenCalledWith({ + foo: 'bar' + }); + }); + + it('should warn if called in custom action', () => { + const machine = createMachine({ + entry: () => { + assign({}); + raise({ type: '' }); + sendTo('', { type: '' }); + emit({ type: '' }); + } + }); + + createActor(machine).start(); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` +[ + [ + "Custom actions should not call \`assign()\` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.", + ], + [ + "Custom actions should not call \`raise()\` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.", + ], + [ + "Custom actions should not call \`sendTo()\` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.", + ], + [ + "Custom actions should not call \`emit()\` directly, as it is not imperative. See https://stately.ai/docs/actions#built-in-actions for more details.", + ], +] +`); + }); + + it('inline actions should not leak into provided actions object', async () => { + const actions = {}; + + const machine = createMachine( + { + entry: () => {} + }, + { actions } + ); + + createActor(machine).start(); + + expect(actions).toEqual({}); + }); +}); diff --git a/packages/core/test/actor.test.ts b/packages/core/test/actor.test.ts index 65ff942554..6425764378 100644 --- a/packages/core/test/actor.test.ts +++ b/packages/core/test/actor.test.ts @@ -231,8 +231,6 @@ describe('spawning machines', () => { }); }); -const aaa = 'dadasda'; - describe('spawning promises', () => { it('should be able to spawn a promise', (done) => { const promiseMachine = createMachine({ diff --git a/packages/core/test/after.v6.test.ts b/packages/core/test/after.v6.test.ts new file mode 100644 index 0000000000..c20a918fb1 --- /dev/null +++ b/packages/core/test/after.v6.test.ts @@ -0,0 +1,344 @@ +import { sleep } from '@xstate-repo/jest-utils'; +import { next_createMachine, createActor } from '../src/index.ts'; + +const lightMachine = next_createMachine({ + id: 'light', + initial: 'green', + context: { + canTurnGreen: true + }, + states: { + green: { + after: { + 1000: 'yellow' + } + }, + yellow: { + after: { + 1000: 'red' + } + }, + red: { + after: { + 1000: 'green' + } + } + } +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('delayed transitions', () => { + it('should transition after delay', () => { + jest.useFakeTimers(); + + const actorRef = createActor(lightMachine).start(); + expect(actorRef.getSnapshot().value).toBe('green'); + + jest.advanceTimersByTime(500); + expect(actorRef.getSnapshot().value).toBe('green'); + + jest.advanceTimersByTime(510); + expect(actorRef.getSnapshot().value).toBe('yellow'); + }); + + it('should not try to clear an undefined timeout when exiting source state of a delayed transition', async () => { + // https://github.com/statelyai/xstate/issues/5001 + const spy = jest.fn(); + + const machine = next_createMachine({ + initial: 'green', + states: { + green: { + after: { + 1: 'yellow' + } + }, + yellow: {} + } + }); + + const actorRef = createActor(machine, { + clock: { + setTimeout, + clearTimeout: spy + } + }).start(); + + // when the after transition gets executed it tries to clear its own timer when exiting its source state + await sleep(5); + expect(actorRef.getSnapshot().value).toBe('yellow'); + expect(spy.mock.calls.length).toBe(0); + }); + + it('should format transitions properly', () => { + const greenNode = lightMachine.states.green; + + const transitions = greenNode.transitions; + + expect([...transitions.keys()]).toMatchInlineSnapshot(` + [ + "xstate.after.1000.light.green", + ] + `); + }); + + it('should be able to transition with delay from nested initial state', (done) => { + const machine = next_createMachine({ + initial: 'nested', + states: { + nested: { + initial: 'wait', + states: { + wait: { + after: { + 10: '#end' + } + } + } + }, + end: { + id: 'end', + type: 'final' + } + } + }); + + const actor = createActor(machine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + it('parent state should enter child state without re-entering self (relative target)', (done) => { + const actual: string[] = []; + const machine = next_createMachine({ + initial: 'one', + states: { + one: { + initial: 'two', + entry2: () => { + actual.push('entered one'); + }, + states: { + two: { + entry2: () => { + actual.push('entered two'); + } + }, + three: { + entry2: () => { + actual.push('entered three'); + }, + always: '#end' + } + }, + after: { + 10: '.three' + } + }, + end: { + id: 'end', + type: 'final' + } + } + }); + + const actor = createActor(machine); + actor.subscribe({ + complete: () => { + expect(actual).toEqual(['entered one', 'entered two', 'entered three']); + done(); + } + }); + actor.start(); + }); + + it('should defer a single send event for a delayed conditional transition (#886)', () => { + jest.useFakeTimers(); + const spy = jest.fn(); + const machine = next_createMachine({ + initial: 'X', + states: { + X: { + after: { + 1: () => { + return { + target: true ? 'Y' : 'Z' + }; + } + } + }, + Y: { + on: { + '*': spy + } + }, + Z: {} + } + }); + + createActor(machine).start(); + + jest.advanceTimersByTime(10); + expect(spy).not.toHaveBeenCalled(); + }); + + // TODO: figure out correct behavior for restoring delayed transitions + it.skip('should execute an after transition after starting from a state resolved using `.getPersistedSnapshot`', (done) => { + const machine = next_createMachine({ + id: 'machine', + initial: 'a', + states: { + a: { + on: { next: 'withAfter' } + }, + + withAfter: { + after: { + 1: { target: 'done' } + } + }, + + done: { + type: 'final' + } + } + }); + + const actorRef1 = createActor(machine).start(); + actorRef1.send({ type: 'next' }); + const withAfterState = actorRef1.getPersistedSnapshot(); + + const actorRef2 = createActor(machine, { snapshot: withAfterState }); + actorRef2.subscribe({ complete: () => done() }); + actorRef2.start(); + }); + + it('should execute an after transition after starting from a persisted state', (done) => { + const createMyMachine = () => + next_createMachine({ + initial: 'A', + states: { + A: { + on: { + NEXT: 'B' + } + }, + B: { + after: { + 1: 'C' + } + }, + C: { + type: 'final' + } + } + }); + + let service = createActor(createMyMachine()).start(); + + const persistedSnapshot = JSON.parse(JSON.stringify(service.getSnapshot())); + + service = createActor(createMyMachine(), { + snapshot: persistedSnapshot + }).start(); + + service.send({ type: 'NEXT' }); + + service.subscribe({ complete: () => done() }); + }); + + describe('delay expressions', () => { + it('should evaluate the expression (function) to determine the delay', () => { + jest.useFakeTimers(); + const spy = jest.fn(); + const context = { + delay: 500 + }; + const machine = next_createMachine( + { + initial: 'inactive', + context, + states: { + inactive: { + after: { myDelay: 'active' } + }, + active: {} + } + }, + { + delays: { + myDelay: ({ context }) => { + spy(context); + return context.delay; + } + } + } + ); + + const actor = createActor(machine).start(); + + expect(spy).toBeCalledWith(context); + expect(actor.getSnapshot().value).toBe('inactive'); + + jest.advanceTimersByTime(300); + expect(actor.getSnapshot().value).toBe('inactive'); + + jest.advanceTimersByTime(200); + expect(actor.getSnapshot().value).toBe('active'); + }); + + it('should evaluate the expression (string) to determine the delay', () => { + jest.useFakeTimers(); + const spy = jest.fn(); + const machine = next_createMachine( + { + initial: 'inactive', + states: { + inactive: { + on: { + ACTIVATE: 'active' + } + }, + active: { + after: { + someDelay: 'inactive' + } + } + } + }, + { + delays: { + someDelay: ({ event }) => { + spy(event); + return event.delay; + } + } + } + ); + + const actor = createActor(machine).start(); + + const event = { + type: 'ACTIVATE', + delay: 500 + } as const; + actor.send(event); + + expect(spy).toBeCalledWith(event); + expect(actor.getSnapshot().value).toBe('active'); + + jest.advanceTimersByTime(300); + expect(actor.getSnapshot().value).toBe('active'); + + jest.advanceTimersByTime(200); + expect(actor.getSnapshot().value).toBe('inactive'); + }); + }); +}); diff --git a/packages/core/test/assert.v6.test.ts b/packages/core/test/assert.v6.test.ts new file mode 100644 index 0000000000..f8794a82c1 --- /dev/null +++ b/packages/core/test/assert.v6.test.ts @@ -0,0 +1,96 @@ +import { createActor, next_createMachine, assertEvent } from '../src'; + +describe('assertion helpers', () => { + it('assertEvent asserts the correct event type', (done) => { + type TestEvent = + | { type: 'greet'; message: string } + | { type: 'count'; value: number }; + + const greet = (event: TestEvent) => { + // @ts-expect-error + event.message; + + assertEvent(event, 'greet'); + event.message satisfies string; + + // @ts-expect-error + event.count; + }; + + const machine = next_createMachine({ + on: { + greet: ({ event }, enq) => { + enq.action(() => greet(event)); + }, + count: ({ event }) => { + greet(event); + } + } + }); + + const actor = createActor(machine); + + actor.subscribe({ + error(err) { + expect(err).toMatchInlineSnapshot( + `[Error: Expected event {"type":"count","value":42} to have type "greet"]` + ); + done(); + } + }); + + actor.start(); + + actor.send({ type: 'count', value: 42 }); + }); + + it('assertEvent asserts multiple event types', (done) => { + type TestEvent = + | { type: 'greet'; message: string } + | { type: 'notify'; message: string; level: 'info' | 'error' } + | { type: 'count'; value: number }; + + const greet = (event: TestEvent) => { + // @ts-expect-error + event.message; + + assertEvent(event, ['greet', 'notify']); + event.message satisfies string; + + // @ts-expect-error + event.level; + + assertEvent(event, ['notify']); + event.level satisfies 'info' | 'error'; + + // @ts-expect-error + event.count; + }; + + const machine = next_createMachine({ + on: { + greet: ({ event }, enq) => { + enq.action(() => greet(event)); + }, + count: ({ event }, enq) => { + enq.action(() => greet(event)); + } + } + }); + + const actor = createActor(machine); + + actor.subscribe({ + error(err) { + expect(err).toMatchInlineSnapshot( + `[Error: Expected event {"type":"count","value":42} to have one of types "greet", "notify"]` + ); + done(); + } + }); + + actor.start(); + + actor.send({ type: 'count', value: 42 }); + }); +}); diff --git a/packages/core/test/assign.v6.test.ts b/packages/core/test/assign.v6.test.ts new file mode 100644 index 0000000000..3aeadb8546 --- /dev/null +++ b/packages/core/test/assign.v6.test.ts @@ -0,0 +1,260 @@ +import { createActor, next_createMachine } from '../src/index.ts'; + +interface CounterContext { + count: number; + foo: string; + maybe?: string; +} + +const createCounterMachine = (context: Partial = {}) => + next_createMachine({ + types: {} as { context: CounterContext }, + initial: 'counting', + context: { count: 0, foo: 'bar', ...context }, + states: { + counting: { + on: { + INC: ({ context }) => ({ + target: 'counting', + context: { ...context, count: context.count + 1 } + }), + DEC: ({ context }) => ({ + target: 'counting', + context: { + ...context, + count: context.count - 1 + } + }), + WIN_PROP: ({ context }) => ({ + target: 'counting', + context: { + ...context, + count: 100, + foo: 'win' + } + }), + WIN_STATIC: ({ context }) => ({ + target: 'counting', + context: { + ...context, + count: 100, + foo: 'win' + } + }), + WIN_MIX: ({ context }) => ({ + target: 'counting', + context: { + ...context, + count: 100, + foo: 'win' + } + }), + WIN: ({ context }) => ({ + target: 'counting', + context: { + ...context, + count: 100, + foo: 'win' + } + }), + SET_MAYBE: ({ context }) => ({ + context: { + ...context, + maybe: 'defined' + } + }) + } + } + } + }); + +describe('assign', () => { + it('applies the assignment to the external state (property assignment)', () => { + const counterMachine = createCounterMachine(); + + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'DEC' + }); + const oneState = actorRef.getSnapshot(); + + expect(oneState.value).toEqual('counting'); + expect(oneState.context).toEqual({ count: -1, foo: 'bar' }); + + actorRef.send({ type: 'DEC' }); + const twoState = actorRef.getSnapshot(); + + expect(twoState.value).toEqual('counting'); + expect(twoState.context).toEqual({ count: -2, foo: 'bar' }); + }); + + it('applies the assignment to the external state', () => { + const counterMachine = createCounterMachine(); + + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'INC' + }); + const oneState = actorRef.getSnapshot(); + + expect(oneState.value).toEqual('counting'); + expect(oneState.context).toEqual({ count: 1, foo: 'bar' }); + + actorRef.send({ type: 'INC' }); + const twoState = actorRef.getSnapshot(); + + expect(twoState.value).toEqual('counting'); + expect(twoState.context).toEqual({ count: 2, foo: 'bar' }); + }); + + it('applies the assignment to multiple properties (property assignment)', () => { + const counterMachine = createCounterMachine(); + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'WIN_PROP' + }); + + expect(actorRef.getSnapshot().context).toEqual({ count: 100, foo: 'win' }); + }); + + it('applies the assignment to multiple properties (static)', () => { + const counterMachine = createCounterMachine(); + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'WIN_STATIC' + }); + + expect(actorRef.getSnapshot().context).toEqual({ count: 100, foo: 'win' }); + }); + + it('applies the assignment to multiple properties (static + prop assignment)', () => { + const counterMachine = createCounterMachine(); + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'WIN_MIX' + }); + + expect(actorRef.getSnapshot().context).toEqual({ count: 100, foo: 'win' }); + }); + + it('applies the assignment to multiple properties', () => { + const counterMachine = createCounterMachine(); + const actorRef = createActor(counterMachine).start(); + actorRef.send({ + type: 'WIN' + }); + + expect(actorRef.getSnapshot().context).toEqual({ count: 100, foo: 'win' }); + }); + + it('applies the assignment to the explicit external state (property assignment)', () => { + const machine = createCounterMachine({ count: 50, foo: 'bar' }); + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'DEC' }); + const oneState = actorRef.getSnapshot(); + + expect(oneState.value).toEqual('counting'); + expect(oneState.context).toEqual({ count: 49, foo: 'bar' }); + + actorRef.send({ type: 'DEC' }); + const twoState = actorRef.getSnapshot(); + + expect(twoState.value).toEqual('counting'); + expect(twoState.context).toEqual({ count: 48, foo: 'bar' }); + + const machine2 = createCounterMachine({ count: 100, foo: 'bar' }); + + const actorRef2 = createActor(machine2).start(); + actorRef2.send({ type: 'DEC' }); + const threeState = actorRef2.getSnapshot(); + + expect(threeState.value).toEqual('counting'); + expect(threeState.context).toEqual({ count: 99, foo: 'bar' }); + }); + + it('applies the assignment to the explicit external state', () => { + const machine = createCounterMachine({ count: 50, foo: 'bar' }); + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'INC' }); + const oneState = actorRef.getSnapshot(); + + expect(oneState.value).toEqual('counting'); + expect(oneState.context).toEqual({ count: 51, foo: 'bar' }); + + actorRef.send({ type: 'INC' }); + const twoState = actorRef.getSnapshot(); + + expect(twoState.value).toEqual('counting'); + expect(twoState.context).toEqual({ count: 52, foo: 'bar' }); + + const machine2 = createCounterMachine({ count: 102, foo: 'bar' }); + + const actorRef2 = createActor(machine2).start(); + actorRef2.send({ type: 'INC' }); + const threeState = actorRef2.getSnapshot(); + + expect(threeState.value).toEqual('counting'); + expect(threeState.context).toEqual({ count: 103, foo: 'bar' }); + }); + + it('should maintain state after unhandled event', () => { + const counterMachine = createCounterMachine(); + const actorRef = createActor(counterMachine).start(); + + actorRef.send({ + type: 'FAKE_EVENT' + }); + const nextState = actorRef.getSnapshot(); + + expect(nextState.context).toBeDefined(); + expect(nextState.context).toEqual({ count: 0, foo: 'bar' }); + }); + + it('sets undefined properties', () => { + const counterMachine = createCounterMachine(); + const actorRef = createActor(counterMachine).start(); + + actorRef.send({ + type: 'SET_MAYBE' + }); + + const nextState = actorRef.getSnapshot(); + + expect(nextState.context.maybe).toBeDefined(); + expect(nextState.context).toEqual({ + count: 0, + foo: 'bar', + maybe: 'defined' + }); + }); + + it('can assign from event', () => { + const machine = next_createMachine({ + types: {} as { + context: { count: number }; + events: { type: 'INC'; value: number }; + }, + initial: 'active', + context: { + count: 0 + }, + states: { + active: { + on: { + INC: ({ context, event }) => ({ + context: { + ...context, + count: event.value + } + }) + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'INC', value: 30 }); + + expect(actorRef.getSnapshot().context.count).toEqual(30); + }); +}); diff --git a/packages/core/test/clock.v6.test.ts b/packages/core/test/clock.v6.test.ts new file mode 100644 index 0000000000..ab4ccd3e99 --- /dev/null +++ b/packages/core/test/clock.v6.test.ts @@ -0,0 +1,34 @@ +import { createActor, next_createMachine, SimulatedClock } from '../src'; + +describe('clock', () => { + it('system clock should be default clock for actors (invoked from machine)', () => { + const clock = new SimulatedClock(); + + const machine = next_createMachine({ + invoke: { + id: 'child', + src: next_createMachine({ + initial: 'a', + states: { + a: { + after: { + 10_000: 'b' + } + }, + b: {} + } + }) + } + }); + + const actor = createActor(machine, { + clock + }).start(); + + expect(actor.getSnapshot().children.child.getSnapshot().value).toEqual('a'); + + clock.increment(10_000); + + expect(actor.getSnapshot().children.child.getSnapshot().value).toEqual('b'); + }); +}); diff --git a/packages/core/test/deep.v6.test.ts b/packages/core/test/deep.v6.test.ts new file mode 100644 index 0000000000..2abed3b063 --- /dev/null +++ b/packages/core/test/deep.v6.test.ts @@ -0,0 +1,495 @@ +import { next_createMachine, createActor } from '../src/index.ts'; +import { trackEntries } from './utils.ts'; + +describe('deep transitions', () => { + describe('exiting super/substates', () => { + it('should exit all substates when superstates exits', () => { + const machine = next_createMachine({ + id: 'root', + initial: 'A', + states: { + DONE: {}, + FAIL: {}, + A: { + on: { + A_EVENT: '#root.DONE' + }, + initial: 'B', + states: { + B: { + initial: 'C', + states: { + C: { + initial: 'D', + states: { + D: {} + } + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'A_EVENT' + }); + + expect(flushTracked()).toEqual([ + 'exit: A.B.C.D', + 'exit: A.B.C', + 'exit: A.B', + 'exit: A', + 'enter: DONE' + ]); + }); + + it('should exit substates and superstates when exiting (B_EVENT)', () => { + const machine = next_createMachine({ + id: 'root', + initial: 'A', + states: { + DONE: {}, + A: { + initial: 'B', + states: { + B: { + on: { + B_EVENT: '#root.DONE' + }, + initial: 'C', + states: { + C: { + initial: 'D', + states: { + D: {} + } + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'B_EVENT' + }); + + expect(flushTracked()).toEqual([ + 'exit: A.B.C.D', + 'exit: A.B.C', + 'exit: A.B', + 'exit: A', + 'enter: DONE' + ]); + }); + + it('should exit substates and superstates when exiting (C_EVENT)', () => { + const machine = next_createMachine({ + id: 'root', + initial: 'A', + states: { + DONE: {}, + A: { + initial: 'B', + states: { + B: { + initial: 'C', + states: { + C: { + on: { + C_EVENT: '#root.DONE' + }, + initial: 'D', + states: { + D: {} + } + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'C_EVENT' + }); + + expect(flushTracked()).toEqual([ + 'exit: A.B.C.D', + 'exit: A.B.C', + 'exit: A.B', + 'exit: A', + 'enter: DONE' + ]); + }); + + it('should exit superstates when exiting (D_EVENT)', () => { + const machine = next_createMachine({ + id: 'root', + initial: 'A', + states: { + DONE: {}, + A: { + initial: 'B', + states: { + B: { + initial: 'C', + states: { + C: { + initial: 'D', + states: { + D: { + on: { + D_EVENT: '#root.DONE' + } + } + } + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'D_EVENT' + }); + + expect(flushTracked()).toEqual([ + 'exit: A.B.C.D', + 'exit: A.B.C', + 'exit: A.B', + 'exit: A', + 'enter: DONE' + ]); + }); + + it('should exit substate when machine handles event (MACHINE_EVENT)', () => { + const machine = next_createMachine({ + id: 'deep', + initial: 'A', + on: { + MACHINE_EVENT: '#deep.DONE' + }, + states: { + DONE: {}, + A: { + initial: 'B', + states: { + B: { + initial: 'C', + states: { + C: { + initial: 'D', + states: { + D: {} + } + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'MACHINE_EVENT' + }); + + expect(flushTracked()).toEqual([ + 'exit: A.B.C.D', + 'exit: A.B.C', + 'exit: A.B', + 'exit: A', + 'enter: DONE' + ]); + }); + + it('should exit deep and enter deep (A_S)', () => { + const machine = next_createMachine({ + id: 'root', + initial: 'A', + states: { + A: { + on: { + A_S: '#root.P.Q.R.S' + }, + initial: 'B', + states: { + B: { + initial: 'C', + states: { + C: { + initial: 'D', + states: { + D: {} + } + } + } + } + } + }, + P: { + initial: 'Q', + states: { + Q: { + initial: 'R', + states: { + R: { + initial: 'S', + states: { + S: {} + } + } + } + } + } + } + } + }); + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'A_S' + }); + + expect(flushTracked()).toEqual([ + 'exit: A.B.C.D', + 'exit: A.B.C', + 'exit: A.B', + 'exit: A', + 'enter: P', + 'enter: P.Q', + 'enter: P.Q.R', + 'enter: P.Q.R.S' + ]); + }); + + it('should exit deep and enter deep (D_P)', () => { + const machine = next_createMachine({ + id: 'deep', + initial: 'A', + states: { + A: { + initial: 'B', + states: { + B: { + initial: 'C', + states: { + C: { + initial: 'D', + states: { + D: { + on: { + D_P: '#deep.P' + } + } + } + } + } + } + } + }, + P: { + initial: 'Q', + states: { + Q: { + initial: 'R', + states: { + R: { + initial: 'S', + states: { + S: {} + } + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'D_P' + }); + + expect(flushTracked()).toEqual([ + 'exit: A.B.C.D', + 'exit: A.B.C', + 'exit: A.B', + 'exit: A', + 'enter: P', + 'enter: P.Q', + 'enter: P.Q.R', + 'enter: P.Q.R.S' + ]); + }); + + it('should exit deep and enter deep when targeting an ancestor of the final resolved deep target', () => { + const machine = next_createMachine({ + id: 'root', + initial: 'A', + states: { + A: { + on: { + A_P: '#root.P' + }, + initial: 'B', + states: { + B: { + initial: 'C', + states: { + C: { + initial: 'D', + states: { + D: {} + } + } + } + } + } + }, + P: { + initial: 'Q', + states: { + Q: { + initial: 'R', + states: { + R: { + initial: 'S', + states: { + S: {} + } + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'A_P' + }); + + expect(flushTracked()).toEqual([ + 'exit: A.B.C.D', + 'exit: A.B.C', + 'exit: A.B', + 'exit: A', + 'enter: P', + 'enter: P.Q', + 'enter: P.Q.R', + 'enter: P.Q.R.S' + ]); + }); + + it('should exit deep and enter deep when targeting a deep state', () => { + const machine = next_createMachine({ + id: 'root', + initial: 'A', + states: { + A: { + initial: 'B', + states: { + B: { + initial: 'C', + states: { + C: { + initial: 'D', + states: { + D: { + on: { + D_S: '#root.P.Q.R.S' + } + } + } + } + } + } + } + }, + P: { + initial: 'Q', + states: { + Q: { + initial: 'R', + states: { + R: { + initial: 'S', + states: { + S: {} + } + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'D_S' + }); + + expect(flushTracked()).toEqual([ + 'exit: A.B.C.D', + 'exit: A.B.C', + 'exit: A.B', + 'exit: A', + 'enter: P', + 'enter: P.Q', + 'enter: P.Q.R', + 'enter: P.Q.R.S' + ]); + }); + }); +}); diff --git a/packages/core/test/definition.v6.test.ts b/packages/core/test/definition.v6.test.ts new file mode 100644 index 0000000000..fab3c70e1d --- /dev/null +++ b/packages/core/test/definition.v6.test.ts @@ -0,0 +1,27 @@ +import { AnyActorLogic, next_createMachine } from '../src/index.ts'; + +describe('definition', () => { + it('should provide invoke definitions', () => { + const invokeMachine = next_createMachine({ + // types: {} as { + // actors: + // | { + // src: 'foo'; + // logic: AnyActorLogic; + // } + // | { + // src: 'bar'; + // logic: AnyActorLogic; + // }; + // }, + id: 'invoke', + invoke: [{ src: 'foo' }, { src: 'bar' }], + initial: 'idle', + states: { + idle: {} + } + }); + + expect(invokeMachine.root.definition.invoke.length).toBe(2); + }); +}); diff --git a/packages/core/test/deterministic.v6.test.ts b/packages/core/test/deterministic.v6.test.ts new file mode 100644 index 0000000000..e1416f3511 --- /dev/null +++ b/packages/core/test/deterministic.v6.test.ts @@ -0,0 +1,294 @@ +import { + fromCallback, + createActor, + transition, + createMachine, + initialTransition +} from '../src/index.ts'; + +describe('deterministic machine', () => { + const lightMachine = createMachine({ + initial: 'green', + states: { + green: { + on: { + TIMER: 'yellow', + POWER_OUTAGE: 'red' + } + }, + yellow: { + on: { + TIMER: 'red', + POWER_OUTAGE: 'red' + } + }, + red: { + on: { + TIMER: 'green', + POWER_OUTAGE: 'red' + }, + initial: 'walk', + states: { + walk: { + on: { + PED_COUNTDOWN: 'wait', + TIMER: undefined // forbidden event + } + }, + wait: { + on: { + PED_COUNTDOWN: 'stop', + TIMER: undefined // forbidden event + } + }, + stop: {} + } + } + } + }); + + const testMachine = createMachine({ + initial: 'a', + states: { + a: { + on: { + T: 'b.b1', + F: 'c' + } + }, + b: { + initial: 'b1', + states: { + b1: {} + } + }, + c: {} + } + }); + + describe('machine transitions', () => { + it('should properly transition states based on event-like object', () => { + expect( + transition( + lightMachine, + lightMachine.resolveState({ value: 'green' }), + { + type: 'TIMER' + } + )[0].value + ).toEqual('yellow'); + }); + + it('should not transition states for illegal transitions', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + const previousSnapshot = actor.getSnapshot(); + + actor.send({ + type: 'FAKE' + }); + + expect(actor.getSnapshot().value).toBe('a'); + expect(actor.getSnapshot()).toBe(previousSnapshot); + }); + + it('should throw an error if not given an event', () => { + expect(() => + transition( + lightMachine, + testMachine.resolveState({ value: 'red' }), + undefined as any + ) + ).toThrow(); + }); + + it('should transition to nested states as target', () => { + expect( + transition(testMachine, testMachine.resolveState({ value: 'a' }), { + type: 'T' + })[0].value + ).toEqual({ + b: 'b1' + }); + }); + + it('should throw an error for transitions from invalid states', () => { + expect(() => + transition(testMachine, testMachine.resolveState({ value: 'fake' }), { + type: 'T' + }) + ).toThrow(); + }); + + it('should throw an error for transitions from invalid substates', () => { + expect(() => + transition(testMachine, testMachine.resolveState({ value: 'a.fake' }), { + type: 'T' + }) + ).toThrow(); + }); + + it('should use the machine.initialState when an undefined state is given', () => { + const [init] = initialTransition(lightMachine, undefined); + expect( + transition(lightMachine, init, { type: 'TIMER' })[0].value + ).toEqual('yellow'); + }); + + it('should use the machine.initialState when an undefined state is given (unhandled event)', () => { + const [init] = initialTransition(lightMachine, undefined); + expect( + transition(lightMachine, init, { type: 'TIMER' })[0].value + ).toEqual('yellow'); + }); + }); + + describe('machine transition with nested states', () => { + it('should properly transition a nested state', () => { + expect( + transition( + lightMachine, + lightMachine.resolveState({ value: { red: 'walk' } }), + { type: 'PED_COUNTDOWN' } + )[0].value + ).toEqual({ red: 'wait' }); + }); + + it('should transition from initial nested states', () => { + expect( + transition(lightMachine, lightMachine.resolveState({ value: 'red' }), { + type: 'PED_COUNTDOWN' + })[0].value + ).toEqual({ + red: 'wait' + }); + }); + + it('should transition from deep initial nested states', () => { + expect( + transition(lightMachine, lightMachine.resolveState({ value: 'red' }), { + type: 'PED_COUNTDOWN' + })[0].value + ).toEqual({ + red: 'wait' + }); + }); + + it('should bubble up events that nested states cannot handle', () => { + expect( + transition( + lightMachine, + lightMachine.resolveState({ value: { red: 'stop' } }), + { type: 'TIMER' } + )[0].value + ).toEqual('green'); + }); + + it('should not transition from illegal events', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + on: { NEXT: 'c' } + }, + c: {} + } + } + } + }); + + const actor = createActor(machine).start(); + + const previousSnapshot = actor.getSnapshot(); + + actor.send({ + type: 'FAKE' + }); + + expect(actor.getSnapshot().value).toEqual({ a: 'b' }); + expect(actor.getSnapshot()).toBe(previousSnapshot); + }); + + it('should transition to the deepest initial state', () => { + expect( + transition( + lightMachine, + lightMachine.resolveState({ value: 'yellow' }), + { + type: 'TIMER' + } + )[0].value + ).toEqual({ + red: 'walk' + }); + }); + + it('should return the same state if no transition occurs', () => { + const [init] = initialTransition(lightMachine, undefined); + const [initialState] = transition(lightMachine, init, { + type: 'NOTHING' + }); + const [nextState] = transition(lightMachine, initialState, { + type: 'NOTHING' + }); + + expect(initialState.value).toEqual(nextState.value); + expect(nextState).toBe(initialState); + }); + }); + + describe('state key names', () => { + const machine = createMachine( + { + initial: 'test', + states: { + test: { + invoke: [{ src: 'activity' }], + entry: ['onEntry'], + on: { + NEXT: 'test' + }, + exit: ['onExit'] + } + } + }, + { + actors: { + activity: fromCallback(() => () => {}) + } + } + ); + + it('should work with substate nodes that have the same key', () => { + const [init] = initialTransition(machine, undefined); + expect(transition(machine, init, { type: 'NEXT' })[0].value).toEqual( + 'test' + ); + }); + }); + + describe('forbidden events', () => { + it('undefined transitions should forbid events', () => { + const [walkState] = transition( + lightMachine, + lightMachine.resolveState({ value: { red: 'walk' } }), + { type: 'TIMER' } + ); + + expect(walkState.value).toEqual({ red: 'walk' }); + }); + }); +}); diff --git a/packages/core/test/emit.v6.test.ts b/packages/core/test/emit.v6.test.ts new file mode 100644 index 0000000000..5f615c3bcf --- /dev/null +++ b/packages/core/test/emit.v6.test.ts @@ -0,0 +1,517 @@ +import { + AnyEventObject, + createActor, + createMachine, + fromCallback, + fromEventObservable, + fromObservable, + fromPromise, + fromTransition, + setup +} from '../src'; + +describe('event emitter', () => { + it('only emits expected events if specified in setup', () => { + setup({ + types: { + emitted: {} as { type: 'greet'; message: string } + } + }).createMachine({ + entry2: (_, enq) => { + enq.emit({ + // @ts-expect-error + type: 'nonsense' + }); + }, + exit2: (_, enq) => { + enq.emit({ + type: 'greet', + // @ts-expect-error + message: 1234 + }); + }, + + on: { + someEvent: (_, enq) => { + enq.emit({ + type: 'greet', + message: 'hello' + }); + } + } + }); + }); + + it('emits any events if not specified in setup (unsafe)', () => { + createMachine({ + entry2: (_, enq) => { + enq.emit({ + type: 'nonsense' + }); + }, + // exit: emit({ type: 'greet', message: 1234 }), + exit2: (_, enq) => { + enq.emit({ + type: 'greet', + // @ts-ignore + message: 1234 + }); + }, + on: { + someEvent: (_, enq) => { + enq.emit({ + type: 'greet', + // @ts-ignore + message: 'hello' + }); + } + } + }); + }); + + it('emits events that can be listened to on actorRef.on(…)', async () => { + const machine = setup({ + types: { + emitted: {} as { type: 'emitted'; foo: string } + } + }).createMachine({ + on: { + someEvent: (_, enq) => { + enq.action(() => {}); + enq.emit({ + type: 'emitted', + foo: 'bar' + }); + } + } + }); + + const actor = createActor(machine).start(); + setTimeout(() => { + actor.send({ + type: 'someEvent' + }); + }); + const event = await new Promise((res) => { + actor.on('emitted', res); + }); + + expect(event.foo).toBe('bar'); + }); + + it('enqueue.emit(…) emits events that can be listened to on actorRef.on(…)', async () => { + const machine = setup({ + types: { + emitted: {} as { type: 'emitted'; foo: string } + } + }).createMachine({ + on: { + someEvent: (_, enq) => { + enq.emit({ + type: 'emitted', + foo: 'bar' + }); + + enq.emit({ + // @ts-expect-error + type: 'unknown' + }); + } + } + }); + + const actor = createActor(machine).start(); + setTimeout(() => { + actor.send({ + type: 'someEvent' + }); + }); + const event = await new Promise((res) => { + actor.on('emitted', res); + }); + + expect(event.foo).toBe('bar'); + }); + + it('handles errors', async () => { + const machine = setup({ + types: { + emitted: {} as { type: 'emitted'; foo: string } + } + }).createMachine({ + on: { + someEvent: (_, enq) => { + enq.emit({ + type: 'emitted', + foo: 'bar' + }); + } + } + }); + + const actor = createActor(machine).start(); + actor.on('emitted', () => { + throw new Error('oops'); + }); + setTimeout(() => { + actor.send({ + type: 'someEvent' + }); + }); + const err = await new Promise((res) => + actor.subscribe({ + error: res + }) + ); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toEqual('oops'); + }); + + it('dynamically emits events that can be listened to on actorRef.on(…)', async () => { + const machine = createMachine({ + context: { count: 10 }, + on: { + someEvent: ({ context }, enq) => { + enq.emit({ + type: 'emitted', + // @ts-ignore + count: context.count + }); + } + } + }); + + const actor = createActor(machine).start(); + setTimeout(() => { + actor.send({ + type: 'someEvent' + }); + }); + const event = await new Promise((res) => { + actor.on('emitted', res); + }); + + expect(event).toEqual({ + type: 'emitted', + count: 10 + }); + }); + + it('listener should be able to read the updated snapshot of the emitting actor', () => { + const spy = jest.fn(); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + ev: (_, enq) => { + enq.emit({ + type: 'someEvent' + }); + + return { + target: 'b' + }; + } + } + }, + b: {} + } + }); + + const actor = createActor(machine); + actor.on('someEvent', () => { + spy(actor.getSnapshot().value); + }); + + actor.start(); + actor.send({ type: 'ev' }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('b'); + }); + + it('wildcard listeners should be able to receive all emitted events', () => { + const spy = jest.fn(); + + const machine = setup({ + types: { + events: {} as { type: 'event' }, + emitted: {} as { type: 'emitted' } | { type: 'anotherEmitted' } + } + }).createMachine({ + on: { + event: (_, enq) => { + enq.emit({ + type: 'emitted' + }); + } + } + }); + + const actor = createActor(machine); + + actor.on('*', (ev) => { + ev.type satisfies 'emitted' | 'anotherEmitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + spy(ev); + }); + + actor.start(); + + actor.send({ type: 'event' }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('events can be emitted from promise logic', () => { + const spy = jest.fn(); + + const logic = fromPromise( + async ({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + } + ); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from transition logic', () => { + const spy = jest.fn(); + + const logic = fromTransition< + any, + any, + any, + any, + { type: 'emitted'; msg: string } + >((s, e, { emit }) => { + if (e.type === 'emit') { + emit({ + type: 'emitted', + msg: 'hello' + }); + } + return s; + }, {}); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + actor.send({ type: 'emit' }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from observable logic', () => { + const spy = jest.fn(); + + const logic = fromObservable( + ({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + + return { + subscribe: () => { + return { + unsubscribe: () => {} + }; + } + }; + } + ); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from event observable logic', () => { + const spy = jest.fn(); + + const logic = fromEventObservable< + any, + any, + { type: 'emitted'; msg: string } + >(({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + + return { + subscribe: () => { + return { + unsubscribe: () => {} + }; + } + }; + }); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from callback logic', () => { + const spy = jest.fn(); + + const logic = fromCallback( + ({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + } + ); + + const actor = createActor(logic); + + actor.on('emitted', (ev) => { + ev.type satisfies 'emitted'; + + // @ts-expect-error + ev.type satisfies 'whatever'; + + ev satisfies { msg: string }; + + spy(ev); + }); + + actor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); + + it('events can be emitted from callback logic (restored root)', () => { + const spy = jest.fn(); + + const logic = fromCallback( + ({ emit }) => { + emit({ + type: 'emitted', + msg: 'hello' + }); + } + ); + + const machine = setup({ + actors: { logic } + }).createMachine({ + invoke: { + id: 'cb', + src: 'logic' + } + }); + + const actor = createActor(machine); + + // Persist the root actor + const persistedSnapshot = actor.getPersistedSnapshot(); + + // Rehydrate a new instance of the root actor using the persisted snapshot + const restoredActor = createActor(machine, { + snapshot: persistedSnapshot + }); + + restoredActor.getSnapshot().children.cb!.on('emitted', (ev) => { + spy(ev); + }); + + restoredActor.start(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'emitted', + msg: 'hello' + }) + ); + }); +}); diff --git a/packages/core/test/errors.v6.test.ts b/packages/core/test/errors.v6.test.ts new file mode 100644 index 0000000000..ee9ddc71d7 --- /dev/null +++ b/packages/core/test/errors.v6.test.ts @@ -0,0 +1,897 @@ +import { sleep } from '@xstate-repo/jest-utils'; +import { + createActor, + createMachine, + fromCallback, + fromPromise, + fromTransition +} from '../src'; + +const cleanups: (() => void)[] = []; +function installGlobalOnErrorHandler(handler: (ev: ErrorEvent) => void) { + window.addEventListener('error', handler); + cleanups.push(() => window.removeEventListener('error', handler)); +} + +afterEach(() => { + cleanups.forEach((cleanup) => cleanup()); + cleanups.length = 0; +}); + +describe('error handling', () => { + // https://github.com/statelyai/xstate/issues/4004 + it('does not cause an infinite loop when an error is thrown in subscribe', (done) => { + const machine = createMachine({ + id: 'machine', + initial: 'initial', + context: { + count: 0 + }, + states: { + initial: { + on: { activate: 'active' } + }, + active: {} + } + }); + + const spy = jest.fn().mockImplementation(() => { + throw new Error('no_infinite_loop_when_error_is_thrown_in_subscribe'); + }); + + const actor = createActor(machine).start(); + + actor.subscribe(spy); + actor.send({ type: 'activate' }); + + expect(spy).toHaveBeenCalledTimes(1); + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + expect(ev.error.message).toEqual( + 'no_infinite_loop_when_error_is_thrown_in_subscribe' + ); + done(); + }); + }); + + it(`doesn't crash the actor when an error is thrown in subscribe`, (done) => { + const spy = jest.fn(); + + const machine = createMachine({ + id: 'machine', + initial: 'initial', + context: { + count: 0 + }, + states: { + initial: { + on: { activate: 'active' } + }, + active: { + on: { + do: (_, enq) => { + enq.action(spy); + } + } + } + } + }); + + const subscriber = jest.fn().mockImplementationOnce(() => { + throw new Error('doesnt_crash_actor_when_error_is_thrown_in_subscribe'); + }); + + const actor = createActor(machine).start(); + + actor.subscribe(subscriber); + actor.send({ type: 'activate' }); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(actor.getSnapshot().status).toEqual('active'); + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + expect(ev.error.message).toEqual( + 'doesnt_crash_actor_when_error_is_thrown_in_subscribe' + ); + + actor.send({ type: 'do' }); + expect(spy).toHaveBeenCalledTimes(1); + + done(); + }); + }); + + it(`doesn't notify error listener when an error is thrown in subscribe`, (done) => { + const machine = createMachine({ + id: 'machine', + initial: 'initial', + context: { + count: 0 + }, + states: { + initial: { + on: { activate: 'active' } + }, + active: {} + } + }); + + const nextSpy = jest.fn().mockImplementation(() => { + throw new Error( + 'doesnt_notify_error_listener_when_error_is_thrown_in_subscribe' + ); + }); + const errorSpy = jest.fn(); + + const actor = createActor(machine).start(); + + actor.subscribe({ + next: nextSpy, + error: errorSpy + }); + actor.send({ type: 'activate' }); + + expect(nextSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(0); + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + expect(ev.error.message).toEqual( + 'doesnt_notify_error_listener_when_error_is_thrown_in_subscribe' + ); + done(); + }); + }); + + it('unhandled sync errors thrown when starting a child actor should be reported globally', (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error('unhandled_sync_error_in_actor_start'); + }), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + createActor(machine).start(); + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + expect(ev.error.message).toEqual('unhandled_sync_error_in_actor_start'); + done(); + }); + }); + + it('unhandled rejection of a promise actor should be reported globally in absence of error listener', (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromPromise(() => + Promise.reject( + new Error( + 'unhandled_rejection_in_promise_actor_without_error_listener' + ) + ) + ), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + createActor(machine).start(); + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + expect(ev.error.message).toEqual( + 'unhandled_rejection_in_promise_actor_without_error_listener' + ); + done(); + }); + }); + + it('unhandled rejection of a promise actor should be reported to the existing error listener of its parent', async () => { + const errorSpy = jest.fn(); + + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromPromise(() => + Promise.reject( + new Error( + 'unhandled_rejection_in_promise_actor_with_parent_listener' + ) + ) + ), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + await sleep(0); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: unhandled_rejection_in_promise_actor_with_parent_listener], + ], + ] + `); + }); + + it('unhandled rejection of a promise actor should be reported to the existing error listener of its grandparent', async () => { + const errorSpy = jest.fn(); + + const child = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromPromise(() => + Promise.reject( + new Error( + 'unhandled_rejection_in_promise_actor_with_grandparent_listener' + ) + ) + ), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: child, + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + await sleep(0); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: unhandled_rejection_in_promise_actor_with_grandparent_listener], + ], + ] + `); + }); + + it('handled sync errors thrown when starting a child actor should not be reported globally', (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error('handled_sync_error_in_actor_start'); + }), + onError: 'failed' + } + }, + failed: { + type: 'final' + } + } + }); + + createActor(machine).start(); + + installGlobalOnErrorHandler(() => { + done.fail(); + }); + + setTimeout(() => { + done(); + }, 10); + }); + + it('handled sync errors thrown when starting a child actor should be reported globally when not all of its own observers come with an error listener', (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error('handled_sync_error_in_actor_start'); + }), + onError: 'failed' + } + }, + failed: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine); + const childActorRef = Object.values(actorRef.getSnapshot().children)[0]; + childActorRef.subscribe({ + error: function preventUnhandledErrorListener() {} + }); + childActorRef.subscribe(() => {}); + actorRef.start(); + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + expect(ev.error.message).toEqual('handled_sync_error_in_actor_start'); + done(); + }); + }); + + it('handled sync errors thrown when starting a child actor should not be reported globally when all of its own observers come with an error listener', (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error('handled_sync_error_in_actor_start'); + }), + onError: 'failed' + } + }, + failed: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine); + const childActorRef = Object.values(actorRef.getSnapshot().children)[0]; + childActorRef.subscribe({ + error: function preventUnhandledErrorListener() {} + }); + childActorRef.subscribe({ + error: function preventUnhandledErrorListener() {} + }); + actorRef.start(); + + installGlobalOnErrorHandler(() => { + done.fail(); + }); + + setTimeout(() => { + done(); + }, 10); + }); + + it('unhandled sync errors thrown when starting a child actor should be reported twice globally when not all of its own observers come with an error listener and when the root has no error listener of its own', (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error('handled_sync_error_in_actor_start'); + }) + } + } + } + }); + + const actorRef = createActor(machine); + const childActorRef = Object.values(actorRef.getSnapshot().children)[0]; + childActorRef.subscribe({ + error: function preventUnhandledErrorListener() {} + }); + childActorRef.subscribe({}); + actorRef.start(); + + const actual: string[] = []; + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + actual.push(ev.error.message); + + if (actual.length === 2) { + expect(actual).toEqual([ + 'handled_sync_error_in_actor_start', + 'handled_sync_error_in_actor_start' + ]); + done(); + } + }); + }); + + it(`handled sync errors shouldn't notify the error listener`, () => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error('handled_sync_error_in_actor_start'); + }), + onError: 'failed' + } + }, + failed: { + type: 'final' + } + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toHaveBeenCalledTimes(0); + }); + + it(`unhandled sync errors should notify the root error listener`, () => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error( + 'unhandled_sync_error_in_actor_start_with_root_error_listener' + ); + }), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: unhandled_sync_error_in_actor_start_with_root_error_listener], + ], + ] + `); + }); + + it(`unhandled sync errors should not notify the global listener when the root error listener is present`, (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error( + 'unhandled_sync_error_in_actor_start_with_root_error_listener' + ); + }), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + + installGlobalOnErrorHandler(() => { + done.fail(); + }); + + setTimeout(() => { + done(); + }, 10); + }); + + it(`handled sync errors thrown when starting an actor shouldn't crash the parent`, () => { + const spy = jest.fn(); + + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error('handled_sync_error_in_actor_start'); + }), + onError: 'failed' + } + }, + failed: { + on: { + do: (_, enq) => { + enq.action(spy); + } + } + } + } + }); + + const actorRef = createActor(machine); + actorRef.start(); + + expect(actorRef.getSnapshot().status).toBe('active'); + + actorRef.send({ type: 'do' }); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it(`unhandled sync errors thrown when starting an actor should crash the parent`, (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error('unhandled_sync_error_in_actor_start'); + }) + } + } + } + }); + + const actorRef = createActor(machine); + actorRef.start(); + + expect(actorRef.getSnapshot().status).toBe('error'); + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + expect(ev.error.message).toEqual('unhandled_sync_error_in_actor_start'); + done(); + }); + }); + + it(`error thrown by the error listener should be reported globally`, (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error('handled_sync_error_in_actor_start'); + }) + } + } + } + }); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: () => { + throw new Error('error_thrown_by_error_listener'); + } + }); + actorRef.start(); + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + expect(ev.error.message).toEqual('error_thrown_by_error_listener'); + done(); + }); + }); + + it(`error should be reported globally if not every observer comes with an error listener`, (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error( + 'error_thrown_when_not_every_observer_comes_with_an_error_listener' + ); + }) + } + } + } + }); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: function preventUnhandledErrorListener() {} + }); + actorRef.subscribe(() => {}); + actorRef.start(); + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + expect(ev.error.message).toEqual( + 'error_thrown_when_not_every_observer_comes_with_an_error_listener' + ); + done(); + }); + }); + + it(`uncaught error and an error thrown by the error listener should both be reported globally when not every observer comes with an error listener`, (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromCallback(() => { + throw new Error( + 'error_thrown_when_not_every_observer_comes_with_an_error_listener' + ); + }) + } + } + } + }); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: () => { + throw new Error('error_thrown_by_error_listener'); + } + }); + actorRef.subscribe(() => {}); + actorRef.start(); + + let actual: string[] = []; + + installGlobalOnErrorHandler((ev) => { + ev.preventDefault(); + actual.push(ev.error.message); + + if (actual.length === 2) { + expect(actual).toEqual([ + 'error_thrown_by_error_listener', + 'error_thrown_when_not_every_observer_comes_with_an_error_listener' + ]); + done(); + } + }); + }); + + it('error thrown in initial custom entry action should error the actor', () => { + const machine = createMachine({ + entry2: () => { + throw new Error('error_thrown_in_initial_entry_action'); + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + const snapshot = actorRef.getSnapshot(); + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot( + `[Error: error_thrown_in_initial_entry_action]` + ); + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: error_thrown_in_initial_entry_action], + ], + ] + `); + }); + + it('error thrown when resolving initial builtin entry action should error the actor immediately', () => { + const machine = createMachine({ + entry2: () => { + throw new Error('error_thrown_when_resolving_initial_entry_action'); + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + + const snapshot = actorRef.getSnapshot(); + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot( + `[Error: error_thrown_when_resolving_initial_entry_action]` + ); + + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: error_thrown_when_resolving_initial_entry_action], + ], + ] + `); + }); + + it('error thrown by a custom entry action when transitioning should error the actor', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + entry2: () => { + throw new Error( + 'error_thrown_in_a_custom_entry_action_when_transitioning' + ); + } + } + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + actorRef.send({ type: 'NEXT' }); + + const snapshot = actorRef.getSnapshot(); + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot( + `[Error: error_thrown_in_a_custom_entry_action_when_transitioning]` + ); + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: error_thrown_in_a_custom_entry_action_when_transitioning], + ], + ] + `); + }); + + it(`shouldn't execute deferred initial actions that come after an action that errors`, () => { + const spy = jest.fn(); + + const machine = createMachine({ + entry2: () => { + throw new Error('error_thrown_in_initial_entry_action_2'); + spy(); + } + }); + + const actorRef = createActor(machine); + actorRef.subscribe({ error: function preventUnhandledErrorListener() {} }); + actorRef.start(); + + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('should error the parent on errored initial state of a child', async () => { + const immediateFailure = fromTransition((_) => undefined, undefined); + immediateFailure.getInitialSnapshot = () => ({ + status: 'error', + output: undefined, + error: 'immediate error!', + context: undefined + }); + + const machine = createMachine( + { + invoke: { + src: 'failure' + } + }, + { + actors: { + failure: immediateFailure + } + } + ); + + const actorRef = createActor(machine); + actorRef.subscribe({ error: function preventUnhandledErrorListener() {} }); + actorRef.start(); + + const snapshot = actorRef.getSnapshot(); + + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toBe('immediate error!'); + }); + + it('should error when a guard throws when transitioning', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: () => { + // this is a bit silly, but just here to show the equivalence + if ( + (() => { + throw new Error('error_thrown_in_guard_when_transitioning'); + })() + ) { + return { + target: 'b' + }; + } + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: spy + }); + actorRef.start(); + actorRef.send({ type: 'NEXT' }); + + const snapshot = actorRef.getSnapshot(); + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot( + `[Error: error_thrown_in_guard_when_transitioning]` + ); + }); +}); diff --git a/packages/core/test/event.v6.test.ts b/packages/core/test/event.v6.test.ts new file mode 100644 index 0000000000..e9cb673e3b --- /dev/null +++ b/packages/core/test/event.v6.test.ts @@ -0,0 +1,131 @@ +import { createMachine, createActor, AnyActorRef } from '../src/index.ts'; + +describe('events', () => { + it('should be able to respond to sender by sending self', (done) => { + const authServerMachine = createMachine({ + types: { + events: {} as { type: 'CODE'; sender: AnyActorRef } + }, + id: 'authServer', + initial: 'waitingForCode', + states: { + waitingForCode: { + on: { + CODE: ({ event }, enq) => { + expect(event.sender).toBeDefined(); + + enq.action(() => { + setTimeout(() => { + event.sender.send({ type: 'TOKEN' }); + }, 10); + }); + } + } + } + } + }); + + const authClientMachine = createMachine({ + id: 'authClient', + initial: 'idle', + states: { + idle: { + on: { AUTH: 'authorizing' } + }, + authorizing: { + invoke: { + id: 'auth-server', + src: authServerMachine + }, + entry2: ({ children, self }) => { + children['auth-server'].send({ + type: 'CODE', + sender: self + }); + }, + on: { + TOKEN: 'authorized' + } + }, + authorized: { + type: 'final' + } + } + }); + + const service = createActor(authClientMachine); + service.subscribe({ complete: () => done() }); + service.start(); + + service.send({ type: 'AUTH' }); + }); +}); + +describe('nested transitions', () => { + it('only take the transition of the most inner matching event', () => { + interface SignInContext { + email: string; + password: string; + } + + interface ChangePassword { + type: 'changePassword'; + password: string; + } + + const assignPassword = ( + context: SignInContext, + password: string + ): SignInContext => ({ + ...context, + password + }); + + const authMachine = createMachine({ + types: {} as { context: SignInContext; events: ChangePassword }, + context: { email: '', password: '' }, + initial: 'passwordField', + states: { + passwordField: { + initial: 'hidden', + states: { + hidden: { + on: { + // We want to assign the new password but remain in the hidden + // state + changePassword: ({ context, event }) => ({ + context: assignPassword(context, event.password) + }) + } + }, + valid: {}, + invalid: {} + }, + on: { + changePassword: ({ context, event }, enq) => { + const ctx = assignPassword(context, event.password); + if (event.password.length >= 10) { + return { + target: '.invalid', + context: ctx + }; + } + + return { + target: '.valid', + context: ctx + }; + } + } + } + } + }); + const password = 'xstate123'; + const actorRef = createActor(authMachine).start(); + actorRef.send({ type: 'changePassword', password }); + + const snapshot = actorRef.getSnapshot(); + expect(snapshot.value).toEqual({ passwordField: 'hidden' }); + expect(snapshot.context).toEqual({ password, email: '' }); + }); +}); diff --git a/packages/core/test/eventDescriptors.v6.test.ts b/packages/core/test/eventDescriptors.v6.test.ts new file mode 100644 index 0000000000..898a8d7376 --- /dev/null +++ b/packages/core/test/eventDescriptors.v6.test.ts @@ -0,0 +1,354 @@ +import { createMachine, createActor } from '../src/index'; + +describe('event descriptors', () => { + it('should fallback to using wildcard transition definition (if specified)', () => { + const machine = createMachine({ + initial: 'A', + states: { + A: { + on: { + FOO: 'B', + '*': 'C' + } + }, + B: {}, + C: {} + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'BAR' }); + expect(service.getSnapshot().value).toBe('C'); + }); + + it('should prioritize explicit descriptor even if wildcard comes first', () => { + const machine = createMachine({ + initial: 'A', + states: { + A: { + on: { + '*': 'fail', + NEXT: 'pass' + } + }, + fail: {}, + pass: {} + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + expect(service.getSnapshot().value).toBe('pass'); + }); + + it('should prioritize explicit descriptor even if a partial one comes first', () => { + const machine = createMachine({ + initial: 'A', + states: { + A: { + on: { + 'foo.*': 'fail', + 'foo.bar': 'pass' + } + }, + fail: {}, + pass: {} + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'foo.bar' }); + expect(service.getSnapshot().value).toBe('pass'); + }); + + it('should prioritize a longer descriptor even if the shorter one comes first', () => { + const machine = createMachine({ + initial: 'A', + states: { + A: { + on: { + 'foo.*': 'fail', + 'foo.bar.*': 'pass' + } + }, + fail: {}, + pass: {} + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'foo.bar.baz' }); + expect(service.getSnapshot().value).toBe('pass'); + }); + + it(`should use a shorter descriptor if the longer one doesn't match`, () => { + const machine = createMachine({ + initial: 'A', + states: { + A: { + on: { + 'foo.bar.*': () => { + if (1 + 1 === 3) { + return { + target: 'fail' + }; + } + }, + 'foo.*': 'pass' + } + }, + fail: {}, + pass: {} + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'foo.bar.baz' }); + expect(service.getSnapshot().value).toBe('pass'); + }); + + it('should NOT support non-tokenized wildcards', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + 'event*': 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actorRef1 = createActor(machine).start(); + + actorRef1.send({ type: 'event' }); + + expect(actorRef1.getSnapshot().matches('success')).toBeFalsy(); + + const actorRef2 = createActor(machine).start(); + + actorRef2.send({ type: 'eventually' }); + + expect(actorRef2.getSnapshot().matches('success')).toBeFalsy(); + }); + + it('should support prefix matching with wildcards (+0)', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + 'event.*': 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actorRef1 = createActor(machine).start(); + + actorRef1.send({ type: 'event' }); + + expect(actorRef1.getSnapshot().matches('success')).toBeTruthy(); + + const actorRef2 = createActor(machine).start(); + + actorRef2.send({ type: 'eventually' }); + + expect(actorRef2.getSnapshot().matches('success')).toBeFalsy(); + }); + + it('should support prefix matching with wildcards (+1)', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + 'event.*': 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actorRef1 = createActor(machine).start(); + + actorRef1.send({ type: 'event.whatever' }); + + expect(actorRef1.getSnapshot().matches('success')).toBeTruthy(); + + const actorRef2 = createActor(machine).start(); + + actorRef2.send({ type: 'eventually' }); + + expect(actorRef2.getSnapshot().matches('success')).toBeFalsy(); + + const actorRef3 = createActor(machine).start(); + + actorRef3.send({ type: 'eventually.event' }); + + expect(actorRef3.getSnapshot().matches('success')).toBeFalsy(); + }); + + it('should support prefix matching with wildcards (+n)', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + 'event.*': 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'event.first.second' }); + + expect(actorRef.getSnapshot().matches('success')).toBeTruthy(); + }); + + it('should support prefix matching with wildcards (+n, multi-prefix)', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + 'event.foo.bar.*': 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'event.foo.bar.first.second' }); + + expect(actorRef.getSnapshot().matches('success')).toBeTruthy(); + }); + + it('should not match infix wildcards', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + 'event.*.bar.*': 'success', + '*.event.*': 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actorRef1 = createActor(machine).start(); + + actorRef1.send({ type: 'event.foo.bar.first.second' }); + + expect(actorRef1.getSnapshot().matches('success')).toBeFalsy(); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` + [ + [ + "Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "event.*.bar.*" event.", + ], + [ + "Infix wildcards in transition events are not allowed. Check the "event.*.bar.*" transition.", + ], + [ + "Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "*.event.*" event.", + ], + [ + "Infix wildcards in transition events are not allowed. Check the "*.event.*" transition.", + ], + ] + `); + + const actorRef2 = createActor(machine).start(); + + actorRef2.send({ type: 'whatever.event' }); + + expect(actorRef2.getSnapshot().matches('success')).toBeFalsy(); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` + [ + [ + "Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "event.*.bar.*" event.", + ], + [ + "Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "*.event.*" event.", + ], + [ + "Infix wildcards in transition events are not allowed. Check the "*.event.*" transition.", + ], + ] + `); + }); + + it('should not match wildcards as part of tokens', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + 'event*.bar.*': 'success', + '*event.*': 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actorRef1 = createActor(machine).start(); + + actorRef1.send({ type: 'eventually.bar.baz' }); + + expect(actorRef1.getSnapshot().matches('success')).toBeFalsy(); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` + [ + [ + "Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "event*.bar.*" event.", + ], + [ + "Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "*event.*" event.", + ], + ] + `); + + const actorRef2 = createActor(machine).start(); + + actorRef2.send({ type: 'prevent.whatever' }); + + expect(actorRef2.getSnapshot().matches('success')).toBeFalsy(); + + expect(console.warn).toMatchMockCallsInlineSnapshot(` + [ + [ + "Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "event*.bar.*" event.", + ], + [ + "Wildcards can only be the last token of an event descriptor (e.g., "event.*") or the entire event descriptor ("*"). Check the "*event.*" event.", + ], + ] + `); + }); +}); diff --git a/packages/core/test/final.v6.test.ts b/packages/core/test/final.v6.test.ts new file mode 100644 index 0000000000..2550bdf919 --- /dev/null +++ b/packages/core/test/final.v6.test.ts @@ -0,0 +1,1299 @@ +import { + createMachine, + createActor, + assign, + AnyActorRef, + sendParent +} from '../src/index.ts'; +import { trackEntries } from './utils.ts'; + +describe('final states', () => { + it('status of a machine with a root state being final should be done', () => { + const machine = createMachine({ type: 'final' }); + const actorRef = createActor(machine).start(); + + expect(actorRef.getSnapshot().status).toBe('done'); + }); + it('output of a machine with a root state being final should be called with a "xstate.done.state.ROOT_ID" event', () => { + const spy = jest.fn(); + const machine = createMachine({ + type: 'final', + output: ({ event }) => { + spy(event); + } + }); + createActor(machine, { input: 42 }).start(); + + expect(spy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "output": undefined, + "type": "xstate.done.state.(machine)", + }, + ], + ] + `); + }); + it('should emit the "xstate.done.state.*" event when all nested states are in their final states', () => { + const onDoneSpy = jest.fn(); + + const machine = createMachine({ + id: 'm', + initial: 'foo', + states: { + foo: { + type: 'parallel', + states: { + first: { + initial: 'a', + states: { + a: { + on: { NEXT_1: 'b' } + }, + b: { + type: 'final' + } + } + }, + second: { + initial: 'a', + states: { + a: { + on: { NEXT_2: 'b' } + }, + b: { + type: 'final' + } + } + } + }, + onDone: { + target: 'bar', + actions: ({ event }) => { + onDoneSpy(event.type); + } + } + }, + bar: {} + } + }); + + const actor = createActor(machine).start(); + + actor.send({ + type: 'NEXT_1' + }); + actor.send({ + type: 'NEXT_2' + }); + + expect(actor.getSnapshot().value).toBe('bar'); + expect(onDoneSpy).toHaveBeenCalledWith('xstate.done.state.m.foo'); + }); + + it('should execute final child state actions first', () => { + const actual: string[] = []; + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + initial: 'bar', + onDone: { actions: () => actual.push('fooAction') }, + states: { + bar: { + initial: 'baz', + onDone: 'barFinal', + states: { + baz: { + type: 'final', + entry2: () => { + actual.push('bazAction'); + } + } + } + }, + barFinal: { + type: 'final', + entry2: () => { + actual.push('barAction'); + } + } + } + } + } + }); + + createActor(machine).start(); + + expect(actual).toEqual(['bazAction', 'barAction', 'fooAction']); + }); + + it('should call output expressions on nested final nodes', (done) => { + interface Ctx { + revealedSecret?: string; + } + + const machine = createMachine({ + types: {} as { context: Ctx }, + initial: 'secret', + context: { + revealedSecret: undefined + }, + states: { + secret: { + initial: 'wait', + states: { + wait: { + on: { + REQUEST_SECRET: 'reveal' + } + }, + reveal: { + type: 'final', + output: () => ({ + secret: 'the secret' + }) + } + }, + onDone: { + target: 'success', + actions: assign({ + revealedSecret: ({ event }) => { + return (event.output as any).secret; + } + }) + } + }, + success: { + type: 'final' + } + } + }); + + const service = createActor(machine); + service.subscribe({ + complete: () => { + expect(service.getSnapshot().context).toEqual({ + revealedSecret: 'the secret' + }); + done(); + } + }); + service.start(); + + service.send({ type: 'REQUEST_SECRET' }); + }); + + it("should only call data expression once when entering root's final state", () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + FINISH: 'end' + } + }, + end: { + type: 'final' + } + }, + output: spy + }); + + const service = createActor(machine).start(); + service.send({ type: 'FINISH', value: 1 }); + expect(spy).toBeCalledTimes(1); + }); + + it('output mapper should receive self', () => { + const machine = createMachine({ + types: { + output: {} as { + selfRef: AnyActorRef; + } + }, + initial: 'done', + states: { + done: { + type: 'final' + } + }, + output: ({ self }) => ({ selfRef: self }) + }); + + const actor = createActor(machine).start(); + expect(actor.getSnapshot().output!.selfRef.send).toBeDefined(); + }); + + it('state output should be able to use context updated by the entry action of the reached final state', () => { + const spy = jest.fn(); + const machine = createMachine({ + context: { + count: 0 + }, + initial: 'a', + states: { + a: { + initial: 'a1', + states: { + a1: { + on: { + NEXT: 'a2' + } + }, + a2: { + type: 'final', + entry2: () => ({ + context: { count: 1 } + }), + output: ({ context }) => context.count + } + }, + onDone: { + actions: ({ event }) => { + spy(event.output); + } + } + } + } + }); + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'NEXT' }); + + expect(spy).toHaveBeenCalledWith(1); + }); + + it('should emit a done state event for a parallel state when its parallel children reach their final states', () => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + type: 'parallel', + states: { + alpha: { + type: 'parallel', + states: { + one: { + initial: 'start', + states: { + start: { + on: { + finish_one_alpha: 'finish' + } + }, + finish: { + type: 'final' + } + } + }, + two: { + initial: 'start', + states: { + start: { + on: { + finish_two_alpha: 'finish' + } + }, + finish: { + type: 'final' + } + } + } + } + }, + beta: { + type: 'parallel', + states: { + third: { + initial: 'start', + states: { + start: { + on: { + finish_three_beta: 'finish' + } + }, + finish: { + type: 'final' + } + } + }, + fourth: { + initial: 'start', + states: { + start: { + on: { + finish_four_beta: 'finish' + } + }, + finish: { + type: 'final' + } + } + } + } + } + }, + onDone: 'done' + }, + done: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ + type: 'finish_one_alpha' + }); + actorRef.send({ + type: 'finish_two_alpha' + }); + actorRef.send({ + type: 'finish_three_beta' + }); + actorRef.send({ + type: 'finish_four_beta' + }); + + expect(actorRef.getSnapshot().status).toBe('done'); + }); + + it('should emit a done state event for a parallel state when its compound child reaches its final state when the other parallel child region is already in its final state', () => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + type: 'parallel', + states: { + alpha: { + type: 'parallel', + states: { + one: { + initial: 'start', + states: { + start: { + on: { + finish_one_alpha: 'finish' + } + }, + finish: { + type: 'final' + } + } + }, + two: { + initial: 'start', + states: { + start: { + on: { + finish_two_alpha: 'finish' + } + }, + finish: { + type: 'final' + } + } + } + } + }, + beta: { + initial: 'three', + states: { + three: { + on: { + finish_beta: 'finish' + } + }, + finish: { + type: 'final' + } + } + } + }, + onDone: 'done' + }, + done: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine).start(); + + // reach final state of a parallel state + actorRef.send({ + type: 'finish_one_alpha' + }); + actorRef.send({ + type: 'finish_two_alpha' + }); + + // reach final state of a compound state + actorRef.send({ + type: 'finish_beta' + }); + + expect(actorRef.getSnapshot().status).toBe('done'); + }); + + it('should emit a done state event for a parallel state when its parallel child reaches its final state when the other compound child region is already in its final state', () => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + type: 'parallel', + states: { + alpha: { + type: 'parallel', + states: { + one: { + initial: 'start', + states: { + start: { + on: { + finish_one_alpha: 'finish' + } + }, + finish: { + type: 'final' + } + } + }, + two: { + initial: 'start', + states: { + start: { + on: { + finish_two_alpha: 'finish' + } + }, + finish: { + type: 'final' + } + } + } + } + }, + beta: { + initial: 'three', + states: { + three: { + on: { + finish_beta: 'finish' + } + }, + finish: { + type: 'final' + } + } + } + }, + onDone: 'done' + }, + done: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine).start(); + + // reach final state of a compound state + actorRef.send({ + type: 'finish_beta' + }); + + // reach final state of a parallel state + actorRef.send({ + type: 'finish_one_alpha' + }); + actorRef.send({ + type: 'finish_two_alpha' + }); + + expect(actorRef.getSnapshot().status).toBe('done'); + }); + + it('should reach a final state when a parallel state reaches its final state and transitions to a top-level final state in response to that', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + type: 'parallel', + onDone: 'b', + states: { + a1: { + type: 'parallel', + states: { + a1a: { type: 'final' }, + a1b: { type: 'final' } + } + }, + a2: { + initial: 'a2a', + states: { a2a: { type: 'final' } } + } + } + }, + b: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine).start(); + + expect(actorRef.getSnapshot().status).toEqual('done'); + }); + + it('should reach a final state when a parallel state nested in a parallel state reaches its final state and transitions to a top-level final state in response to that', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + type: 'parallel', + onDone: 'b', + states: { + a1: { + type: 'parallel', + states: { + a1a: { type: 'final' }, + a1b: { type: 'final' } + } + }, + a2: { + initial: 'a2a', + states: { a2a: { type: 'final' } } + } + } + }, + b: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine).start(); + + expect(actorRef.getSnapshot().status).toEqual('done'); + }); + it('root output should be called with a "xstate.done.state.*" event of the parallel root when a direct final child of that parallel root is reached', () => { + const spy = jest.fn(); + const machine = createMachine({ + type: 'parallel', + states: { + a: { + type: 'final' + } + }, + output: ({ event }) => { + spy(event); + } + }); + + createActor(machine).start(); + + expect(spy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "output": undefined, + "type": "xstate.done.state.(machine)", + }, + ], + ] + `); + }); + + it('root output should be called with a "xstate.done.state.*" event of the parallel root when a final child of its compound child is reached', () => { + const spy = jest.fn(); + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'b', + states: { + b: { + type: 'final' + } + } + } + }, + output: ({ event }) => { + spy(event); + } + }); + + createActor(machine).start(); + + expect(spy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "output": undefined, + "type": "xstate.done.state.(machine)", + }, + ], + ] + `); + }); + + it('root output should be called with a "xstate.done.state.*" event of the parallel root when a final descendant is reached 2 parallel levels deep', () => { + const spy = jest.fn(); + const machine = createMachine({ + type: 'parallel', + states: { + a: { + type: 'parallel', + states: { + b: { + initial: 'c', + states: { + c: { + type: 'final' + } + } + } + } + } + }, + output: ({ event }) => { + spy(event); + } + }); + + createActor(machine).start(); + + expect(spy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "output": undefined, + "type": "xstate.done.state.(machine)", + }, + ], + ] + `); + }); + + it('onDone of an outer parallel state should be called with its own "xstate.done.state.*" event when its direct parallel child completes', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'a', + states: { + a: { + type: 'parallel', + states: { + b: { + type: 'parallel', + states: { + c: { + initial: 'd', + states: { + d: { + type: 'final' + } + } + } + } + } + }, + onDone: { + actions: ({ event }) => { + spy(event); + } + } + } + } + }); + createActor(machine).start(); + + expect(spy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "output": undefined, + "type": "xstate.done.state.(machine).a", + }, + ], + ] + `); + }); + + it('onDone should not be called when the machine reaches its final state', () => { + const spy = jest.fn(); + const machine = createMachine({ + type: 'parallel', + states: { + a: { + type: 'parallel', + states: { + b: { + initial: 'c', + states: { + c: { + type: 'final' + } + }, + onDone: { + actions: spy + } + } + }, + onDone: { + actions: spy + } + } + }, + onDone: { + actions: spy + } + }); + createActor(machine).start(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('machine should not complete when a parallel child of a compound state completes', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'a', + states: { + a: { + type: 'parallel', + states: { + b: { + initial: 'c', + states: { + c: { + type: 'final' + } + } + } + } + } + } + }); + + const actorRef = createActor(machine).start(); + + expect(actorRef.getSnapshot().status).toBe('active'); + }); + + it('root output should only be called once when multiple parallel regions complete at once', () => { + const spy = jest.fn(); + + const machine = createMachine({ + type: 'parallel', + states: { + a: { + type: 'final' + }, + b: { + type: 'final' + } + }, + output: spy + }); + + createActor(machine).start(); + + expect(spy).toBeCalledTimes(1); + }); + + it('onDone of a parallel state should only be called once when multiple parallel regions complete at once', () => { + const spy = jest.fn(); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + type: 'parallel', + states: { + b: { + type: 'final' + }, + c: { + type: 'final' + } + }, + onDone: { + actions: spy + } + } + } + }); + + createActor(machine).start(); + + expect(spy).toBeCalledTimes(1); + }); + + it('should call exit actions in reversed document order when the machines reaches its final state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const flushTracked = trackEntries(machine); + + const actorRef = createActor(machine).start(); + flushTracked(); + + // it's important to send an event here that results in a transition that computes new `state._nodes` + // and that could impact the order in which exit actions are called + actorRef.send({ type: 'EV' }); + + expect(flushTracked()).toEqual([ + // result of the transition + 'exit: a', + 'enter: b', + // result of reaching final states + 'exit: b', + 'exit: __root__' + ]); + }); + + it('should call exit actions of parallel states in reversed document order when the machines reaches its final state after earlier region transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'child_a1', + states: { + child_a1: { + on: { + EV2: 'child_a2' + } + }, + child_a2: { + type: 'final' + } + } + }, + b: { + initial: 'child_b1', + states: { + child_b1: { + on: { + EV1: 'child_b2' + } + }, + child_b2: { + type: 'final' + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actorRef = createActor(machine).start(); + + // it's important to send an event here that results in a transition as that computes new `state._nodes` + // and that could impact the order in which exit actions are called + actorRef.send({ type: 'EV1' }); + flushTracked(); + actorRef.send({ type: 'EV2' }); + + expect(flushTracked()).toEqual([ + // result of the transition + 'exit: a.child_a1', + 'enter: a.child_a2', + // result of reaching final states + 'exit: b.child_b2', + 'exit: b', + 'exit: a.child_a2', + 'exit: a', + 'exit: __root__' + ]); + }); + + it('should call exit actions of parallel states in reversed document order when the machines reaches its final state after later region transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'child_a1', + states: { + child_a1: { + on: { + EV2: 'child_a2' + } + }, + child_a2: { + type: 'final' + } + } + }, + b: { + initial: 'child_b1', + states: { + child_b1: { + on: { + EV1: 'child_b2' + } + }, + child_b2: { + type: 'final' + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actorRef = createActor(machine).start(); + // it's important to send an event here that results in a transition as that computes new `state._nodes` + // and that could impact the order in which exit actions are called + actorRef.send({ type: 'EV1' }); + flushTracked(); + actorRef.send({ type: 'EV2' }); + + expect(flushTracked()).toEqual([ + // result of the transition + 'exit: a.child_a1', + 'enter: a.child_a2', + // result of reaching final states + 'exit: b.child_b2', + 'exit: b', + 'exit: a.child_a2', + 'exit: a', + 'exit: __root__' + ]); + }); + + it('should call exit actions of parallel states in reversed document order when the machines reaches its final state after multiple regions transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'child_a1', + states: { + child_a1: { + on: { + EV: 'child_a2' + } + }, + child_a2: { + type: 'final' + } + } + }, + b: { + initial: 'child_b1', + states: { + child_b1: { + on: { + EV: 'child_b2' + } + }, + child_b2: { + type: 'final' + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actorRef = createActor(machine).start(); + flushTracked(); + // it's important to send an event here that results in a transition as that computes new `state._nodes` + // and that could impact the order in which exit actions are called + actorRef.send({ type: 'EV' }); + + expect(flushTracked()).toEqual([ + // result of the transition + 'exit: b.child_b1', + 'exit: a.child_a1', + 'enter: a.child_a2', + 'enter: b.child_b2', + // result of reaching final states + 'exit: b.child_b2', + 'exit: b', + 'exit: a.child_a2', + 'exit: a', + 'exit: __root__' + ]); + }); + + it('should not complete a parallel root immediately when only some of its regions are in their final states (final state reached in a compound region)', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A1', + states: { + A1: { + type: 'final' + } + } + }, + B: { + initial: 'B1', + states: { + B1: {}, + B2: { + type: 'final' + } + } + } + } + }); + + const actorRef = createActor(machine).start(); + + expect(actorRef.getSnapshot().status).toBe('active'); + }); + + it('should not complete a parallel root immediately when only some of its regions are in their final states (a direct final child state reached)', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + type: 'final' + }, + B: { + initial: 'B1', + states: { + B1: {}, + B2: { + type: 'final' + } + } + } + } + }); + + const actorRef = createActor(machine).start(); + + expect(actorRef.getSnapshot().status).toBe('active'); + }); + + it('should not resolve output of a final state if its parent is a parallel state', () => { + const spy = jest.fn(); + + const machine = createMachine({ + initial: 'A', + states: { + A: { + type: 'parallel', + states: { + B: { + type: 'final', + output: spy + }, + C: { + initial: 'C1', + states: { + C1: {} + } + } + } + } + } + }); + + createActor(machine).start(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should only call exit actions once when a child machine reaches its final state and sends an event to its parent that ends up stopping that child', () => { + const spy = jest.fn(); + + const child = createMachine({ + initial: 'start', + exit2: spy, + states: { + start: { + on: { + CANCEL: 'canceled' + } + }, + canceled: { + type: 'final', + entry2: ({ parent }) => { + parent?.send({ type: 'CHILD_CANCELED' }); + } + } + } + }); + const parent = createMachine({ + initial: 'start', + states: { + start: { + invoke: { + id: 'child', + src: child, + onDone: 'completed' + }, + on: { + CHILD_CANCELED: 'canceled' + } + }, + canceled: {}, + completed: {} + } + }); + + const actorRef = createActor(parent).start(); + + actorRef.getSnapshot().children.child.send({ + type: 'CANCEL' + }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should deliver final outgoing events (from final entry action) to the parent before delivering the `xstate.done.actor.*` event', () => { + const child = createMachine({ + initial: 'start', + states: { + start: { + on: { + CANCEL: 'canceled' + } + }, + canceled: { + type: 'final', + entry2: ({ parent }) => { + parent?.send({ type: 'CHILD_CANCELED' }); + } + } + } + }); + const parent = createMachine({ + initial: 'start', + states: { + start: { + invoke: { + id: 'child', + src: child, + onDone: 'completed' + }, + on: { + CHILD_CANCELED: 'canceled' + } + }, + canceled: {}, + completed: {} + } + }); + + const actorRef = createActor(parent).start(); + + actorRef.getSnapshot().children.child.send({ + type: 'CANCEL' + }); + + // if `xstate.done.actor.*` would be delivered first the value would be `completed` + expect(actorRef.getSnapshot().value).toBe('canceled'); + }); + + it.only('should deliver final outgoing events (from root exit action) to the parent before delivering the `xstate.done.actor.*` event', () => { + const child = createMachine({ + initial: 'start', + states: { + start: { + on: { + CANCEL: 'canceled' + } + }, + canceled: { + type: 'final' + } + }, + exit2: ({ parent }) => { + parent?.send({ type: 'CHILD_CANCELED' }); + } + }); + const parent = createMachine({ + initial: 'start', + states: { + start: { + invoke: { + id: 'child', + src: child, + onDone: 'completed' + }, + on: { + CHILD_CANCELED: 'canceled' + } + }, + canceled: {}, + completed: {} + } + }); + + const actorRef = createActor(parent).start(); + + actorRef.getSnapshot().children.child.send({ + type: 'CANCEL' + }); + + // if `xstate.done.actor.*` would be delivered first the value would be `completed` + expect(actorRef.getSnapshot().value).toBe('canceled'); + }); + + it('should be possible to complete with a null output (directly on root)', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + NEXT: 'end' + } + }, + end: { + type: 'final' + } + }, + output: null + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'NEXT' }); + + expect(actorRef.getSnapshot().output).toBe(null); + }); + + it("should be possible to complete with a null output (resolving with final state's output)", () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + NEXT: 'end' + } + }, + end: { + type: 'final', + output: null + } + }, + output: ({ event }) => event.output + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'NEXT' }); + + expect(actorRef.getSnapshot().output).toBe(null); + }); +}); diff --git a/packages/core/test/getNextSnapshot.v6.test.ts b/packages/core/test/getNextSnapshot.v6.test.ts new file mode 100644 index 0000000000..dee9c82166 --- /dev/null +++ b/packages/core/test/getNextSnapshot.v6.test.ts @@ -0,0 +1,78 @@ +import { + createMachine, + fromTransition, + transition, + initialTransition +} from '../src'; + +describe('transition', () => { + it('should calculate the next snapshot for transition logic', () => { + const logic = fromTransition( + (state, event) => { + if (event.type === 'next') { + return { count: state.count + 1 }; + } else { + return state; + } + }, + { count: 0 } + ); + + const [init] = initialTransition(logic, undefined); + const [s1] = transition(logic, init, { type: 'next' }); + expect(s1.context.count).toEqual(1); + const [s2] = transition(logic, s1, { type: 'next' }); + expect(s2.context.count).toEqual(2); + }); + it('should calculate the next snapshot for machine logic', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + on: { + NEXT: 'c' + } + }, + c: {} + } + }); + + const [init] = initialTransition(machine, undefined); + const [s1] = transition(machine, init, { type: 'NEXT' }); + + expect(s1.value).toEqual('b'); + + const [s2] = transition(machine, s1, { type: 'NEXT' }); + + expect(s2.value).toEqual('c'); + }); + it('should not execute actions', () => { + const fn = jest.fn(); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + event: (_, enq) => { + enq.action(fn); + return { target: 'b' }; + } + } + }, + b: {} + } + }); + + const [init] = initialTransition(machine, undefined); + const [nextSnapshot] = transition(machine, init, { type: 'event' }); + + expect(fn).not.toHaveBeenCalled(); + expect(nextSnapshot.value).toEqual('b'); + }); +}); diff --git a/packages/core/test/guards.test.ts b/packages/core/test/guards.test.ts index 099b6a6063..608627da4d 100644 --- a/packages/core/test/guards.test.ts +++ b/packages/core/test/guards.test.ts @@ -258,9 +258,7 @@ describe('guard conditions', () => { ] `); }); -}); -describe('guard conditions', () => { it('should guard against transition', () => { const machine = createMachine({ type: 'parallel', @@ -402,6 +400,286 @@ describe('guard conditions', () => { }); }); +describe('[function] guard conditions', () => { + interface LightMachineCtx { + elapsed: number; + } + type LightMachineEvents = + | { type: 'TIMER' } + | { + type: 'EMERGENCY'; + isEmergency?: boolean; + } + | { type: 'TIMER_COND_OBJ' } + | { type: 'BAD_COND' }; + + const minTimeElapsed = (elapsed: number) => elapsed >= 100 && elapsed < 200; + + const lightMachine = createMachine({ + types: {} as { + input: { elapsed?: number }; + context: LightMachineCtx; + events: LightMachineEvents; + }, + context: ({ input = {} }) => ({ + elapsed: input.elapsed ?? 0 + }), + initial: 'green', + states: { + green: { + on: { + TIMER: ({ context }) => { + if (context.elapsed < 100) { + return { target: 'green' }; + } + if (context.elapsed >= 100 && context.elapsed < 200) { + return { target: 'yellow' }; + } + }, + EMERGENCY: ({ event }) => + event.isEmergency ? { target: 'red' } : undefined + } + }, + yellow: { + on: { + TIMER: ({ context }) => + minTimeElapsed(context.elapsed) ? { target: 'red' } : undefined, + + TIMER_COND_OBJ: ({ context }) => + minTimeElapsed(context.elapsed) ? { target: 'red' } : undefined + } + }, + red: {} + } + }); + + it('should transition only if condition is met', () => { + const actorRef1 = createActor(lightMachine, { + input: { elapsed: 50 } + }).start(); + actorRef1.send({ type: 'TIMER' }); + expect(actorRef1.getSnapshot().value).toEqual('green'); + + const actorRef2 = createActor(lightMachine, { + input: { elapsed: 120 } + }).start(); + actorRef2.send({ type: 'TIMER' }); + expect(actorRef2.getSnapshot().value).toEqual('yellow'); + }); + + it('should transition if condition based on event is met', () => { + const actorRef = createActor(lightMachine, { input: {} }).start(); + actorRef.send({ + type: 'EMERGENCY', + isEmergency: true + }); + expect(actorRef.getSnapshot().value).toEqual('red'); + }); + + it('should not transition if condition based on event is not met', () => { + const actorRef = createActor(lightMachine, { input: {} }).start(); + actorRef.send({ + type: 'EMERGENCY' + }); + expect(actorRef.getSnapshot().value).toEqual('green'); + }); + + it('should not transition if no condition is met', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + TIMER: ({ event }) => ({ + target: + event.elapsed > 200 + ? 'b' + : event.elapsed > 100 + ? 'c' + : undefined + }) + } + }, + b: {}, + c: {} + } + }); + + const flushTracked = trackEntries(machine); + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'TIMER', elapsed: 10 }); + + expect(actor.getSnapshot().value).toBe('a'); + expect(flushTracked()).toEqual([]); + }); + + it('should work with defined string transitions', () => { + const actorRef = createActor(lightMachine, { + input: { elapsed: 120 } + }).start(); + actorRef.send({ + type: 'TIMER' + }); + expect(actorRef.getSnapshot().value).toEqual('yellow'); + actorRef.send({ + type: 'TIMER' + }); + expect(actorRef.getSnapshot().value).toEqual('red'); + }); + + it('should work with guard objects', () => { + const actorRef = createActor(lightMachine, { + input: { elapsed: 150 } + }).start(); + actorRef.send({ + type: 'TIMER' + }); + expect(actorRef.getSnapshot().value).toEqual('yellow'); + actorRef.send({ + type: 'TIMER_COND_OBJ' + }); + expect(actorRef.getSnapshot().value).toEqual('red'); + }); + + it('should work with defined string transitions (condition not met)', () => { + const machine = createMachine({ + types: {} as { context: LightMachineCtx; events: LightMachineEvents }, + context: { + elapsed: 10 + }, + initial: 'yellow', + states: { + green: { + on: { + TIMER: ({ context }) => ({ + target: + context.elapsed < 100 + ? 'green' + : context.elapsed >= 100 && context.elapsed < 200 + ? 'yellow' + : undefined + }), + EMERGENCY: ({ event }) => ({ + target: event.isEmergency ? 'red' : undefined + }) + } + }, + yellow: { + on: { + TIMER: ({ context }) => ({ + target: minTimeElapsed(context.elapsed) ? 'red' : undefined + }) + } + }, + red: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ + type: 'TIMER' + }); + + expect(actorRef.getSnapshot().value).toEqual('yellow'); + }); + + it.skip('should allow a matching transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A2', + states: { + A0: {}, + A2: {} + } + }, + B: { + initial: 'B0', + states: { + B0: { + always: [ + { + target: 'B4', + guard: () => false + } + ], + on: { + T2: [ + { + target: 'B2', + guard: stateIn('A.A2') + } + ] + } + }, + B1: {}, + B2: {}, + B4: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'T2' }); + + expect(actorRef.getSnapshot().value).toEqual({ + A: 'A2', + B: 'B2' + }); + }); + + it.skip('should check guards with interim states', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A2', + states: { + A2: { + on: { + A: 'A3' + } + }, + A3: { + always: 'A4' + }, + A4: { + always: 'A5' + }, + A5: {} + } + }, + B: { + initial: 'B0', + states: { + B0: { + always: [ + { + target: 'B4', + guard: stateIn('A.A4') + } + ] + }, + B4: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'A' }); + + expect(actorRef.getSnapshot().value).toEqual({ + A: 'A5', + B: 'B4' + }); + }); +}); + describe('custom guards', () => { it('should evaluate custom guards', () => { interface Ctx { diff --git a/packages/core/test/guards.v6.test.ts b/packages/core/test/guards.v6.test.ts new file mode 100644 index 0000000000..f97d28bec6 --- /dev/null +++ b/packages/core/test/guards.v6.test.ts @@ -0,0 +1,1350 @@ +import { createMachine, createActor, matchesState } from '../src'; +import { trackEntries } from './utils.ts'; + +describe('guard conditions', () => { + interface LightMachineCtx { + elapsed: number; + } + type LightMachineEvents = + | { type: 'TIMER' } + | { + type: 'EMERGENCY'; + isEmergency?: boolean; + } + | { type: 'TIMER_COND_OBJ' } + | { type: 'BAD_COND' }; + + const lightMachine = createMachine( + { + types: {} as { + input: { elapsed?: number }; + context: LightMachineCtx; + events: LightMachineEvents; + }, + context: ({ input = {} }) => ({ + elapsed: input.elapsed ?? 0 + }), + initial: 'green', + states: { + green: { + on: { + TIMER: [ + { + target: 'green', + guard: ({ context: { elapsed } }) => elapsed < 100 + }, + { + target: 'yellow', + guard: ({ context: { elapsed } }) => + elapsed >= 100 && elapsed < 200 + } + ], + EMERGENCY: { + target: 'red', + guard: ({ event }) => !!event.isEmergency + } + } + }, + yellow: { + on: { + TIMER: { + target: 'red', + guard: 'minTimeElapsed' + }, + TIMER_COND_OBJ: { + target: 'red', + guard: { + type: 'minTimeElapsed' + } + } + } + }, + red: { + on: { + BAD_COND: { + target: 'red', + guard: 'doesNotExist' + } + } + } + } + }, + { + guards: { + minTimeElapsed: ({ context: { elapsed } }) => + elapsed >= 100 && elapsed < 200 + } + } + ); + + it('should transition only if condition is met', () => { + const actorRef1 = createActor(lightMachine, { + input: { elapsed: 50 } + }).start(); + actorRef1.send({ type: 'TIMER' }); + expect(actorRef1.getSnapshot().value).toEqual('green'); + + const actorRef2 = createActor(lightMachine, { + input: { elapsed: 120 } + }).start(); + actorRef2.send({ type: 'TIMER' }); + expect(actorRef2.getSnapshot().value).toEqual('yellow'); + }); + + it('should transition if condition based on event is met', () => { + const actorRef = createActor(lightMachine, { input: {} }).start(); + actorRef.send({ + type: 'EMERGENCY', + isEmergency: true + }); + expect(actorRef.getSnapshot().value).toEqual('red'); + }); + + it('should not transition if condition based on event is not met', () => { + const actorRef = createActor(lightMachine, { input: {} }).start(); + actorRef.send({ + type: 'EMERGENCY' + }); + expect(actorRef.getSnapshot().value).toEqual('green'); + }); + + it('should not transition if no condition is met', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + TIMER: [ + { + target: 'b', + guard: ({ event: { elapsed } }) => elapsed > 200 + }, + { + target: 'c', + guard: ({ event: { elapsed } }) => elapsed > 100 + } + ] + } + }, + b: {}, + c: {} + } + }); + + const flushTracked = trackEntries(machine); + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'TIMER', elapsed: 10 }); + + expect(actor.getSnapshot().value).toBe('a'); + expect(flushTracked()).toEqual([]); + }); + + it('should work with defined string transitions', () => { + const actorRef = createActor(lightMachine, { + input: { elapsed: 120 } + }).start(); + actorRef.send({ + type: 'TIMER' + }); + expect(actorRef.getSnapshot().value).toEqual('yellow'); + actorRef.send({ + type: 'TIMER' + }); + expect(actorRef.getSnapshot().value).toEqual('red'); + }); + + it('should work with guard objects', () => { + const actorRef = createActor(lightMachine, { + input: { elapsed: 150 } + }).start(); + actorRef.send({ + type: 'TIMER' + }); + expect(actorRef.getSnapshot().value).toEqual('yellow'); + actorRef.send({ + type: 'TIMER_COND_OBJ' + }); + expect(actorRef.getSnapshot().value).toEqual('red'); + }); + + it('should work with defined string transitions (condition not met)', () => { + const machine = createMachine( + { + types: {} as { context: LightMachineCtx; events: LightMachineEvents }, + context: { + elapsed: 10 + }, + initial: 'yellow', + states: { + green: { + on: { + TIMER: [ + { + target: 'green', + guard: ({ context: { elapsed } }) => elapsed < 100 + }, + { + target: 'yellow', + guard: ({ context: { elapsed } }) => + elapsed >= 100 && elapsed < 200 + } + ], + EMERGENCY: { + target: 'red', + guard: ({ event }) => !!event.isEmergency + } + } + }, + yellow: { + on: { + TIMER: { + target: 'red', + guard: 'minTimeElapsed' + } + } + }, + red: {} + } + }, + { + guards: { + minTimeElapsed: ({ context: { elapsed } }) => + elapsed >= 100 && elapsed < 200 + } + } + ); + + const actorRef = createActor(machine).start(); + actorRef.send({ + type: 'TIMER' + }); + + expect(actorRef.getSnapshot().value).toEqual('yellow'); + }); + + it('should throw if string transition is not defined', () => { + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + on: { + BAD_COND: { + guard: 'doesNotExist' + } + } + } + } + }); + + const errorSpy = jest.fn(); + + const actorRef = createActor(machine); + actorRef.subscribe({ + error: errorSpy + }); + actorRef.start(); + + actorRef.send({ type: 'BAD_COND' }); + + expect(errorSpy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: Unable to evaluate guard 'doesNotExist' in transition for event 'BAD_COND' in state node '(machine).foo': + Guard 'doesNotExist' is not implemented.'.], + ], + ] + `); + }); + + it('should guard against transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A2', + states: { + A0: {}, + A2: {} + } + }, + B: { + initial: 'B0', + states: { + B0: { + always: [ + { + target: 'B4', + guard: () => false + } + ], + on: { + T1: [ + { + target: 'B1', + guard: () => false + } + ] + } + }, + B1: {}, + B4: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'T1' }); + + expect(actorRef.getSnapshot().value).toEqual({ + A: 'A2', + B: 'B0' + }); + }); + + it('should allow a matching transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A2', + states: { + A0: {}, + A2: {} + } + }, + B: { + initial: 'B0', + states: { + B0: { + always: [ + { + target: 'B4', + guard: () => false + } + ], + on: { + T2: ({ value }) => { + if (matchesState('A.A2', value)) { + return { target: 'B2' }; + } + } + } + }, + B1: {}, + B2: {}, + B4: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'T2' }); + + expect(actorRef.getSnapshot().value).toEqual({ + A: 'A2', + B: 'B2' + }); + }); + + it('should check guards with interim states', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A2', + states: { + A2: { + on: { + A: 'A3' + } + }, + A3: { + always: 'A4' + }, + A4: { + always: 'A5' + }, + A5: {} + } + }, + B: { + initial: 'B0', + states: { + B0: { + always: ({ value }) => { + if (matchesState('A.A4', value)) { + return { target: 'B4' }; + } + } + }, + B4: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'A' }); + + expect(actorRef.getSnapshot().value).toEqual({ + A: 'A5', + B: 'B4' + }); + }); +}); + +describe('[function] guard conditions', () => { + interface LightMachineCtx { + elapsed: number; + } + type LightMachineEvents = + | { type: 'TIMER' } + | { + type: 'EMERGENCY'; + isEmergency?: boolean; + } + | { type: 'TIMER_COND_OBJ' } + | { type: 'BAD_COND' }; + + const minTimeElapsed = (elapsed: number) => elapsed >= 100 && elapsed < 200; + + const lightMachine = createMachine({ + types: {} as { + input: { elapsed?: number }; + context: LightMachineCtx; + events: LightMachineEvents; + }, + context: ({ input = {} }) => ({ + elapsed: input.elapsed ?? 0 + }), + initial: 'green', + states: { + green: { + on: { + TIMER: ({ context }) => { + if (context.elapsed < 100) { + return { target: 'green' }; + } + if (context.elapsed >= 100 && context.elapsed < 200) { + return { target: 'yellow' }; + } + }, + EMERGENCY: ({ event }) => + event.isEmergency ? { target: 'red' } : undefined + } + }, + yellow: { + on: { + TIMER: ({ context }) => + minTimeElapsed(context.elapsed) ? { target: 'red' } : undefined, + TIMER_COND_OBJ: ({ context }) => + minTimeElapsed(context.elapsed) ? { target: 'red' } : undefined + } + }, + red: {} + } + }); + + it('should transition only if condition is met', () => { + const actorRef1 = createActor(lightMachine, { + input: { elapsed: 50 } + }).start(); + actorRef1.send({ type: 'TIMER' }); + expect(actorRef1.getSnapshot().value).toEqual('green'); + + const actorRef2 = createActor(lightMachine, { + input: { elapsed: 120 } + }).start(); + actorRef2.send({ type: 'TIMER' }); + expect(actorRef2.getSnapshot().value).toEqual('yellow'); + }); + + it('should transition if condition based on event is met', () => { + const actorRef = createActor(lightMachine, { input: {} }).start(); + actorRef.send({ + type: 'EMERGENCY', + isEmergency: true + }); + expect(actorRef.getSnapshot().value).toEqual('red'); + }); + + it('should not transition if condition based on event is not met', () => { + const actorRef = createActor(lightMachine, { input: {} }).start(); + actorRef.send({ + type: 'EMERGENCY' + }); + expect(actorRef.getSnapshot().value).toEqual('green'); + }); + + it('should not transition if no condition is met', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + TIMER: ({ event }) => ({ + target: + event.elapsed > 200 + ? 'b' + : event.elapsed > 100 + ? 'c' + : undefined + }) + } + }, + b: {}, + c: {} + } + }); + + const flushTracked = trackEntries(machine); + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ type: 'TIMER', elapsed: 10 }); + + expect(actor.getSnapshot().value).toBe('a'); + expect(flushTracked()).toEqual([]); + }); + + it('should work with defined string transitions', () => { + const actorRef = createActor(lightMachine, { + input: { elapsed: 120 } + }).start(); + actorRef.send({ + type: 'TIMER' + }); + expect(actorRef.getSnapshot().value).toEqual('yellow'); + actorRef.send({ + type: 'TIMER' + }); + expect(actorRef.getSnapshot().value).toEqual('red'); + }); + + it('should work with guard objects', () => { + const actorRef = createActor(lightMachine, { + input: { elapsed: 150 } + }).start(); + actorRef.send({ + type: 'TIMER' + }); + expect(actorRef.getSnapshot().value).toEqual('yellow'); + actorRef.send({ + type: 'TIMER_COND_OBJ' + }); + expect(actorRef.getSnapshot().value).toEqual('red'); + }); + + it('should work with defined string transitions (condition not met)', () => { + const machine = createMachine({ + types: {} as { context: LightMachineCtx; events: LightMachineEvents }, + context: { + elapsed: 10 + }, + initial: 'yellow', + states: { + green: { + on: { + TIMER: ({ context }) => ({ + target: + context.elapsed < 100 + ? 'green' + : context.elapsed >= 100 && context.elapsed < 200 + ? 'yellow' + : undefined + }), + EMERGENCY: ({ event }) => ({ + target: event.isEmergency ? 'red' : undefined + }) + } + }, + yellow: { + on: { + TIMER: ({ context }) => ({ + target: minTimeElapsed(context.elapsed) ? 'red' : undefined + }) + } + }, + red: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ + type: 'TIMER' + }); + + expect(actorRef.getSnapshot().value).toEqual('yellow'); + }); + + it.skip('should allow a matching transition', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A2', + states: { + A0: {}, + A2: {} + } + }, + B: { + initial: 'B0', + states: { + B0: { + always: [ + { + target: 'B4', + guard: () => false + } + ], + on: { + T2: ({ value }) => { + if (matchesState('A.A2', value)) { + return { target: 'B2' }; + } + } + } + }, + B1: {}, + B2: {}, + B4: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'T2' }); + + expect(actorRef.getSnapshot().value).toEqual({ + A: 'A2', + B: 'B2' + }); + }); + + it.skip('should check guards with interim states', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A2', + states: { + A2: { + on: { + A: 'A3' + } + }, + A3: { + always: 'A4' + }, + A4: { + always: 'A5' + }, + A5: {} + } + }, + B: { + initial: 'B0', + states: { + B0: { + always: ({ value }) => { + if (matchesState('A.A4', value)) { + return { target: 'B4' }; + } + } + }, + B4: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'A' }); + + expect(actorRef.getSnapshot().value).toEqual({ + A: 'A5', + B: 'B4' + }); + }); +}); + +describe('custom guards', () => { + it('should evaluate custom guards', () => { + interface Ctx { + count: number; + } + interface Events { + type: 'EVENT'; + value: number; + } + const machine = createMachine( + { + types: {} as { + context: Ctx; + events: Events; + guards: { + type: 'custom'; + params: { + prop: keyof Ctx; + op: 'greaterThan'; + compare: number; + }; + }; + }, + initial: 'inactive', + context: { + count: 0 + }, + states: { + inactive: { + on: { + EVENT: { + target: 'active', + guard: { + type: 'custom', + params: { prop: 'count', op: 'greaterThan', compare: 3 } + } + } + } + }, + active: {} + } + }, + { + guards: { + custom: ({ context, event }, params) => { + const { prop, compare, op } = params; + if (op === 'greaterThan') { + return context[prop] + event.value > compare; + } + + return false; + } + } + } + ); + + const actorRef1 = createActor(machine).start(); + actorRef1.send({ type: 'EVENT', value: 4 }); + const passState = actorRef1.getSnapshot(); + + expect(passState.value).toEqual('active'); + + const actorRef2 = createActor(machine).start(); + actorRef2.send({ type: 'EVENT', value: 3 }); + const failState = actorRef2.getSnapshot(); + + expect(failState.value).toEqual('inactive'); + }); + + it('should provide the undefined params if a guard was configured using a string', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + on: { + FOO: { + guard: 'myGuard' + } + } + }, + { + guards: { + myGuard: (_, params) => { + spy(params); + return true; + } + } + } + ); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledWith(undefined); + }); + + it('should provide the guard with resolved params when they are dynamic', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + on: { + FOO: { + guard: { type: 'myGuard', params: () => ({ stuff: 100 }) } + } + } + }, + { + guards: { + myGuard: (_, params) => { + spy(params); + return true; + } + } + } + ); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledWith({ + stuff: 100 + }); + }); + + it('should resolve dynamic params using context value', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + context: { + secret: 42 + }, + on: { + FOO: { + guard: { + type: 'myGuard', + params: ({ context }) => ({ secret: context.secret }) + } + } + } + }, + { + guards: { + myGuard: (_, params) => { + spy(params); + return true; + } + } + } + ); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'FOO' }); + + expect(spy).toHaveBeenCalledWith({ + secret: 42 + }); + }); + + it('should resolve dynamic params using event value', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + on: { + FOO: { + guard: { + type: 'myGuard', + params: ({ event }) => ({ secret: event.secret }) + } + } + } + }, + { + guards: { + myGuard: (_, params) => { + spy(params); + return true; + } + } + } + ); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'FOO', secret: 77 }); + + expect(spy).toHaveBeenCalledWith({ + secret: 77 + }); + }); +}); + +describe('guards - other', () => { + it('should allow for a fallback target to be a simple string', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: false ? 'b' : 'c' + }; + } + } + }, + b: {}, + c: {} + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'EVENT' }); + + expect(service.getSnapshot().value).toBe('c'); + }); +}); + +describe('not() guard', () => { + it('should guard with inline function', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: !false ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should guard with string', () => { + const falsyGuard = () => false; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: !falsyGuard() ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should guard with object', () => { + const greaterThan10 = (num: number) => num > 10; + const machine = createMachine({ + types: {} as { + guards: { type: 'greaterThan10'; params: { value: number } }; + }, + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: !greaterThan10(5) ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should guard with nested built-in guards', () => { + const truthy = () => true; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: !(!truthy() && truthy()) ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should evaluate dynamic params of the referenced guard', () => { + const spy = jest.fn(); + const myGuard = (params: any) => { + spy(params); + return true; + }; + + const machine = createMachine({ + on: { + EV: ({ event }) => { + if (myGuard({ secret: event.secret })) { + return { + target: undefined + }; + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EV', secret: 42 }); + + expect(spy).toMatchMockCallsInlineSnapshot(` +[ + [ + { + "secret": 42, + }, + ], + [ + { + "secret": 42, + }, + ], + [ + { + "secret": 42, + }, + ], + [ + { + "secret": 42, + }, + ], + [ + { + "secret": 42, + }, + ], + [ + { + "secret": 42, + }, + ], + [ + { + "secret": 42, + }, + ], + [ + { + "secret": 42, + }, + ], +] +`); + }); +}); + +describe('and() guard', () => { + it('should guard with inline function', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: !(!true && 1 + 1 === 2) ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should guard with string', () => { + const truthy = () => true; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: !(!truthy() && truthy()) ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should guard with object', () => { + const greaterThan10 = (num: number) => num > 10; + const machine = createMachine({ + types: {} as { + guards: { + type: 'greaterThan10'; + params: { value: number }; + }; + }, + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: !(!greaterThan10(11) && greaterThan10(50)) + ? 'b' + : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should guard with nested built-in guards', () => { + const truthy = () => true; + const falsy = () => false; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: + true && !falsy() && !falsy() && truthy() ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should evaluate dynamic params of the referenced guard', () => { + const spy = jest.fn(); + + const myGuard = (params: any) => { + spy(params); + return true; + }; + + const machine = createMachine({ + on: { + EV: ({ event }) => { + return { + target: myGuard({ secret: event.secret }) && true ? 'b' : undefined + }; + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EV', secret: 42 }); + + expect(spy).toMatchMockCallsInlineSnapshot(` +[ + [ + { + "secret": 42, + }, + ], + [ + { + "secret": 42, + }, + ], +] +`); + }); +}); + +describe('or() guard', () => { + it('should guard with inline function', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: false || 1 + 1 === 2 ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should guard with string', () => { + const falsy = () => false; + const truthy = () => true; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: falsy() || truthy() ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should guard with object', () => { + const greaterThan10 = (num: number) => num > 10; + const machine = createMachine({ + types: {} as { + guards: { + type: 'greaterThan10'; + params: { value: number }; + }; + }, + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: greaterThan10(4) || greaterThan10(50) ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should guard with nested built-in guards', () => { + const truthy = () => true; + const falsy = () => false; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: () => { + return { + target: falsy() || (!falsy() && truthy()) ? 'b' : undefined + }; + } + } + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().matches('b')).toBeTruthy(); + }); + + it('should evaluate dynamic params of the referenced guard', () => { + const spy = jest.fn(); + + const myGuard = (params: any) => { + spy(params); + return true; + }; + + const machine = createMachine({ + on: { + EV: ({ event }) => { + return { + target: myGuard({ secret: event.secret }) || true ? 'b' : undefined + }; + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EV', secret: 42 }); + + expect(spy).toMatchMockCallsInlineSnapshot(` +[ + [ + { + "secret": 42, + }, + ], + [ + { + "secret": 42, + }, + ], +] +`); + }); +}); diff --git a/packages/core/test/id.v6.test.ts b/packages/core/test/id.v6.test.ts new file mode 100644 index 0000000000..a4faa96bcd --- /dev/null +++ b/packages/core/test/id.v6.test.ts @@ -0,0 +1,217 @@ +import { testAll } from './utils'; +import { + createMachine, + createActor, + transition, + initialTransition, + getNextSnapshot +} from '../src/index.ts'; + +const idMachine = createMachine({ + initial: 'A', + states: { + A: { + id: 'A', + initial: 'foo', + states: { + foo: { + id: 'A_foo', + on: { + NEXT: '#A_bar' + } + }, + bar: { + id: 'A_bar', + on: { + NEXT: '#B_foo' + } + } + }, + on: { + NEXT_DOT_RESOLVE: '#B.bar' + } + }, + B: { + id: 'B', + initial: 'foo', + states: { + foo: { + id: 'B_foo', + on: { + NEXT: '#B_bar', + NEXT_DOT: '#B.dot' + } + }, + bar: { + id: 'B_bar', + on: { + NEXT: '#A_foo' + } + }, + dot: {} + } + } + } +}); + +describe('State node IDs', () => { + const expected = { + A: { + NEXT: { A: 'bar' }, + NEXT_DOT_RESOLVE: { B: 'bar' } + }, + '{"A":"foo"}': { + NEXT: { A: 'bar' } + }, + '{"A":"bar"}': { + NEXT: { B: 'foo' } + }, + '{"B":"foo"}': { + 'NEXT,NEXT': { A: 'foo' }, + NEXT_DOT: { B: 'dot' } + } + }; + + testAll(idMachine, expected); + + it('should work with ID + relative path', () => { + const machine = createMachine({ + initial: 'foo', + on: { + ACTION: '#bar.qux.quux' + }, + states: { + foo: { + id: 'foo' + }, + bar: { + id: 'bar', + initial: 'baz', + states: { + baz: {}, + qux: { + initial: 'quux', + states: { + quux: { + id: '#bar.qux.quux' + } + } + } + } + } + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ + type: 'ACTION' + }); + + expect(actorRef.getSnapshot().value).toEqual({ + bar: { + qux: 'quux' + } + }); + }); + + it('should work with keys that have escaped periods', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + escaped: 'foo\\.bar', + unescaped: 'foo.bar' + } + }, + 'foo.bar': {}, + foo: { + initial: 'bar', + states: { + bar: {} + } + } + } + }); + + const [initialState] = initialTransition(machine); + const [escapedState] = transition(machine, initialState, { + type: 'escaped' + }); + + expect(escapedState.value).toEqual('foo.bar'); + + const [unescapedState] = transition(machine, initialState, { + type: 'unescaped' + }); + expect(unescapedState.value).toEqual({ foo: 'bar' }); + }); + + it('should work with IDs that have escaped periods', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + escaped: '#foo\\.bar', + unescaped: '#foo.bar' + } + }, + stateWithDot: { + id: 'foo.bar' + }, + foo: { + id: 'foo', + initial: 'bar', + states: { + bar: {} + } + } + } + }); + + const [initialState] = initialTransition(machine); + const [escapedState] = transition(machine, initialState, { + type: 'escaped' + }); + + expect(escapedState.value).toEqual('stateWithDot'); + + const [unescapedState] = transition(machine, initialState, { + type: 'unescaped' + }); + expect(unescapedState.value).toEqual({ foo: 'bar' }); + }); + + it("should not treat escaped backslash as period's escape", () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + EV: '#some\\\\.thing' + } + }, + foo: { + id: 'some\\.thing' + }, + bar: { + id: 'some\\', + initial: 'baz', + states: { + baz: {}, + thing: {} + } + } + } + }); + + const [initialState] = initialTransition(machine); + const [escapedState] = transition(machine, initialState, { + type: 'EV' + }); + + expect(escapedState.value).toEqual({ bar: 'thing' }); + }); +}); diff --git a/packages/core/test/initial.v6.test.ts b/packages/core/test/initial.v6.test.ts new file mode 100644 index 0000000000..7906c3c61d --- /dev/null +++ b/packages/core/test/initial.v6.test.ts @@ -0,0 +1,164 @@ +import { createActor, createMachine } from '../src/index.ts'; + +describe('Initial states', () => { + it('should return the correct initial state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { + c: {} + } + } + } + }, + leaf: {} + } + }); + expect(createActor(machine).getSnapshot().value).toEqual({ + a: { b: 'c' } + }); + }); + + it('should return the correct initial state (parallel)', () => { + const machine = createMachine({ + type: 'parallel', + states: { + foo: { + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { + c: {} + } + } + } + }, + leaf: {} + } + }, + bar: { + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { + c: {} + } + } + } + }, + leaf: {} + } + } + } + }); + expect(createActor(machine).getSnapshot().value).toEqual({ + foo: { a: { b: 'c' } }, + bar: { a: { b: 'c' } } + }); + }); + + it('should return the correct initial state (deep parallel)', () => { + const machine = createMachine({ + initial: 'one', + states: { + one: { + type: 'parallel', + states: { + foo: { + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { + c: {} + } + } + } + }, + leaf: {} + } + }, + bar: { + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { + c: {} + } + } + } + }, + leaf: {} + } + } + } + }, + two: { + type: 'parallel', + states: { + foo: { + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { + c: {} + } + } + } + }, + leaf: {} + } + }, + bar: { + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + initial: 'c', + states: { + c: {} + } + } + } + }, + leaf: {} + } + } + } + } + } + }); + expect(createActor(machine).getSnapshot().value).toEqual({ + one: { + foo: { a: { b: 'c' } }, + bar: { a: { b: 'c' } } + } + }); + }); +}); diff --git a/packages/core/test/input.v6.test.ts b/packages/core/test/input.v6.test.ts new file mode 100644 index 0000000000..a3ba22bbc9 --- /dev/null +++ b/packages/core/test/input.v6.test.ts @@ -0,0 +1,250 @@ +import { of } from 'rxjs'; +import { assign, createActor, spawnChild } from '../src'; +import { createMachine } from '../src/createMachine'; +import { + fromCallback, + fromObservable, + fromPromise, + fromTransition +} from '../src/actors'; + +describe('input', () => { + it('should create a machine with input', () => { + const spy = jest.fn(); + + const machine = createMachine({ + types: {} as { + context: { count: number }; + input: { startCount: number }; + }, + context: ({ input }) => ({ + count: input.startCount + }), + entry: ({ context }) => { + spy(context.count); + } + }); + + createActor(machine, { input: { startCount: 42 } }).start(); + + expect(spy).toHaveBeenCalledWith(42); + }); + + it('initial event should have input property', (done) => { + const machine = createMachine({ + entry2: ({ event }) => { + expect(event.input.greeting).toBe('hello'); + done(); + } + }); + + createActor(machine, { input: { greeting: 'hello' } }).start(); + }); + + it('should error if input is expected but not provided', () => { + const machine = createMachine({ + types: {} as { + input: { greeting: string }; + context: { message: string }; + }, + context: ({ input }) => { + return { message: `Hello, ${input.greeting}` }; + } + }); + + // @ts-expect-error + const snapshot = createActor(machine).getSnapshot(); + + expect(snapshot.status).toBe('error'); + }); + + it('should be a type error if input is not expected yet provided', () => { + const machine = createMachine({ + context: { count: 42 } + }); + + expect(() => { + // TODO: add ts-expect-errpr + createActor(machine).start(); + }).not.toThrow(); + }); + + it('should provide input data to invoked machines', (done) => { + const invokedMachine = createMachine({ + types: {} as { + input: { greeting: string }; + context: { greeting: string }; + }, + context: ({ input }) => input, + entry2: ({ context, event }) => { + expect(context.greeting).toBe('hello'); + expect(event.input.greeting).toBe('hello'); + done(); + } + }); + + const machine = createMachine({ + invoke: { + src: invokedMachine, + input: { greeting: 'hello' } + } + }); + + createActor(machine).start(); + }); + + it('should provide input data to spawned machines', (done) => { + const spawnedMachine = createMachine({ + types: {} as { + input: { greeting: string }; + context: { greeting: string }; + }, + context({ input }) { + return input; + }, + entry2: ({ context, event }) => { + expect(context.greeting).toBe('hello'); + expect(event.input.greeting).toBe('hello'); + done(); + } + }); + + const machine = createMachine({ + entry: assign(({ spawn }) => { + return { + ref: spawn(spawnedMachine, { input: { greeting: 'hello' } }) + }; + }) + }); + + createActor(machine).start(); + }); + + it('should create a promise with input', async () => { + const promiseLogic = fromPromise<{ count: number }, { count: number }>( + ({ input }) => Promise.resolve(input) + ); + + const promiseActor = createActor(promiseLogic, { + input: { count: 42 } + }).start(); + + await new Promise((res) => setTimeout(res, 5)); + + expect(promiseActor.getSnapshot().output).toEqual({ count: 42 }); + }); + + it('should create a transition function actor with input', () => { + const transitionLogic = fromTransition( + (state) => state, + ({ input }) => input + ); + + const transitionActor = createActor(transitionLogic, { + input: { count: 42 } + }).start(); + + expect(transitionActor.getSnapshot().context).toEqual({ count: 42 }); + }); + + it('should create an observable actor with input', (done) => { + const observableLogic = fromObservable< + { count: number }, + { count: number } + >(({ input }) => of(input)); + + const observableActor = createActor(observableLogic, { + input: { count: 42 } + }); + + const sub = observableActor.subscribe((state) => { + if (state.context?.count !== 42) return; + expect(state.context).toEqual({ count: 42 }); + done(); + sub.unsubscribe(); + }); + + observableActor.start(); + }); + + it('should create a callback actor with input', (done) => { + const callbackLogic = fromCallback(({ input }) => { + expect(input).toEqual({ count: 42 }); + done(); + }); + + createActor(callbackLogic, { + input: { count: 42 } + }).start(); + }); + + it('should provide a dynamic inline input to the referenced actor', () => { + const spy = jest.fn(); + + const child = createMachine({ + context: ({ input }: { input: number }) => { + spy(input); + return {}; + } + }); + + const machine = createMachine({ + types: {} as { + actors: { + src: 'child'; + logic: typeof child; + }; + input: number; + context: { + count: number; + }; + }, + context: ({ input }) => ({ + count: input + }), + invoke: { + src: child, + input: ({ context }) => { + return context.count + 100; + } + } + }); + + createActor(machine, { input: 42 }).start(); + + expect(spy).toHaveBeenCalledWith(142); + }); + + it('should call the input factory with self when invoking', () => { + const spy = jest.fn(); + + const machine = createMachine({ + invoke: { + src: createMachine({}), + input: ({ self }: any) => spy(self) + } + }); + + const actor = createActor(machine).start(); + + expect(spy).toHaveBeenCalledWith(actor); + }); + + it('should call the input factory with self when spawning', () => { + const spy = jest.fn(); + + const childMachine = createMachine({}); + + const machine = createMachine({ + entry2: (_, enq) => { + enq.spawn(childMachine, { + input: ({ self }: any) => spy(self) + }); + } + }); + + const actor = createActor(machine).start(); + + expect(spy).toHaveBeenCalledWith(actor); + }); +}); diff --git a/packages/core/test/inspect.test.ts b/packages/core/test/inspect.test.ts index 87ec10e6eb..8062f0bc64 100644 --- a/packages/core/test/inspect.test.ts +++ b/packages/core/test/inspect.test.ts @@ -240,214 +240,214 @@ describe('inspect', () => { ['@xstate.actor', '@xstate.event', '@xstate.snapshot'].includes(ev.type) ) ).toMatchInlineSnapshot(` - [ - { - "actorId": "x:1", - "type": "@xstate.actor", - }, - { - "actorId": "x:2", - "type": "@xstate.actor", - }, - { - "event": { - "input": undefined, - "type": "xstate.init", - }, - "sourceId": undefined, - "targetId": "x:1", - "type": "@xstate.event", - }, - { - "event": { - "input": undefined, - "type": "xstate.init", - }, - "sourceId": "x:1", - "targetId": "x:2", - "type": "@xstate.event", - }, - { - "actorId": "x:2", - "event": { - "input": undefined, - "type": "xstate.init", - }, - "snapshot": { - "value": "start", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:1", - "event": { - "input": undefined, - "type": "xstate.init", - }, - "snapshot": { - "value": "waiting", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "event": { - "type": "load", - }, - "sourceId": undefined, - "targetId": "x:1", - "type": "@xstate.event", - }, - { - "event": { - "type": "loadChild", - }, - "sourceId": "x:1", - "targetId": "x:2", - "type": "@xstate.event", - }, - { - "actorId": "x:3", - "type": "@xstate.actor", - }, - { - "event": { - "input": undefined, - "type": "xstate.init", - }, - "sourceId": "x:2", - "targetId": "x:3", - "type": "@xstate.event", - }, - { - "actorId": "x:3", - "event": { - "input": undefined, - "type": "xstate.init", - }, - "snapshot": { - "error": undefined, - "input": undefined, - "output": undefined, - "status": "active", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:2", - "event": { - "type": "loadChild", - }, - "snapshot": { - "value": "loading", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:1", - "event": { - "type": "load", - }, - "snapshot": { - "value": "waiting", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "event": { - "data": 42, - "type": "xstate.promise.resolve", - }, - "sourceId": "x:3", - "targetId": "x:3", - "type": "@xstate.event", - }, - { - "event": { - "actorId": "0.(machine).loading", - "output": 42, - "type": "xstate.done.actor.0.(machine).loading", - }, - "sourceId": "x:3", - "targetId": "x:2", - "type": "@xstate.event", - }, - { - "event": { - "type": "toParent", - }, - "sourceId": "x:2", - "targetId": "x:1", - "type": "@xstate.event", - }, - { - "actorId": "x:1", - "event": { - "type": "toParent", - }, - "snapshot": { - "value": "waiting", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "event": { - "actorId": "child", - "output": undefined, - "type": "xstate.done.actor.child", - }, - "sourceId": "x:2", - "targetId": "x:1", - "type": "@xstate.event", - }, - { - "actorId": "x:1", - "event": { - "actorId": "child", - "output": undefined, - "type": "xstate.done.actor.child", - }, - "snapshot": { - "value": "success", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:2", - "event": { - "actorId": "0.(machine).loading", - "output": 42, - "type": "xstate.done.actor.0.(machine).loading", - }, - "snapshot": { - "value": "loaded", - }, - "status": "done", - "type": "@xstate.snapshot", - }, - { - "actorId": "x:3", - "event": { - "data": 42, - "type": "xstate.promise.resolve", - }, - "snapshot": { - "error": undefined, - "input": undefined, - "output": 42, - "status": "done", - }, - "status": "done", - "type": "@xstate.snapshot", - }, - ] - `); +[ + { + "actorId": "x:0", + "type": "@xstate.actor", + }, + { + "actorId": "x:1", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": "x:0", + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "actorId": "x:1", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "start", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:0", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "load", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "event": { + "type": "loadChild", + }, + "sourceId": "x:0", + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "actorId": "x:2", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": "x:1", + "targetId": "x:2", + "type": "@xstate.event", + }, + { + "actorId": "x:2", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "error": undefined, + "input": undefined, + "output": undefined, + "status": "active", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:1", + "event": { + "type": "loadChild", + }, + "snapshot": { + "value": "loading", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:0", + "event": { + "type": "load", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "data": 42, + "type": "xstate.promise.resolve", + }, + "sourceId": "x:2", + "targetId": "x:2", + "type": "@xstate.event", + }, + { + "event": { + "actorId": "0.(machine).loading", + "output": 42, + "type": "xstate.done.actor.0.(machine).loading", + }, + "sourceId": "x:2", + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "event": { + "type": "toParent", + }, + "sourceId": "x:1", + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "type": "toParent", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "actorId": "child", + "output": undefined, + "type": "xstate.done.actor.child", + }, + "sourceId": "x:1", + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "actorId": "child", + "output": undefined, + "type": "xstate.done.actor.child", + }, + "snapshot": { + "value": "success", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:1", + "event": { + "actorId": "0.(machine).loading", + "output": 42, + "type": "xstate.done.actor.0.(machine).loading", + }, + "snapshot": { + "value": "loaded", + }, + "status": "done", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:2", + "event": { + "data": 42, + "type": "xstate.promise.resolve", + }, + "snapshot": { + "error": undefined, + "input": undefined, + "output": 42, + "status": "done", + }, + "status": "done", + "type": "@xstate.snapshot", + }, +] +`); }); it('can inspect microsteps from always events', async () => { @@ -474,202 +474,202 @@ describe('inspect', () => { }).start(); expect(events).toMatchInlineSnapshot(` - [ - { - "actorRef": { - "id": "x:4", - "xstate$$type": 1, - }, - "rootId": "x:4", - "type": "@xstate.actor", - }, - { - "_transitions": [ - { - "actions": [ - [Function], - ], - "eventType": "", - "guard": undefined, - "reenter": false, - "source": "#(machine).counting", - "target": undefined, - "toJSON": [Function], - }, - ], - "actorRef": { - "id": "x:4", - "xstate$$type": 1, - }, - "event": { - "input": undefined, - "type": "xstate.init", - }, - "rootId": "x:4", - "snapshot": { - "children": {}, - "context": { - "count": 1, - }, - "error": undefined, - "historyValue": {}, - "output": undefined, - "status": "active", - "tags": [], - "value": "counting", - }, - "type": "@xstate.microstep", - }, - { - "_transitions": [ - { - "actions": [ - [Function], - ], - "eventType": "", - "guard": undefined, - "reenter": false, - "source": "#(machine).counting", - "target": undefined, - "toJSON": [Function], - }, - ], - "actorRef": { - "id": "x:4", - "xstate$$type": 1, - }, - "event": { - "input": undefined, - "type": "xstate.init", - }, - "rootId": "x:4", - "snapshot": { - "children": {}, - "context": { - "count": 2, - }, - "error": undefined, - "historyValue": {}, - "output": undefined, - "status": "active", - "tags": [], - "value": "counting", - }, - "type": "@xstate.microstep", - }, - { - "_transitions": [ - { - "actions": [ - [Function], - ], - "eventType": "", - "guard": undefined, - "reenter": false, - "source": "#(machine).counting", - "target": undefined, - "toJSON": [Function], - }, - ], - "actorRef": { - "id": "x:4", - "xstate$$type": 1, - }, - "event": { - "input": undefined, - "type": "xstate.init", - }, - "rootId": "x:4", - "snapshot": { - "children": {}, - "context": { - "count": 3, - }, - "error": undefined, - "historyValue": {}, - "output": undefined, - "status": "active", - "tags": [], - "value": "counting", - }, - "type": "@xstate.microstep", - }, - { - "_transitions": [ - { - "actions": [], - "eventType": "", - "guard": [Function], - "reenter": false, - "source": "#(machine).counting", - "target": [ - "#(machine).done", - ], - "toJSON": [Function], - }, - ], - "actorRef": { - "id": "x:4", - "xstate$$type": 1, - }, - "event": { - "input": undefined, - "type": "xstate.init", - }, - "rootId": "x:4", - "snapshot": { - "children": {}, - "context": { - "count": 3, - }, - "error": undefined, - "historyValue": {}, - "output": undefined, - "status": "active", - "tags": [], - "value": "done", - }, - "type": "@xstate.microstep", - }, - { - "actorRef": { - "id": "x:4", - "xstate$$type": 1, - }, - "event": { - "input": undefined, - "type": "xstate.init", - }, - "rootId": "x:4", - "sourceRef": undefined, - "type": "@xstate.event", - }, - { - "actorRef": { - "id": "x:4", - "xstate$$type": 1, - }, - "event": { - "input": undefined, - "type": "xstate.init", - }, - "rootId": "x:4", - "snapshot": { - "children": {}, - "context": { - "count": 3, - }, - "error": undefined, - "historyValue": {}, - "output": undefined, - "status": "active", - "tags": [], - "value": "done", - }, - "type": "@xstate.snapshot", - }, - ] - `); +[ + { + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "rootId": "x:0", + "type": "@xstate.actor", + }, + { + "_transitions": [ + { + "actions": [ + [Function], + ], + "eventType": "", + "guard": undefined, + "reenter": false, + "source": "#(machine).counting", + "target": undefined, + "toJSON": [Function], + }, + ], + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 1, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "counting", + }, + "type": "@xstate.microstep", + }, + { + "_transitions": [ + { + "actions": [ + [Function], + ], + "eventType": "", + "guard": undefined, + "reenter": false, + "source": "#(machine).counting", + "target": undefined, + "toJSON": [Function], + }, + ], + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 2, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "counting", + }, + "type": "@xstate.microstep", + }, + { + "_transitions": [ + { + "actions": [ + [Function], + ], + "eventType": "", + "guard": undefined, + "reenter": false, + "source": "#(machine).counting", + "target": undefined, + "toJSON": [Function], + }, + ], + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 3, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "counting", + }, + "type": "@xstate.microstep", + }, + { + "_transitions": [ + { + "actions": [], + "eventType": "", + "guard": [Function], + "reenter": false, + "source": "#(machine).counting", + "target": [ + "#(machine).done", + ], + "toJSON": [Function], + }, + ], + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 3, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "done", + }, + "type": "@xstate.microstep", + }, + { + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "sourceRef": undefined, + "type": "@xstate.event", + }, + { + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 3, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "done", + }, + "type": "@xstate.snapshot", + }, +] +`); }); it('can inspect microsteps from raised events', async () => { @@ -699,7 +699,7 @@ describe('inspect', () => { expect(simplifyEvents(events)).toMatchInlineSnapshot(` [ { - "actorId": "x:5", + "actorId": "x:0", "type": "@xstate.actor", }, { @@ -738,7 +738,7 @@ describe('inspect', () => { "type": "xstate.init", }, "sourceId": undefined, - "targetId": "x:5", + "targetId": "x:0", "type": "@xstate.event", }, { @@ -768,7 +768,7 @@ describe('inspect', () => { "type": "@xstate.action", }, { - "actorId": "x:5", + "actorId": "x:0", "event": { "input": undefined, "type": "xstate.init", @@ -798,68 +798,68 @@ describe('inspect', () => { actorRef.send({ type: 'EV' }); expect(simplifyEvents(events)).toMatchInlineSnapshot(` - [ - { - "actorId": "x:6", - "type": "@xstate.actor", - }, - { - "event": { - "input": undefined, - "type": "xstate.init", - }, - "sourceId": undefined, - "targetId": "x:6", - "type": "@xstate.event", - }, - { - "actorId": "x:6", - "event": { - "input": undefined, - "type": "xstate.init", - }, - "snapshot": { - "value": "a", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "event": { - "type": "EV", - }, - "sourceId": undefined, - "targetId": "x:6", - "type": "@xstate.event", - }, - { - "event": { - "type": "EV", - }, - "transitions": [ - { - "eventType": "EV", - "target": [ - "(machine).b", - ], - }, - ], - "type": "@xstate.microstep", - "value": "b", - }, - { - "actorId": "x:6", - "event": { - "type": "EV", - }, - "snapshot": { - "value": "b", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - ] - `); +[ + { + "actorId": "x:0", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "a", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "EV", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "event": { + "type": "EV", + }, + "transitions": [ + { + "eventType": "EV", + "target": [ + "(machine).b", + ], + }, + ], + "type": "@xstate.microstep", + "value": "b", + }, + { + "actorId": "x:0", + "event": { + "type": "EV", + }, + "snapshot": { + "value": "b", + }, + "status": "active", + "type": "@xstate.snapshot", + }, +] +`); }); it('should inspect microsteps for eventless/always transitions', () => { @@ -878,83 +878,83 @@ describe('inspect', () => { actorRef.send({ type: 'EV' }); expect(simplifyEvents(events)).toMatchInlineSnapshot(` - [ - { - "actorId": "x:7", - "type": "@xstate.actor", - }, - { - "event": { - "input": undefined, - "type": "xstate.init", - }, - "sourceId": undefined, - "targetId": "x:7", - "type": "@xstate.event", - }, - { - "actorId": "x:7", - "event": { - "input": undefined, - "type": "xstate.init", - }, - "snapshot": { - "value": "a", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - { - "event": { - "type": "EV", - }, - "sourceId": undefined, - "targetId": "x:7", - "type": "@xstate.event", - }, - { - "event": { - "type": "EV", - }, - "transitions": [ - { - "eventType": "EV", - "target": [ - "(machine).b", - ], - }, - ], - "type": "@xstate.microstep", - "value": "b", - }, - { - "event": { - "type": "EV", - }, - "transitions": [ - { - "eventType": "", - "target": [ - "(machine).c", - ], - }, - ], - "type": "@xstate.microstep", - "value": "c", - }, - { - "actorId": "x:7", - "event": { - "type": "EV", - }, - "snapshot": { - "value": "c", - }, - "status": "active", - "type": "@xstate.snapshot", - }, - ] - `); +[ + { + "actorId": "x:0", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "a", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "EV", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "event": { + "type": "EV", + }, + "transitions": [ + { + "eventType": "EV", + "target": [ + "(machine).b", + ], + }, + ], + "type": "@xstate.microstep", + "value": "b", + }, + { + "event": { + "type": "EV", + }, + "transitions": [ + { + "eventType": "", + "target": [ + "(machine).c", + ], + }, + ], + "type": "@xstate.microstep", + "value": "c", + }, + { + "actorId": "x:0", + "event": { + "type": "EV", + }, + "snapshot": { + "value": "c", + }, + "status": "active", + "type": "@xstate.snapshot", + }, +] +`); }); it('should inspect actions', () => { diff --git a/packages/core/test/inspect.v6.test.ts b/packages/core/test/inspect.v6.test.ts new file mode 100644 index 0000000000..2535c127ff --- /dev/null +++ b/packages/core/test/inspect.v6.test.ts @@ -0,0 +1,1182 @@ +import { + createActor, + createMachine, + fromPromise, + waitFor, + InspectionEvent, + isMachineSnapshot, + setup, + fromCallback +} from '../src'; +import { InspectedActionEvent } from '../src/inspection'; + +function simplifyEvents( + inspectionEvents: InspectionEvent[], + filter?: (ev: InspectionEvent) => boolean +) { + return inspectionEvents + .filter(filter ?? (() => true)) + .map((inspectionEvent) => { + if (inspectionEvent.type === '@xstate.event') { + return { + type: inspectionEvent.type, + sourceId: inspectionEvent.sourceRef?.sessionId, + targetId: inspectionEvent.actorRef.sessionId, + event: inspectionEvent.event + }; + } + if (inspectionEvent.type === '@xstate.actor') { + return { + type: inspectionEvent.type, + actorId: inspectionEvent.actorRef.sessionId + }; + } + + if (inspectionEvent.type === '@xstate.snapshot') { + return { + type: inspectionEvent.type, + actorId: inspectionEvent.actorRef.sessionId, + snapshot: isMachineSnapshot(inspectionEvent.snapshot) + ? { value: inspectionEvent.snapshot.value } + : inspectionEvent.snapshot, + event: inspectionEvent.event, + status: inspectionEvent.snapshot.status + }; + } + + if (inspectionEvent.type === '@xstate.microstep') { + return { + type: inspectionEvent.type, + value: (inspectionEvent.snapshot as any).value, + event: inspectionEvent.event, + transitions: inspectionEvent._transitions.map((t) => ({ + eventType: t.eventType, + target: t.target?.map((target) => target.id) ?? [] + })) + }; + } + + if (inspectionEvent.type === '@xstate.action') { + return { + type: inspectionEvent.type, + action: inspectionEvent.action + }; + } + }); +} + +describe('inspect', () => { + it('the .inspect option can observe inspection events', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + on: { + NEXT: 'c' + } + }, + c: {} + } + }); + + const events: InspectionEvent[] = []; + + const actor = createActor(machine, { + inspect: (ev) => events.push(ev), + id: 'parent' + }); + actor.start(); + + actor.send({ type: 'NEXT' }); + actor.send({ type: 'NEXT' }); + + expect( + simplifyEvents(events, (ev) => + ['@xstate.actor', '@xstate.event', '@xstate.snapshot'].includes(ev.type) + ) + ).toMatchInlineSnapshot(` + [ + { + "actorId": "x:0", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "a", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "NEXT", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "type": "NEXT", + }, + "snapshot": { + "value": "b", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "NEXT", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "type": "NEXT", + }, + "snapshot": { + "value": "c", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + ] + `); + }); + + it('can inspect communications between actors', async () => { + const parentMachine = createMachine({ + initial: 'waiting', + states: { + waiting: {}, + success: {} + }, + invoke: { + src: createMachine({ + initial: 'start', + states: { + start: { + on: { + loadChild: 'loading' + } + }, + loading: { + invoke: { + src: fromPromise(() => { + return Promise.resolve(42); + }), + onDone: ({ parent }) => { + parent?.send({ type: 'toParent' }); + return { + target: 'loaded' + }; + } + } + }, + loaded: { + type: 'final' + } + } + }), + id: 'child', + onDone: (_, enq) => { + enq.action(() => {}); + return { + target: '.success' + }; + } + }, + on: { + load: ({ children }) => { + children.child.send({ type: 'loadChild' }); + } + } + }); + + const events: InspectionEvent[] = []; + + const actor = createActor(parentMachine, { + inspect: { + next: (event) => { + events.push(event); + } + } + }); + + actor.start(); + actor.send({ type: 'load' }); + + await waitFor(actor, (state) => state.value === 'success'); + + expect( + simplifyEvents(events, (ev) => + ['@xstate.actor', '@xstate.event', '@xstate.snapshot'].includes(ev.type) + ) + ).toMatchInlineSnapshot(` +[ + { + "actorId": "x:0", + "type": "@xstate.actor", + }, + { + "actorId": "x:1", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": "x:0", + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "actorId": "x:1", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "start", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:0", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "load", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "event": { + "type": "loadChild", + }, + "sourceId": undefined, + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "actorId": "x:2", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": "x:1", + "targetId": "x:2", + "type": "@xstate.event", + }, + { + "actorId": "x:2", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "error": undefined, + "input": undefined, + "output": undefined, + "status": "active", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:1", + "event": { + "type": "loadChild", + }, + "snapshot": { + "value": "loading", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:0", + "event": { + "type": "load", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "data": 42, + "type": "xstate.promise.resolve", + }, + "sourceId": "x:2", + "targetId": "x:2", + "type": "@xstate.event", + }, + { + "event": { + "actorId": "0.(machine).loading", + "output": 42, + "type": "xstate.done.actor.0.(machine).loading", + }, + "sourceId": "x:2", + "targetId": "x:1", + "type": "@xstate.event", + }, + { + "event": { + "type": "toParent", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "type": "toParent", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "toParent", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "type": "toParent", + }, + "snapshot": { + "value": "waiting", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "actorId": "child", + "output": undefined, + "type": "xstate.done.actor.child", + }, + "sourceId": "x:1", + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "actorId": "child", + "output": undefined, + "type": "xstate.done.actor.child", + }, + "snapshot": { + "value": "success", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:1", + "event": { + "actorId": "0.(machine).loading", + "output": 42, + "type": "xstate.done.actor.0.(machine).loading", + }, + "snapshot": { + "value": "loaded", + }, + "status": "done", + "type": "@xstate.snapshot", + }, + { + "actorId": "x:2", + "event": { + "data": 42, + "type": "xstate.promise.resolve", + }, + "snapshot": { + "error": undefined, + "input": undefined, + "output": 42, + "status": "done", + }, + "status": "done", + "type": "@xstate.snapshot", + }, +] +`); + }); + + it('can inspect microsteps from always events', async () => { + const machine = createMachine({ + context: { count: 0 }, + initial: 'counting', + states: { + counting: { + always: ({ context }) => { + if (context.count === 3) { + return { + target: 'done' + }; + } + return { + context: { + ...context, + count: context.count + 1 + } + }; + } + }, + done: {} + } + }); + + const events: InspectionEvent[] = []; + + createActor(machine, { + inspect: (ev) => { + events.push(ev); + } + }).start(); + + expect(events).toMatchInlineSnapshot(` +[ + { + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "rootId": "x:0", + "type": "@xstate.actor", + }, + { + "_transitions": [ + { + "actions": [], + "eventType": "", + "fn": [Function], + "guard": undefined, + "reenter": false, + "source": "#(machine).counting", + "target": undefined, + "toJSON": [Function], + }, + ], + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 1, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "counting", + }, + "type": "@xstate.microstep", + }, + { + "_transitions": [ + { + "actions": [], + "eventType": "", + "fn": [Function], + "guard": undefined, + "reenter": false, + "source": "#(machine).counting", + "target": undefined, + "toJSON": [Function], + }, + ], + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 2, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "counting", + }, + "type": "@xstate.microstep", + }, + { + "_transitions": [ + { + "actions": [], + "eventType": "", + "fn": [Function], + "guard": undefined, + "reenter": false, + "source": "#(machine).counting", + "target": undefined, + "toJSON": [Function], + }, + ], + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 3, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "counting", + }, + "type": "@xstate.microstep", + }, + { + "_transitions": [ + { + "actions": [], + "eventType": "", + "fn": [Function], + "guard": undefined, + "reenter": false, + "source": "#(machine).counting", + "target": undefined, + "toJSON": [Function], + }, + ], + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 3, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "done", + }, + "type": "@xstate.microstep", + }, + { + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "sourceRef": undefined, + "type": "@xstate.event", + }, + { + "actorRef": { + "id": "x:0", + "xstate$$type": 1, + }, + "event": { + "input": undefined, + "type": "xstate.init", + }, + "rootId": "x:0", + "snapshot": { + "children": {}, + "context": { + "count": 3, + }, + "error": undefined, + "historyValue": {}, + "output": undefined, + "status": "active", + "tags": [], + "value": "done", + }, + "type": "@xstate.snapshot", + }, +] +`); + }); + + it('can inspect microsteps from raised events', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry2: (_, enq) => { + enq.raise({ type: 'to_b' }); + }, + on: { to_b: 'b' } + }, + b: { + entry2: (_, enq) => { + enq.raise({ type: 'to_c' }); + }, + on: { to_c: 'c' } + }, + c: {} + } + }); + + const events: InspectionEvent[] = []; + + const actor = createActor(machine, { + inspect: (ev) => { + events.push(ev); + } + }).start(); + + expect(actor.getSnapshot().matches('c')).toBe(true); + + expect(simplifyEvents(events)).toMatchInlineSnapshot(` +[ + { + "actorId": "x:0", + "type": "@xstate.actor", + }, + { + "event": { + "type": "to_b", + }, + "transitions": [ + { + "eventType": "to_b", + "target": [ + "(machine).b", + ], + }, + ], + "type": "@xstate.microstep", + "value": "b", + }, + { + "event": { + "type": "to_c", + }, + "transitions": [ + { + "eventType": "to_c", + "target": [ + "(machine).c", + ], + }, + ], + "type": "@xstate.microstep", + "value": "c", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "action": { + "params": { + "delay": undefined, + "event": { + "type": "to_b", + }, + "id": undefined, + }, + "type": "xstate.raise", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": { + "delay": undefined, + "event": { + "type": "to_c", + }, + "id": undefined, + }, + "type": "xstate.raise", + }, + "type": "@xstate.action", + }, + { + "actorId": "x:0", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "c", + }, + "status": "active", + "type": "@xstate.snapshot", + }, +] +`); + }); + + it('should inspect microsteps for normal transitions', () => { + const events: any[] = []; + const machine = createMachine({ + initial: 'a', + states: { + a: { on: { EV: 'b' } }, + b: {} + } + }); + const actorRef = createActor(machine, { + inspect: (ev) => events.push(ev) + }).start(); + actorRef.send({ type: 'EV' }); + + expect(simplifyEvents(events)).toMatchInlineSnapshot(` +[ + { + "actorId": "x:0", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "a", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "EV", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "event": { + "type": "EV", + }, + "transitions": [ + { + "eventType": "EV", + "target": [ + "(machine).b", + ], + }, + ], + "type": "@xstate.microstep", + "value": "b", + }, + { + "actorId": "x:0", + "event": { + "type": "EV", + }, + "snapshot": { + "value": "b", + }, + "status": "active", + "type": "@xstate.snapshot", + }, +] +`); + }); + + it('should inspect microsteps for eventless/always transitions', () => { + const events: any[] = []; + const machine = createMachine({ + initial: 'a', + states: { + a: { on: { EV: 'b' } }, + b: { always: 'c' }, + c: {} + } + }); + const actorRef = createActor(machine, { + inspect: (ev) => events.push(ev) + }).start(); + actorRef.send({ type: 'EV' }); + + expect(simplifyEvents(events)).toMatchInlineSnapshot(` +[ + { + "actorId": "x:0", + "type": "@xstate.actor", + }, + { + "event": { + "input": undefined, + "type": "xstate.init", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "actorId": "x:0", + "event": { + "input": undefined, + "type": "xstate.init", + }, + "snapshot": { + "value": "a", + }, + "status": "active", + "type": "@xstate.snapshot", + }, + { + "event": { + "type": "EV", + }, + "sourceId": undefined, + "targetId": "x:0", + "type": "@xstate.event", + }, + { + "event": { + "type": "EV", + }, + "transitions": [ + { + "eventType": "EV", + "target": [ + "(machine).b", + ], + }, + ], + "type": "@xstate.microstep", + "value": "b", + }, + { + "event": { + "type": "EV", + }, + "transitions": [ + { + "eventType": "", + "target": [ + "(machine).c", + ], + }, + ], + "type": "@xstate.microstep", + "value": "c", + }, + { + "actorId": "x:0", + "event": { + "type": "EV", + }, + "snapshot": { + "value": "c", + }, + "status": "active", + "type": "@xstate.snapshot", + }, +] +`); + }); + + it('should inspect actions', () => { + const events: InspectedActionEvent[] = []; + + const machine = setup({ + actions: { + enter1: () => {}, + exit1: () => {}, + stringAction: () => {}, + namedAction: () => {} + } + }).createMachine({ + entry: 'enter1', + exit: 'exit1', + initial: 'loading', + states: { + loading: { + on: { + event: { + target: 'done', + actions: [ + 'stringAction', + { type: 'namedAction', params: { foo: 'bar' } }, + () => { + /* inline */ + } + ] + } + } + }, + done: { + type: 'final' + } + } + }); + + const actor = createActor(machine, { + inspect: (ev) => { + if (ev.type === '@xstate.action') { + events.push(ev); + } + } + }); + + actor.start(); + actor.send({ type: 'event' }); + + expect(simplifyEvents(events, (ev) => ev.type === '@xstate.action')) + .toMatchInlineSnapshot(` +[ + { + "action": { + "params": undefined, + "type": "enter1", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": undefined, + "type": "stringAction", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": { + "foo": "bar", + }, + "type": "namedAction", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": undefined, + "type": "(anonymous)", + }, + "type": "@xstate.action", + }, + { + "action": { + "params": undefined, + "type": "exit1", + }, + "type": "@xstate.action", + }, +] +`); + }); + + it('@xstate.microstep inspection events should report no transitions if an unknown event was sent', () => { + const machine = createMachine({}); + expect.assertions(1); + + const actor = createActor(machine, { + inspect: (ev) => { + if (ev.type === '@xstate.microstep') { + expect(ev._transitions.length).toBe(0); + } + } + }); + + actor.start(); + actor.send({ type: 'any' }); + }); + + it('actor.system.inspect(…) can inspect actors', () => { + const actor = createActor(createMachine({})); + const events: InspectionEvent[] = []; + + actor.system.inspect((ev) => { + events.push(ev); + }); + + actor.start(); + + expect(events).toContainEqual( + expect.objectContaining({ + type: '@xstate.event' + }) + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: '@xstate.snapshot' + }) + ); + }); + + it('actor.system.inspect(…) can inspect actors (observer)', () => { + const actor = createActor(createMachine({})); + const events: InspectionEvent[] = []; + + actor.system.inspect({ + next: (ev) => { + events.push(ev); + } + }); + + actor.start(); + + expect(events).toContainEqual( + expect.objectContaining({ + type: '@xstate.event' + }) + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: '@xstate.snapshot' + }) + ); + }); + + it('actor.system.inspect(…) can be unsubscribed', () => { + const actor = createActor(createMachine({})); + const events: InspectionEvent[] = []; + + const sub = actor.system.inspect((ev) => { + events.push(ev); + }); + + actor.start(); + + expect(events.length).toEqual(2); + + events.length = 0; + + sub.unsubscribe(); + + actor.send({ type: 'someEvent' }); + + expect(events.length).toEqual(0); + }); + + it('actor.system.inspect(…) can be unsubscribed (observer)', () => { + const actor = createActor(createMachine({})); + const events: InspectionEvent[] = []; + + const sub = actor.system.inspect({ + next: (ev) => { + events.push(ev); + } + }); + + actor.start(); + + expect(events.length).toEqual(2); + + events.length = 0; + + sub.unsubscribe(); + + actor.send({ type: 'someEvent' }); + + expect(events.length).toEqual(0); + }); +}); diff --git a/packages/core/test/internalTransitions.v6.test.ts b/packages/core/test/internalTransitions.v6.test.ts new file mode 100644 index 0000000000..628c194ce6 --- /dev/null +++ b/packages/core/test/internalTransitions.v6.test.ts @@ -0,0 +1,384 @@ +import { createMachine, createActor, assign } from '../src/index'; +import { trackEntries } from './utils'; + +describe('internal transitions', () => { + it('parent state should enter child state without re-entering self', () => { + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + initial: 'a', + states: { + a: {}, + b: {} + }, + on: { + CLICK: '.b' + } + } + } + }); + + const flushTracked = trackEntries(machine); + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'CLICK' + }); + + expect(actor.getSnapshot().value).toEqual({ foo: 'b' }); + expect(flushTracked()).toEqual(['exit: foo.a', 'enter: foo.b']); + }); + + it('parent state should re-enter self upon transitioning to child state if transition is reentering', () => { + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + initial: 'left', + states: { + left: {}, + right: {} + }, + on: { + NEXT: () => ({ + target: '.right', + reenter: true + }) + } + } + } + }); + + const flushTracked = trackEntries(machine); + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'NEXT' + }); + + expect(actor.getSnapshot().value).toEqual({ foo: 'right' }); + expect(flushTracked()).toEqual([ + 'exit: foo.left', + 'exit: foo', + 'enter: foo', + 'enter: foo.right' + ]); + }); + + it('parent state should only exit/reenter if there is an explicit self-transition', () => { + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: {} + }, + on: { + RESET: { + target: 'foo', + reenter: true + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + const actor = createActor(machine).start(); + actor.send({ + type: 'NEXT' + }); + flushTracked(); + + actor.send({ + type: 'RESET' + }); + + expect(actor.getSnapshot().value).toEqual({ foo: 'a' }); + expect(flushTracked()).toEqual([ + 'exit: foo.b', + 'exit: foo', + 'enter: foo', + 'enter: foo.a' + ]); + }); + + it('parent state should only exit/reenter if there is an explicit self-transition (to child)', () => { + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + initial: 'a', + states: { + a: {}, + b: {} + }, + on: { + RESET_TO_B: { + target: 'foo.b', + reenter: true + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + const actor = createActor(machine).start(); + flushTracked(); + + actor.send({ + type: 'RESET_TO_B' + }); + + expect(actor.getSnapshot().value).toEqual({ foo: 'b' }); + expect(flushTracked()).toEqual([ + 'exit: foo.a', + 'exit: foo', + 'enter: foo', + 'enter: foo.b' + ]); + }); + + it('should listen to events declared at top state', () => { + const machine = createMachine({ + initial: 'foo', + on: { + CLICKED: '.bar' + }, + states: { + foo: {}, + bar: {} + } + }); + const actor = createActor(machine).start(); + actor.send({ + type: 'CLICKED' + }); + + expect(actor.getSnapshot().value).toEqual('bar'); + }); + + it('should work with targetless transitions (in conditional array)', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + on: { + TARGETLESS_ARRAY: (_, enq) => void enq.action(spy) + } + } + } + }); + const actor = createActor(machine).start(); + actor.send({ + type: 'TARGETLESS_ARRAY' + }); + expect(spy).toHaveBeenCalled(); + }); + + it('should work with targetless transitions (in object)', () => { + const spy = jest.fn(); + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + on: { + TARGETLESS_OBJECT: (_, enq) => void enq.action(spy) + } + } + } + }); + const actor = createActor(machine).start(); + actor.send({ + type: 'TARGETLESS_OBJECT' + }); + expect(spy).toHaveBeenCalled(); + }); + + it('should work on parent with targetless transitions (in conditional array)', () => { + const spy = jest.fn(); + const machine = createMachine({ + on: { + TARGETLESS_ARRAY: (_, enq) => void enq.action(spy) + }, + initial: 'foo', + states: { foo: {} } + }); + const actor = createActor(machine).start(); + actor.send({ + type: 'TARGETLESS_ARRAY' + }); + expect(spy).toHaveBeenCalled(); + }); + + it('should work on parent with targetless transitions (in object)', () => { + const spy = jest.fn(); + const machine = createMachine({ + on: { + TARGETLESS_OBJECT: (_, enq) => void enq.action(spy) + }, + initial: 'foo', + states: { foo: {} } + }); + const actor = createActor(machine).start(); + actor.send({ + type: 'TARGETLESS_OBJECT' + }); + expect(spy).toHaveBeenCalled(); + }); + + it('should maintain the child state when targetless transition is handled by parent', () => { + const machine = createMachine({ + initial: 'foo', + on: { + PARENT_EVENT: (_, enq) => void enq.action(() => {}) + }, + states: { + foo: {} + } + }); + const actor = createActor(machine).start(); + actor.send({ + type: 'PARENT_EVENT' + }); + + expect(actor.getSnapshot().value).toEqual('foo'); + }); + + it('should reenter proper descendants of a source state of an internal transition', () => { + const machine = createMachine({ + types: {} as { + context: { + sourceStateEntries: number; + directDescendantEntries: number; + deepDescendantEntries: number; + }; + }, + context: { + sourceStateEntries: 0, + directDescendantEntries: 0, + deepDescendantEntries: 0 + }, + initial: 'a1', + states: { + a1: { + initial: 'a11', + entry2: ({ context }) => ({ + context: { + ...context, + sourceStateEntries: context.sourceStateEntries + 1 + } + }), + states: { + a11: { + initial: 'a111', + entry2: ({ context }) => ({ + context: { + ...context, + directDescendantEntries: context.directDescendantEntries + 1 + } + }), + states: { + a111: { + entry2: ({ context }) => ({ + context: { + ...context, + deepDescendantEntries: context.deepDescendantEntries + 1 + } + }) + } + } + } + }, + on: { + REENTER: '.a11.a111' + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'REENTER' }); + + expect(service.getSnapshot().context).toEqual({ + sourceStateEntries: 1, + directDescendantEntries: 2, + deepDescendantEntries: 2 + }); + }); + + it('should exit proper descendants of a source state of an internal transition', () => { + const machine = createMachine({ + types: {} as { + context: { + sourceStateExits: number; + directDescendantExits: number; + deepDescendantExits: number; + }; + }, + context: { + sourceStateExits: 0, + directDescendantExits: 0, + deepDescendantExits: 0 + }, + initial: 'a1', + states: { + a1: { + initial: 'a11', + exit2: ({ context }) => ({ + context: { + ...context, + sourceStateExits: context.sourceStateExits + 1 + } + }), + states: { + a11: { + initial: 'a111', + exit2: ({ context }) => ({ + context: { + ...context, + directDescendantExits: context.directDescendantExits + 1 + } + }), + states: { + a111: { + exit2: ({ context }) => ({ + context: { + ...context, + deepDescendantExits: context.deepDescendantExits + 1 + } + }) + } + } + } + }, + on: { + REENTER: '.a11.a111' + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'REENTER' }); + + expect(service.getSnapshot().context).toEqual({ + sourceStateExits: 0, + directDescendantExits: 1, + deepDescendantExits: 1 + }); + }); +}); diff --git a/packages/core/test/interpreter.test.ts b/packages/core/test/interpreter.test.ts index 4f2d62ddd9..393210ad96 100644 --- a/packages/core/test/interpreter.test.ts +++ b/packages/core/test/interpreter.test.ts @@ -736,13 +736,13 @@ describe('interpreter', () => { } expect(console.warn).toMatchMockCallsInlineSnapshot(` - [ - [ - "Event "TIMER" was sent to stopped actor "x:27 (x:27)". This actor has already reached its final state, and will not transition. - Event: {"type":"TIMER"}", - ], - ] - `); +[ + [ + "Event "TIMER" was sent to stopped actor "x:0 (x:0)". This actor has already reached its final state, and will not transition. +Event: {"type":"TIMER"}", + ], +] +`); }); it('should be able to log (log action)', () => { @@ -1151,13 +1151,13 @@ describe('interpreter', () => { setTimeout(() => { expect(called).toBeFalsy(); expect(console.warn).toMatchMockCallsInlineSnapshot(` - [ - [ - "Event "TRIGGER" was sent to stopped actor "x:43 (x:43)". This actor has already reached its final state, and will not transition. - Event: {"type":"TRIGGER"}", - ], - ] - `); +[ + [ + "Event "TRIGGER" was sent to stopped actor "x:0 (x:0)". This actor has already reached its final state, and will not transition. +Event: {"type":"TRIGGER"}", + ], +] +`); done(); }, 10); }); diff --git a/packages/core/test/interpreter.v6.test.ts b/packages/core/test/interpreter.v6.test.ts new file mode 100644 index 0000000000..e837e5c60c --- /dev/null +++ b/packages/core/test/interpreter.v6.test.ts @@ -0,0 +1,1920 @@ +import { SimulatedClock } from '../src/SimulatedClock'; +import { + createActor, + assign, + sendParent, + StateValue, + createMachine, + ActorRefFrom, + ActorRef, + cancel, + raise, + stopChild, + log, + AnyActorRef +} from '../src/index.ts'; +import { interval, from } from 'rxjs'; +import { fromObservable } from '../src/actors/observable'; +import { PromiseActorLogic, fromPromise } from '../src/actors/promise'; +import { fromCallback } from '../src/actors/callback'; +import { assertEvent } from '../src/assert.ts'; + +const lightMachine = createMachine({ + id: 'light', + initial: 'green', + states: { + green: { + entry2: (_, enq) => { + enq.raise({ type: 'TIMER' }, { id: 'TIMER1', delay: 10 }); + }, + on: { + TIMER: 'yellow', + KEEP_GOING: (_, enq) => { + enq.cancel('TIMER1'); + } + } + }, + yellow: { + entry2: (_, enq) => { + enq.raise({ type: 'TIMER' }, { delay: 10 }); + }, + on: { + TIMER: 'red' + } + }, + red: { + after: { + 10: 'green' + } + } + } +}); + +describe('interpreter', () => { + describe('initial state', () => { + it('.getSnapshot returns the initial state', () => { + const machine = createMachine({ + initial: 'foo', + states: { + bar: {}, + foo: {} + } + }); + const service = createActor(machine); + + expect(service.getSnapshot().value).toEqual('foo'); + }); + + it('initially spawned actors should not be spawned when reading initial state', (done) => { + let promiseSpawned = 0; + + const machine = createMachine({ + initial: 'idle', + context: { + actor: undefined! as ActorRefFrom> + }, + states: { + idle: { + // entry: assign({ + // actor: ({ spawn }) => { + // return spawn( + // fromPromise( + // () => + // new Promise(() => { + // promiseSpawned++; + // }) + // ) + // ); + // } + // }), + entry2: ({ context }, enq) => ({ + context: { + ...context, + actor: enq.spawn( + fromPromise( + () => + new Promise(() => { + promiseSpawned++; + }) + ) + ) + } + }) + } + } + }); + + const service = createActor(machine); + + expect(promiseSpawned).toEqual(0); + + service.getSnapshot(); + service.getSnapshot(); + service.getSnapshot(); + + expect(promiseSpawned).toEqual(0); + + service.start(); + + setTimeout(() => { + expect(promiseSpawned).toEqual(1); + done(); + }, 100); + }); + + it('does not execute actions from a restored state', () => { + let called = false; + const machine = createMachine({ + initial: 'green', + states: { + green: { + on: { + TIMER: (_, enq) => { + enq.action(() => (called = true)); + return { target: 'yellow' }; + } + } + }, + yellow: { + on: { + TIMER: { + target: 'red' + } + } + }, + red: { + on: { + TIMER: 'green' + } + } + } + }); + + let actorRef = createActor(machine).start(); + + actorRef.send({ type: 'TIMER' }); + called = false; + const persisted = actorRef.getPersistedSnapshot(); + actorRef = createActor(machine, { snapshot: persisted }).start(); + + expect(called).toBe(false); + }); + + it('should not execute actions that are not part of the actual persisted state', () => { + let called = false; + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry2: (_, enq) => { + // this should not be called when starting from a different state + enq.action(() => (called = true)); + }, + always: 'b' + }, + b: {} + } + }); + + const actorRef = createActor(machine).start(); + called = false; + expect(actorRef.getSnapshot().value).toEqual('b'); + const persisted = actorRef.getPersistedSnapshot(); + + createActor(machine, { snapshot: persisted }).start(); + + expect(called).toBe(false); + }); + }); + + describe('subscribing', () => { + const machine = createMachine({ + initial: 'active', + states: { + active: {} + } + }); + + it('should not notify subscribers of the current state upon subscription (subscribe)', () => { + const spy = jest.fn(); + const service = createActor(machine).start(); + + service.subscribe(spy); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('send with delay', () => { + it('can send an event after a delay', async () => { + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + entry2: (_, enq) => { + enq.raise({ type: 'TIMER' }, { delay: 10 }); + }, + on: { + TIMER: 'bar' + } + }, + bar: {} + } + }); + const actorRef = createActor(machine); + expect(actorRef.getSnapshot().value).toBe('foo'); + + await new Promise((res) => setTimeout(res, 10)); + expect(actorRef.getSnapshot().value).toBe('foo'); + + actorRef.start(); + expect(actorRef.getSnapshot().value).toBe('foo'); + + await new Promise((res) => setTimeout(res, 5)); + expect(actorRef.getSnapshot().value).toBe('foo'); + + await new Promise((res) => setTimeout(res, 10)); + expect(actorRef.getSnapshot().value).toBe('bar'); + }); + + it('can send an event after a delay (expression)', () => { + interface DelayExprMachineCtx { + initialDelay: number; + } + + type DelayExpMachineEvents = + | { type: 'ACTIVATE'; wait: number } + | { type: 'FINISH' }; + + const delayExprMachine = createMachine({ + types: {} as { + context: DelayExprMachineCtx; + events: DelayExpMachineEvents; + }, + id: 'delayExpr', + context: { + initialDelay: 100 + }, + initial: 'idle', + states: { + idle: { + on: { + ACTIVATE: 'pending' + } + }, + pending: { + entry2: ({ context, event }, enq) => { + enq.raise( + { type: 'FINISH' }, + { + delay: + context.initialDelay + ('wait' in event ? event.wait : 0) + } + ); + }, + on: { + FINISH: 'finished' + } + }, + finished: { type: 'final' } + } + }); + + let stopped = false; + + const clock = new SimulatedClock(); + + const delayExprService = createActor(delayExprMachine, { + clock + }); + delayExprService.subscribe({ + complete: () => { + stopped = true; + } + }); + delayExprService.start(); + + delayExprService.send({ + type: 'ACTIVATE', + wait: 50 + }); + + clock.increment(101); + + expect(stopped).toBe(false); + + clock.increment(50); + + expect(stopped).toBe(true); + }); + + it('can send an event after a delay (expression using _event)', () => { + interface DelayExprMachineCtx { + initialDelay: number; + } + + type DelayExpMachineEvents = + | { + type: 'ACTIVATE'; + wait: number; + } + | { + type: 'FINISH'; + }; + + const delayExprMachine = createMachine({ + types: {} as { + context: DelayExprMachineCtx; + events: DelayExpMachineEvents; + }, + id: 'delayExpr', + context: { + initialDelay: 100 + }, + initial: 'idle', + states: { + idle: { + on: { + ACTIVATE: 'pending' + } + }, + pending: { + entry2: ({ context, event }, enq) => { + assertEvent(event, 'ACTIVATE'); + enq.raise( + { type: 'FINISH' }, + { delay: context.initialDelay + event.wait } + ); + }, + on: { + FINISH: 'finished' + } + }, + finished: { + type: 'final' + } + } + }); + + let stopped = false; + + const clock = new SimulatedClock(); + + const delayExprService = createActor(delayExprMachine, { + clock + }); + delayExprService.subscribe({ + complete: () => { + stopped = true; + } + }); + delayExprService.start(); + + delayExprService.send({ + type: 'ACTIVATE', + wait: 50 + }); + + clock.increment(101); + + expect(stopped).toBe(false); + + clock.increment(50); + + expect(stopped).toBe(true); + }); + + it('can send an event after a delay (delayed transitions)', (done) => { + const clock = new SimulatedClock(); + const letterMachine = createMachine( + { + types: {} as { + events: { type: 'FIRE_DELAY'; value: number }; + }, + id: 'letter', + context: { + delay: 100 + }, + initial: 'a', + states: { + a: { + after: { + delayA: 'b' + } + }, + b: { + after: { + someDelay: 'c' + } + }, + c: { + entry2: (_, enq) => { + enq.raise({ type: 'FIRE_DELAY', value: 200 }, { delay: 20 }); + }, + on: { + FIRE_DELAY: 'd' + } + }, + d: { + after: { + delayD: 'e' + } + }, + e: { + after: { someDelay: 'f' } + }, + f: { + type: 'final' + } + } + }, + { + delays: { + someDelay: ({ context }) => { + return context.delay + 50; + }, + delayA: ({ context }) => context.delay, + delayD: ({ context, event }) => context.delay + event.value + } + } + ); + + const actor = createActor(letterMachine, { clock }); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + + expect(actor.getSnapshot().value).toEqual('a'); + clock.increment(100); + expect(actor.getSnapshot().value).toEqual('b'); + clock.increment(100 + 50); + expect(actor.getSnapshot().value).toEqual('c'); + clock.increment(20); + expect(actor.getSnapshot().value).toEqual('d'); + clock.increment(100 + 200); + expect(actor.getSnapshot().value).toEqual('e'); + clock.increment(100 + 50); + }); + }); + + describe('activities (deprecated)', () => { + it('should start activities', () => { + const spy = jest.fn(); + + const activityMachine = createMachine( + { + id: 'activity', + initial: 'on', + states: { + on: { + invoke: { + src: 'myActivity' + }, + on: { + TURN_OFF: 'off' + } + }, + off: {} + } + }, + { + actors: { + myActivity: fromCallback(spy) + } + } + ); + const service = createActor(activityMachine); + + service.start(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should stop activities', () => { + const spy = jest.fn(); + + const activityMachine = createMachine( + { + id: 'activity', + initial: 'on', + states: { + on: { + invoke: { + src: 'myActivity' + }, + on: { + TURN_OFF: 'off' + } + }, + off: {} + } + }, + { + actors: { + myActivity: fromCallback(() => spy) + } + } + ); + const service = createActor(activityMachine); + + service.start(); + + expect(spy).not.toHaveBeenCalled(); + + service.send({ type: 'TURN_OFF' }); + + expect(spy).toHaveBeenCalled(); + }); + + it('should stop activities upon stopping the service', () => { + const spy = jest.fn(); + + const stopActivityMachine = createMachine( + { + id: 'stopActivity', + initial: 'on', + states: { + on: { + invoke: { + src: 'myActivity' + }, + on: { + TURN_OFF: 'off' + } + }, + off: {} + } + }, + { + actors: { + myActivity: fromCallback(() => spy) + } + } + ); + + const stopActivityService = createActor(stopActivityMachine).start(); + + expect(spy).not.toHaveBeenCalled(); + + stopActivityService.stop(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should restart activities from a compound state', () => { + let activityActive = false; + + const machine = createMachine( + { + initial: 'inactive', + states: { + inactive: { + on: { TOGGLE: 'active' } + }, + active: { + invoke: { src: 'blink' }, + on: { TOGGLE: 'inactive' }, + initial: 'A', + states: { + A: { on: { SWITCH: 'B' } }, + B: { on: { SWITCH: 'A' } } + } + } + } + }, + { + actors: { + blink: fromCallback(() => { + activityActive = true; + return () => { + activityActive = false; + }; + }) + } + } + ); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'TOGGLE' }); + actorRef.send({ type: 'SWITCH' }); + const bState = actorRef.getPersistedSnapshot(); + actorRef.stop(); + activityActive = false; + + createActor(machine, { snapshot: bState }).start(); + + expect(activityActive).toBeTruthy(); + }); + }); + + it('can cancel a delayed event', () => { + const service = createActor(lightMachine, { + clock: new SimulatedClock() + }); + const clock = service.clock as SimulatedClock; + service.start(); + + clock.increment(5); + service.send({ type: 'KEEP_GOING' }); + + expect(service.getSnapshot().value).toEqual('green'); + clock.increment(10); + expect(service.getSnapshot().value).toEqual('green'); + }); + + it('can cancel a delayed event using expression to resolve send id', (done) => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + entry2: (_, enq) => { + enq.raise({ type: 'FOO' }, { id: 'foo', delay: 100 }); + enq.raise({ type: 'BAR' }, { delay: 200 }); + enq.cancel('foo'); + }, + on: { + FOO: 'fail', + BAR: 'pass' + } + }, + fail: { + type: 'final' + }, + pass: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + service.subscribe({ + complete: () => { + expect(service.getSnapshot().value).toBe('pass'); + done(); + } + }); + }); + + it('should not throw an error if an event is sent to an uninitialized interpreter', () => { + const actorRef = createActor(lightMachine); + + expect(() => actorRef.send({ type: 'SOME_EVENT' })).not.toThrow(); + }); + + it('should defer events sent to an uninitialized service', (done) => { + const deferMachine = createMachine({ + id: 'defer', + initial: 'a', + states: { + a: { + on: { NEXT_A: 'b' } + }, + b: { + on: { NEXT_B: 'c' } + }, + c: { + type: 'final' + } + } + }); + + let state: any; + const deferService = createActor(deferMachine); + + deferService.subscribe({ + next: (nextState) => { + state = nextState; + }, + complete: done + }); + + // uninitialized + deferService.send({ type: 'NEXT_A' }); + deferService.send({ type: 'NEXT_B' }); + + expect(state).not.toBeDefined(); + + // initialized + deferService.start(); + }); + + it('should throw an error if initial state sent to interpreter is invalid', () => { + const invalidMachine = { + id: 'fetchMachine', + initial: 'create', + states: { + edit: { + initial: 'idle', + states: { + idle: { + on: { + FETCH: 'pending' + } + }, + pending: {} + } + } + } + }; + + const snapshot = createActor(createMachine(invalidMachine)).getSnapshot(); + + expect(snapshot.status).toBe('error'); + expect(snapshot.error).toMatchInlineSnapshot( + `[Error: Initial state node "create" not found on parent state node #fetchMachine]` + ); + }); + + it('should not update when stopped', () => { + const service = createActor(lightMachine, { + clock: new SimulatedClock() + }); + + service.start(); + service.send({ type: 'TIMER' }); // yellow + expect(service.getSnapshot().value).toEqual('yellow'); + + service.stop(); + try { + service.send({ type: 'TIMER' }); // red if interpreter is not stopped + } catch (e) { + expect(service.getSnapshot().value).toEqual('yellow'); + } + + expect(console.warn).toMatchMockCallsInlineSnapshot(` +[ + [ + "Event "TIMER" was sent to stopped actor "x:0 (x:0)". This actor has already reached its final state, and will not transition. +Event: {"type":"TIMER"}", + ], +] +`); + }); + + it('should be able to log (log action)', () => { + const logs: any[] = []; + + const logMachine = createMachine({ + types: {} as { context: { count: number } }, + id: 'log', + initial: 'x', + context: { count: 0 }, + states: { + x: { + on: { + LOG: ({ context }, enq) => { + const nextContext = { + count: context.count + 1 + }; + enq.log(nextContext); + return { + context: nextContext + }; + } + } + } + } + }); + + const service = createActor(logMachine, { + logger: (msg) => logs.push(msg) + }).start(); + + service.send({ type: 'LOG' }); + service.send({ type: 'LOG' }); + + expect(logs.length).toBe(2); + expect(logs).toEqual([{ count: 1 }, { count: 2 }]); + }); + + it('should receive correct event (log action)', () => { + const logs: any[] = []; + const logAction = log(({ event }) => event.type); + + const parentMachine = createMachine({ + initial: 'foo', + states: { + foo: { + on: { + EXTERNAL_EVENT: ({ event }, enq) => { + enq.raise({ type: 'RAISED_EVENT' }); + enq.log(event.type); + } + } + } + }, + on: { + '*': ({ event }, enq) => { + // actions: [logAction] + enq.log(event.type); + } + } + }); + + const service = createActor(parentMachine, { + logger: (msg) => logs.push(msg) + }).start(); + + service.send({ type: 'EXTERNAL_EVENT' }); + + expect(logs.length).toBe(2); + expect(logs).toEqual(['EXTERNAL_EVENT', 'RAISED_EVENT']); + }); + + describe('send() event expressions', () => { + interface Ctx { + password: string; + } + interface Events { + type: 'NEXT'; + password: string; + } + const machine = createMachine({ + types: {} as { context: Ctx; events: Events }, + id: 'sendexpr', + initial: 'start', + context: { + password: 'foo' + }, + states: { + start: { + entry2: ({ context }, enq) => { + enq.raise({ type: 'NEXT', password: context.password }); + }, + on: { + NEXT: ({ event }) => { + if (event.password === 'foo') { + return { target: 'finish' }; + } + } + } + }, + finish: { + type: 'final' + } + } + }); + + it('should resolve send event expressions', (done) => { + const actor = createActor(machine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + }); + + describe('sendParent() event expressions', () => { + it('should resolve sendParent event expressions', (done) => { + const childMachine = createMachine({ + types: {} as { + context: { password: string }; + input: { password: string }; + }, + id: 'child', + initial: 'start', + context: ({ input }) => ({ + password: input.password + }), + states: { + start: { + // entry: sendParent(({ context }) => { + // return { type: 'NEXT', password: context.password }; + // }), + entry2: ({ context, parent }) => { + parent?.send({ type: 'NEXT', password: context.password }); + } + } + } + }); + + const parentMachine = createMachine({ + types: {} as { + events: { + type: 'NEXT'; + password: string; + }; + }, + id: 'parent', + initial: 'start', + states: { + start: { + invoke: { + id: 'child', + src: childMachine, + input: { password: 'foo' } + }, + on: { + NEXT: ({ event }) => { + if (event.password === 'foo') { + return { target: 'finish' }; + } + } + } + }, + finish: { + type: 'final' + } + } + }); + + const actor = createActor(parentMachine); + actor.subscribe({ + next: (state) => { + if (state.matches('start')) { + const childActor = state.children.child; + + expect(typeof childActor!.send).toBe('function'); + } + }, + complete: () => done() + }); + actor.start(); + }); + }); + + describe('.send()', () => { + const sendMachine = createMachine({ + id: 'send', + initial: 'inactive', + states: { + inactive: { + on: { + EVENT: ({ event }) => { + if (event.id === 42) { + return { target: 'active' }; + } + }, + ACTIVATE: 'active' + } + }, + active: { + type: 'final' + } + } + }); + + it('can send events with a string', (done) => { + const service = createActor(sendMachine); + service.subscribe({ complete: () => done() }); + service.start(); + + service.send({ type: 'ACTIVATE' }); + }); + + it('can send events with an object', (done) => { + const service = createActor(sendMachine); + service.subscribe({ complete: () => done() }); + service.start(); + + service.send({ type: 'ACTIVATE' }); + }); + + it('can send events with an object with payload', (done) => { + const service = createActor(sendMachine); + service.subscribe({ complete: () => done() }); + service.start(); + + service.send({ type: 'EVENT', id: 42 }); + }); + + it('should receive and process all events sent simultaneously', (done) => { + const toggleMachine = createMachine({ + id: 'toggle', + initial: 'inactive', + states: { + fail: {}, + inactive: { + on: { + INACTIVATE: 'fail', + ACTIVATE: 'active' + } + }, + active: { + on: { + INACTIVATE: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const toggleService = createActor(toggleMachine); + toggleService.subscribe({ + complete: () => { + done(); + } + }); + toggleService.start(); + + toggleService.send({ type: 'ACTIVATE' }); + toggleService.send({ type: 'INACTIVATE' }); + }); + }); + + describe('.start()', () => { + it('should initialize the service', () => { + const contextSpy = jest.fn(); + const entrySpy = jest.fn(); + + const machine = createMachine({ + context: contextSpy, + entry2: (_, enq) => void enq.action(entrySpy), + initial: 'foo', + states: { + foo: {} + } + }); + const actor = createActor(machine); + actor.start(); + + expect(contextSpy).toHaveBeenCalled(); + expect(entrySpy).toHaveBeenCalled(); + expect(actor.getSnapshot()).toBeDefined(); + expect(actor.getSnapshot().matches('foo')).toBeTruthy(); + }); + + it('should not reinitialize a started service', () => { + const contextSpy = jest.fn(); + const entrySpy = jest.fn(); + + const machine = createMachine({ + context: contextSpy, + entry2: (_, enq) => void enq.action(entrySpy) + }); + const actor = createActor(machine); + actor.start(); + actor.start(); + + expect(contextSpy).toHaveBeenCalledTimes(1); + expect(entrySpy).toHaveBeenCalledTimes(1); + }); + + it('should be able to be initialized at a custom state', () => { + const machine = createMachine({ + initial: 'foo', + states: { + foo: {}, + bar: {} + } + }); + const actor = createActor(machine, { + snapshot: machine.resolveState({ value: 'bar' }) + }); + + expect(actor.getSnapshot().matches('bar')).toBeTruthy(); + actor.start(); + expect(actor.getSnapshot().matches('bar')).toBeTruthy(); + }); + + it('should be able to be initialized at a custom state value', () => { + const machine = createMachine({ + initial: 'foo', + states: { + foo: {}, + bar: {} + } + }); + const actor = createActor(machine, { + snapshot: machine.resolveState({ value: 'bar' }) + }); + + expect(actor.getSnapshot().matches('bar')).toBeTruthy(); + actor.start(); + expect(actor.getSnapshot().matches('bar')).toBeTruthy(); + }); + + it('should be able to resolve a custom initialized state', () => { + const machine = createMachine({ + id: 'start', + initial: 'foo', + states: { + foo: { + initial: 'one', + states: { + one: {} + } + }, + bar: {} + } + }); + const actor = createActor(machine, { + snapshot: machine.resolveState({ value: 'foo' }) + }); + + expect(actor.getSnapshot().matches({ foo: 'one' })).toBeTruthy(); + actor.start(); + expect(actor.getSnapshot().matches({ foo: 'one' })).toBeTruthy(); + }); + }); + + describe('.stop()', () => { + it('should cancel delayed events', (done) => { + let called = false; + const delayedMachine = createMachine({ + id: 'delayed', + initial: 'foo', + states: { + foo: { + after: { + 50: (_, enq) => { + enq.action(() => (called = true)); + return { target: 'bar' }; + } + } + }, + bar: {} + } + }); + + const delayedService = createActor(delayedMachine).start(); + + delayedService.stop(); + + setTimeout(() => { + expect(called).toBe(false); + done(); + }, 60); + }); + + it('should not execute transitions after being stopped', (done) => { + let called = false; + + const testMachine = createMachine({ + initial: 'waiting', + states: { + waiting: { + on: { + TRIGGER: 'active' + } + }, + active: { + entry2: (_, enq) => { + enq.action(() => (called = true)); + } + } + } + }); + + const service = createActor(testMachine).start(); + + service.stop(); + + service.send({ type: 'TRIGGER' }); + + setTimeout(() => { + expect(called).toBeFalsy(); + expect(console.warn).toMatchMockCallsInlineSnapshot(` +[ + [ + "Event "TRIGGER" was sent to stopped actor "x:0 (x:0)". This actor has already reached its final state, and will not transition. +Event: {"type":"TRIGGER"}", + ], +] +`); + done(); + }, 10); + }); + + it('stopping a not-started interpreter should not crash', () => { + const service = createActor( + createMachine({ + initial: 'a', + states: { a: {} } + }) + ); + + expect(() => { + service.stop(); + }).not.toThrow(); + }); + }); + + describe('.unsubscribe()', () => { + it('should remove transition listeners', () => { + const toggleMachine = createMachine({ + id: 'toggle', + initial: 'inactive', + states: { + inactive: { + on: { TOGGLE: 'active' } + }, + active: { + on: { TOGGLE: 'inactive' } + } + } + }); + + const toggleService = createActor(toggleMachine).start(); + + let stateCount = 0; + + const listener = () => stateCount++; + + const sub = toggleService.subscribe(listener); + + expect(stateCount).toEqual(0); + + toggleService.send({ type: 'TOGGLE' }); + + expect(stateCount).toEqual(1); + + toggleService.send({ type: 'TOGGLE' }); + + expect(stateCount).toEqual(2); + + sub.unsubscribe(); + toggleService.send({ type: 'TOGGLE' }); + + expect(stateCount).toEqual(2); + }); + }); + + describe('transient states', () => { + it('should transition in correct order', () => { + const stateMachine = createMachine({ + id: 'transient', + initial: 'idle', + states: { + idle: { on: { START: 'transient' } }, + transient: { always: 'next' }, + next: { on: { FINISH: 'end' } }, + end: { type: 'final' } + } + }); + + const stateValues: StateValue[] = []; + const service = createActor(stateMachine); + service.subscribe((current) => stateValues.push(current.value)); + service.start(); + service.send({ type: 'START' }); + + const expectedStateValues = ['idle', 'next']; + expect(stateValues.length).toEqual(expectedStateValues.length); + for (let i = 0; i < expectedStateValues.length; i++) { + expect(stateValues[i]).toEqual(expectedStateValues[i]); + } + }); + + it('should transition in correct order when there is a condition', () => { + const alwaysFalse = () => false; + const stateMachine = createMachine({ + id: 'transient', + initial: 'idle', + states: { + idle: { on: { START: 'transient' } }, + transient: { + always: (_) => { + if (alwaysFalse()) { + return { target: 'end' }; + } + return { target: 'next' }; + } + }, + next: { on: { FINISH: 'end' } }, + end: { type: 'final' } + } + }); + + const stateValues: StateValue[] = []; + const service = createActor(stateMachine); + service.subscribe((current) => stateValues.push(current.value)); + service.start(); + service.send({ type: 'START' }); + + const expectedStateValues = ['idle', 'next']; + expect(stateValues.length).toEqual(expectedStateValues.length); + for (let i = 0; i < expectedStateValues.length; i++) { + expect(stateValues[i]).toEqual(expectedStateValues[i]); + } + }); + }); + + describe('observable', () => { + const context = { count: 0 }; + const intervalMachine = createMachine({ + id: 'interval', + types: {} as { context: typeof context }, + context, + initial: 'active', + states: { + active: { + after: { + 10: ({ context }) => ({ + target: 'active', + reenter: true, + context: { + count: context.count + 1 + } + }) + }, + always: ({ context }) => { + if (context.count >= 5) { + return { target: 'finished' }; + } + } + }, + finished: { + type: 'final' + } + } + }); + + it('should be subscribable', (done) => { + let count: number; + const intervalActor = createActor(intervalMachine).start(); + + expect(typeof intervalActor.subscribe === 'function').toBeTruthy(); + + intervalActor.subscribe( + (state) => { + count = state.context.count; + }, + undefined, + () => { + expect(count).toEqual(5); + done(); + } + ); + }); + + it('should be interoperable with RxJS, etc. via Symbol.observable', (done) => { + let count = 0; + const intervalActor = createActor(intervalMachine).start(); + + const state$ = from(intervalActor); + + state$.subscribe({ + next: () => { + count += 1; + }, + error: undefined, + complete: () => { + expect(count).toEqual(5); + done(); + } + }); + }); + + it('should be unsubscribable', (done) => { + const countContext = { count: 0 }; + const machine = createMachine({ + types: {} as { context: typeof countContext }, + context: countContext, + initial: 'active', + states: { + active: { + always: ({ context }) => { + if (context.count >= 5) { + return { target: 'finished' }; + } + }, + on: { + INC: ({ context }) => ({ + context: { + count: context.count + 1 + } + }) + } + }, + finished: { + type: 'final' + } + } + }); + + let count: number; + const service = createActor(machine); + service.subscribe({ + complete: () => { + expect(count).toEqual(2); + done(); + } + }); + service.start(); + + const subscription = service.subscribe( + (state) => (count = state.context.count) + ); + + service.send({ type: 'INC' }); + service.send({ type: 'INC' }); + subscription.unsubscribe(); + service.send({ type: 'INC' }); + service.send({ type: 'INC' }); + service.send({ type: 'INC' }); + }); + + it('should call complete() once a final state is reached', () => { + const completeCb = jest.fn(); + + const service = createActor( + createMachine({ + initial: 'idle', + states: { + idle: { + on: { + NEXT: 'done' + } + }, + done: { type: 'final' } + } + }) + ).start(); + + service.subscribe({ + complete: completeCb + }); + + service.send({ type: 'NEXT' }); + + expect(completeCb).toHaveBeenCalledTimes(1); + }); + + it('should call complete() once the interpreter is stopped', () => { + const completeCb = jest.fn(); + + const service = createActor(createMachine({})).start(); + + service.subscribe({ + complete: () => { + completeCb(); + } + }); + + service.stop(); + + expect(completeCb).toHaveBeenCalledTimes(1); + }); + }); + + describe('actors', () => { + it("doesn't crash cryptically on undefined return from the actor creator", () => { + const child = fromCallback(() => { + // nothing + }); + const machine = createMachine( + { + types: {} as { + actors: { + src: 'testService'; + logic: typeof child; + }; + }, + initial: 'initial', + states: { + initial: { + invoke: { + src: 'testService' + } + } + } + }, + { + actors: { + testService: child + } + } + ); + + const service = createActor(machine); + expect(() => service.start()).not.toThrow(); + }); + }); + + describe('children', () => { + it('state.children should reference invoked child actors (machine)', () => { + const childMachine = createMachine({ + initial: 'active', + states: { + active: { + on: { + FIRE: ({ parent }, enq) => { + enq.action(() => parent?.send({ type: 'FIRED' })); + } + } + } + } + }); + const parentMachine = createMachine({ + initial: 'active', + states: { + active: { + invoke: { + id: 'childActor', + src: childMachine + }, + on: { + FIRED: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actor = createActor(parentMachine); + actor.start(); + actor.getSnapshot().children.childActor.send({ type: 'FIRE' }); + + // the actor should be done by now + expect(actor.getSnapshot().children).not.toHaveProperty('childActor'); + }); + + it('state.children should reference invoked child actors (promise)', (done) => { + const parentMachine = createMachine( + { + initial: 'active', + types: {} as { + actors: { + src: 'num'; + logic: PromiseActorLogic; + }; + }, + states: { + active: { + invoke: { + id: 'childActor', + src: 'num', + onDone: ({ event }) => { + if (event.output === 42) { + return { target: 'success' }; + } + return { target: 'failure' }; + } + } + }, + success: { + type: 'final' + }, + failure: { + type: 'final' + } + } + }, + { + actors: { + num: fromPromise( + () => + new Promise((res) => { + setTimeout(() => { + res(42); + }, 100); + }) + ) + } + } + ); + + const service = createActor(parentMachine); + + service.subscribe({ + next: (state) => { + if (state.matches('active')) { + const childActor = state.children.childActor; + + expect(childActor).toHaveProperty('send'); + } + }, + complete: () => { + expect(service.getSnapshot().matches('success')).toBeTruthy(); + expect(service.getSnapshot().children).not.toHaveProperty( + 'childActor' + ); + done(); + } + }); + + service.start(); + }); + + it('state.children should reference invoked child actors (observable)', (done) => { + const interval$ = interval(10); + const intervalLogic = fromObservable(() => interval$); + + const parentMachine = createMachine( + { + types: {} as { + actors: { + src: 'intervalLogic'; + logic: typeof intervalLogic; + }; + }, + initial: 'active', + states: { + active: { + invoke: { + id: 'childActor', + src: 'intervalLogic', + onSnapshot: ({ event }) => { + if (event.snapshot.context === 3) { + return { target: 'success' }; + } + } + } + }, + success: { + type: 'final' + } + } + }, + { + actors: { + intervalLogic + } + } + ); + + const service = createActor(parentMachine); + service.subscribe({ + complete: () => { + expect(service.getSnapshot().children).not.toHaveProperty( + 'childActor' + ); + done(); + } + }); + + service.subscribe((state) => { + if (state.matches('active')) { + expect(state.children['childActor']).not.toBeUndefined(); + } + }); + + service.start(); + }); + + it('state.children should reference spawned actors', () => { + const childMachine = createMachine({ + initial: 'idle', + states: { + idle: {} + } + }); + const formMachine = createMachine({ + id: 'form', + initial: 'idle', + context: {}, + entry: assign({ + firstNameRef: ({ spawn }) => spawn(childMachine, { id: 'child' }) + }), + entry2: ({ context }, enq) => ({ + context: { + ...context, + firstNameRef: enq.spawn(childMachine, { id: 'child' }) + } + }), + states: { + idle: {} + } + }); + + const actor = createActor(formMachine); + actor.start(); + expect(actor.getSnapshot().children).toHaveProperty('child'); + }); + + // TODO: Need to actually delete children + it.skip('stopped spawned actors should be cleaned up in parent', () => { + const childMachine = createMachine({ + initial: 'idle', + states: { + idle: {} + } + }); + + const parentMachine = createMachine({ + id: 'form', + initial: 'present', + context: {} as { + machineRef: ActorRefFrom; + promiseRef: ActorRefFrom; + observableRef: AnyActorRef; + }, + + entry2: ({ context }, enq) => ({ + context: { + ...context, + machineRef: enq.spawn(childMachine, { id: 'machineChild' }), + promiseRef: enq.spawn( + fromPromise( + () => + new Promise(() => { + // ... + }) + ), + { id: 'promiseChild' } + ), + observableRef: enq.spawn( + fromObservable(() => interval(1000)), + { id: 'observableChild' } + ) + } + }), + states: { + present: { + on: { + NEXT: ({ context }, enq) => { + enq.cancel(context.machineRef.id); + enq.cancel(context.promiseRef.id); + enq.cancel(context.observableRef.id); + return { target: 'gone' }; + } + } + }, + gone: { + type: 'final' + } + } + }); + + const service = createActor(parentMachine).start(); + + expect(service.getSnapshot().children).toHaveProperty('machineChild'); + expect(service.getSnapshot().children).toHaveProperty('promiseChild'); + expect(service.getSnapshot().children).toHaveProperty('observableChild'); + + service.send({ type: 'NEXT' }); + + expect(service.getSnapshot().children.machineChild).toBeUndefined(); + expect(service.getSnapshot().children.promiseChild).toBeUndefined(); + expect(service.getSnapshot().children.observableChild).toBeUndefined(); + }); + }); + + it("shouldn't execute actions when reading a snapshot of not started actor", () => { + const spy = jest.fn(); + const actorRef = createActor( + createMachine({ + entry2: (_, enq) => { + enq.action(() => spy()); + } + }) + ); + + actorRef.getSnapshot(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it(`should execute entry actions when starting the actor after reading its snapshot first`, () => { + const spy = jest.fn(); + + const actorRef = createActor( + createMachine({ + entry2: (_, enq) => { + enq.action(() => spy()); + } + }) + ); + + actorRef.getSnapshot(); + expect(spy).not.toHaveBeenCalled(); + + actorRef.start(); + + expect(spy).toHaveBeenCalled(); + }); + + it('the first state of an actor should be its initial state', () => { + const machine = createMachine({}); + const actor = createActor(machine); + const initialState = actor.getSnapshot(); + + actor.start(); + + expect(actor.getSnapshot()).toBe(initialState); + }); + + it('should call an onDone callback immediately if the service is already done', (done) => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + expect(service.getSnapshot().status).toBe('done'); + + service.subscribe({ + complete: () => { + done(); + } + }); + }); +}); + +it('should throw if an event is received', () => { + const machine = createMachine({}); + + const actor = createActor(machine).start(); + + expect(() => + actor.send( + // @ts-ignore + 'EVENT' + ) + ).toThrow(); +}); + +it('should not process events sent directly to own actor ref before initial entry actions are processed', () => { + const actual: string[] = []; + const machine = createMachine({ + entry: () => { + actual.push('initial root entry start'); + actorRef.send({ + type: 'EV' + }); + actual.push('initial root entry end'); + }, + on: { + EV: (_, enq) => { + enq.action(() => actual.push('EV transition')); + } + }, + initial: 'a', + states: { + a: { + entry2: (_, enq) => { + enq.action(() => actual.push('initial nested entry')); + } + } + } + }); + + const actorRef = createActor(machine); + actorRef.start(); + + expect(actual).toEqual([ + 'initial root entry start', + 'initial root entry end', + 'initial nested entry', + 'EV transition' + ]); +}); + +it('should not notify the completion observer for an active logic when it gets subscribed before starting', () => { + const spy = jest.fn(); + + const machine = createMachine({}); + createActor(machine).subscribe({ complete: spy }); + + expect(spy).not.toHaveBeenCalled(); +}); + +it('should not notify the completion observer for an errored logic when it gets subscribed after it errors', () => { + const spy = jest.fn(); + + const machine = createMachine({ + entry2: (_, enq) => { + enq.action(() => { + throw new Error('error'); + }); + } + }); + const actorRef = createActor(machine); + actorRef.subscribe({ error: () => {} }); + actorRef.start(); + + actorRef.subscribe({ + complete: spy + }); + + expect(spy).not.toHaveBeenCalled(); +}); + +it('should notify the error observer for an errored logic when it gets subscribed after it errors', () => { + const spy = jest.fn(); + + const machine = createMachine({ + entry2: (_, enq) => { + enq.action(() => { + throw new Error('error'); + }); + } + }); + const actorRef = createActor(machine); + actorRef.subscribe({ error: () => {} }); + actorRef.start(); + + actorRef.subscribe({ + error: spy + }); + + expect(spy).toMatchMockCallsInlineSnapshot(` + [ + [ + [Error: error], + ], + ] + `); +}); diff --git a/packages/core/test/invalid.v6.test.ts b/packages/core/test/invalid.v6.test.ts new file mode 100644 index 0000000000..4d9929830f --- /dev/null +++ b/packages/core/test/invalid.v6.test.ts @@ -0,0 +1,166 @@ +import { createMachine, transition } from '../src/index.ts'; + +describe('invalid or resolved states', () => { + it('should resolve a String state', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A1', + states: { + A1: {}, + A2: {} + } + }, + B: { + initial: 'B1', + states: { + B1: {}, + B2: {} + } + } + } + }); + expect( + transition(machine, machine.resolveState({ value: 'A' }), { + type: 'E' + })[0].value + ).toEqual({ + A: 'A1', + B: 'B1' + }); + }); + + it('should resolve transitions from empty states', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A1', + states: { + A1: {}, + A2: {} + } + }, + B: { + initial: 'B1', + states: { + B1: {}, + B2: {} + } + } + } + }); + expect( + transition(machine, machine.resolveState({ value: { A: {}, B: {} } }), { + type: 'E' + })[0].value + ).toEqual({ + A: 'A1', + B: 'B1' + }); + }); + + it('should allow transitioning from valid states', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A1', + states: { + A1: {}, + A2: {} + } + }, + B: { + initial: 'B1', + states: { + B1: {}, + B2: {} + } + } + } + }); + transition(machine, machine.resolveState({ value: { A: 'A1', B: 'B1' } }), { + type: 'E' + }); + }); + + it('should reject transitioning from bad state configs', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A1', + states: { + A1: {}, + A2: {} + } + }, + B: { + initial: 'B1', + states: { + B1: {}, + B2: {} + } + } + } + }); + expect(() => + transition( + machine, + machine.resolveState({ value: { A: 'A3', B: 'B3' } }), + { type: 'E' } + ) + ).toThrow(); + }); + + it('should resolve transitioning from partially valid states', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A1', + states: { + A1: {}, + A2: {} + } + }, + B: { + initial: 'B1', + states: { + B1: {}, + B2: {} + } + } + } + }); + expect( + transition(machine, machine.resolveState({ value: { A: 'A1', B: {} } }), { + type: 'E' + })[0].value + ).toEqual({ + A: 'A1', + B: 'B1' + }); + }); +}); + +describe('invalid transition', () => { + it('should throw when attempting to create a machine with a sibling target on the root node', () => { + expect(() => { + createMachine({ + id: 'direction', + initial: 'left', + states: { + left: {}, + right: {} + }, + on: { + LEFT_CLICK: 'left', + RIGHT_CLICK: 'right' + } + }); + }).toThrow(/invalid target/i); + }); +}); diff --git a/packages/core/test/invoke.v6.test.ts b/packages/core/test/invoke.v6.test.ts new file mode 100644 index 0000000000..6230591a17 --- /dev/null +++ b/packages/core/test/invoke.v6.test.ts @@ -0,0 +1,3498 @@ +import { interval, of } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { + PromiseActorLogic, + fromCallback, + fromEventObservable, + fromObservable, + fromPromise, + fromTransition +} from '../src/actors/index.ts'; +import { + ActorLogic, + ActorScope, + EventObject, + StateValue, + createMachine, + createActor, + Snapshot, + ActorRef, + AnyEventObject +} from '../src/index.ts'; +import { sleep } from '@xstate-repo/jest-utils'; + +const user = { name: 'David' }; + +describe('invoke', () => { + it('child can immediately respond to the parent with multiple events', () => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'FORWARD_DEC' }; + }, + id: 'child', + initial: 'init', + states: { + init: { + on: { + FORWARD_DEC: ({ parent }) => { + parent?.send({ type: 'DEC' }); + parent?.send({ type: 'DEC' }); + parent?.send({ type: 'DEC' }); + } + } + } + } + }); + + const someParentMachine = createMachine( + { + id: 'parent', + types: {} as { + context: { count: number }; + actors: { + src: 'child'; + id: 'someService'; + logic: typeof childMachine; + }; + }, + context: { count: 0 }, + initial: 'start', + states: { + start: { + invoke: { + src: 'child', + id: 'someService' + }, + always: ({ context }) => { + if (context.count === -3) { + return { target: 'stop' }; + } + }, + on: { + DEC: ({ context }) => ({ + context: { + ...context, + count: context.count - 1 + } + }), + FORWARD_DEC: ({ children }) => { + children.someService.send({ type: 'FORWARD_DEC' }); + } + } + }, + stop: { + type: 'final' + } + } + }, + { + actors: { + child: childMachine + } + } + ); + + const actorRef = createActor(someParentMachine).start(); + actorRef.send({ type: 'FORWARD_DEC' }); + + // 1. The 'parent' machine will not do anything (inert transition) + // 2. The 'FORWARD_DEC' event will be "forwarded" to the child machine + // 3. On the child machine, the 'FORWARD_DEC' event sends the 'DEC' action to the parent thrice + // 4. The context of the 'parent' machine will be updated from 0 to -3 + expect(actorRef.getSnapshot().context).toEqual({ count: -3 }); + }); + + it('should start services (explicit machine, invoke = config)', (done) => { + const childMachine = createMachine({ + id: 'fetch', + types: {} as { + context: { userId: string | undefined; user?: typeof user | undefined }; + events: { + type: 'RESOLVE'; + user: typeof user; + }; + input: { userId: string }; + }, + context: ({ input }) => ({ + userId: input.userId + }), + initial: 'pending', + states: { + pending: { + entry2: (_, enq) => { + enq.raise({ type: 'RESOLVE', user }); + }, + on: { + RESOLVE: ({ context }) => { + if (context.userId !== undefined) { + return { target: 'success' }; + } + } + } + }, + success: { + type: 'final', + entry2: ({ context, event }) => ({ + context: { + ...context, + user: event.user + } + }) + }, + failure: { + entry2: ({ parent }) => { + parent?.send({ type: 'REJECT' }); + } + } + }, + output: ({ context }) => ({ user: context.user }) + }); + + const machine = createMachine({ + types: {} as { + context: { + selectedUserId: string; + user?: typeof user; + }; + }, + id: 'fetcher', + initial: 'idle', + context: { + selectedUserId: '42', + user: undefined + }, + states: { + idle: { + on: { + GO_TO_WAITING: 'waiting' + } + }, + waiting: { + invoke: { + src: childMachine, + input: ({ context }: any) => ({ + userId: context.selectedUserId + }), + onDone: ({ event }) => { + // Should receive { user: { name: 'David' } } as event data + if ((event.output as any).user.name === 'David') { + return { target: 'received' }; + } + } + } + }, + received: { + type: 'final' + } + } + }); + + const actor = createActor(machine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + actor.send({ type: 'GO_TO_WAITING' }); + }); + + it('should start services (explicit machine, invoke = machine)', (done) => { + const childMachine = createMachine({ + types: {} as { + events: { type: 'RESOLVE' }; + input: { userId: string }; + }, + initial: 'pending', + states: { + pending: { + entry2: (_, enq) => { + enq.raise({ type: 'RESOLVE' }); + }, + on: { + RESOLVE: { + target: 'success' + } + } + }, + success: { + type: 'final' + } + } + }); + + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + GO_TO_WAITING: 'waiting' + } + }, + waiting: { + invoke: { + src: childMachine, + onDone: 'received' + } + }, + received: { + type: 'final' + } + } + }); + const actor = createActor(machine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + actor.send({ type: 'GO_TO_WAITING' }); + }); + + it('should start services (machine as invoke config)', (done) => { + const machineInvokeMachine = createMachine({ + types: {} as { + events: { + type: 'SUCCESS'; + data: number; + }; + }, + id: 'machine-invoke', + initial: 'pending', + states: { + pending: { + invoke: { + src: createMachine({ + id: 'child', + initial: 'sending', + states: { + sending: { + entry2: ({ parent }) => { + parent?.send({ type: 'SUCCESS', data: 42 }); + } + } + } + }) + }, + on: { + SUCCESS: ({ event }) => { + if (event.data === 42) { + return { target: 'success' }; + } + } + } + }, + success: { + type: 'final' + } + } + }); + const actor = createActor(machineInvokeMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + + it('should start deeply nested service (machine as invoke config)', (done) => { + const machineInvokeMachine = createMachine({ + types: {} as { + events: { + type: 'SUCCESS'; + data: number; + }; + }, + id: 'parent', + initial: 'a', + states: { + a: { + initial: 'b', + states: { + b: { + invoke: { + src: createMachine({ + id: 'child', + initial: 'sending', + states: { + sending: { + entry2: ({ parent }) => { + parent?.send({ type: 'SUCCESS', data: 42 }); + } + } + } + }) + } + } + } + }, + success: { + id: 'success', + type: 'final' + } + }, + on: { + SUCCESS: ({ event }) => { + if (event.data === 42) { + return { target: '.success' }; + } + } + } + }); + const actor = createActor(machineInvokeMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + + it('should use the service overwritten by .provide(...)', (done) => { + const childMachine = createMachine({ + id: 'child', + initial: 'init', + states: { + init: {} + } + }); + + const someParentMachine = createMachine( + { + id: 'parent', + types: {} as { + context: { count: number }; + actors: { + src: 'child'; + id: 'someService'; + logic: typeof childMachine; + }; + }, + context: { count: 0 }, + initial: 'start', + states: { + start: { + invoke: { + src: 'child', + id: 'someService' + }, + on: { + STOP: 'stop' + } + }, + stop: { + type: 'final' + } + } + }, + { + actors: { + child: childMachine + } + } + ); + + const actor = createActor( + someParentMachine.provide({ + actors: { + child: createMachine({ + id: 'child', + initial: 'init', + states: { + init: { + entry2: ({ parent }) => { + parent?.send({ type: 'STOP' }); + } + } + } + }) + } + }) + ); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + describe('parent to child', () => { + const subMachine = createMachine({ + id: 'child', + initial: 'one', + states: { + one: { + on: { NEXT: 'two' } + }, + two: { + entry2: ({ parent }) => { + parent?.send({ type: 'NEXT' }); + } + } + } + }); + + it.skip('should communicate with the child machine (invoke on machine)', (done) => { + const mainMachine = createMachine({ + id: 'parent', + initial: 'one', + invoke: { + id: 'foo-child', + src: subMachine + }, + states: { + one: { + entry2: ({ children }) => { + // TODO: foo-child is invoked after entry2 is executed so it does not exist yet + children.fooChild?.send({ type: 'NEXT' }); + }, + on: { NEXT: 'two' } + }, + two: { + type: 'final' + } + } + }); + + const actor = createActor(mainMachine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + it('should communicate with the child machine (invoke on state)', (done) => { + const mainMachine = createMachine({ + id: 'parent', + initial: 'one', + states: { + one: { + invoke: { + id: 'foo-child', + src: subMachine + }, + entry2: ({ children }) => { + children['foo-child']?.send({ type: 'NEXT' }); + }, + on: { NEXT: 'two' } + }, + two: { + type: 'final' + } + } + }); + + const actor = createActor(mainMachine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + it('should transition correctly if child invocation causes it to directly go to final state', () => { + const doneSubMachine = createMachine({ + id: 'child', + initial: 'one', + states: { + one: { + on: { NEXT: 'two' } + }, + two: { + type: 'final' + } + } + }); + + const mainMachine = createMachine({ + id: 'parent', + initial: 'one', + states: { + one: { + invoke: { + id: 'foo-child', + src: doneSubMachine, + onDone: 'two' + }, + entry2: ({ children }) => { + children['foo-child']?.send({ type: 'NEXT' }); + } + }, + two: { + on: { NEXT: 'three' } + }, + three: { + type: 'final' + } + } + }); + + const actor = createActor(mainMachine).start(); + + expect(actor.getSnapshot().value).toBe('two'); + }); + + it('should work with invocations defined in orthogonal state nodes', (done) => { + const pongMachine = createMachine({ + id: 'pong', + initial: 'active', + states: { + active: { + type: 'final' + } + }, + output: { secret: 'pingpong' } + }); + + const pingMachine = createMachine({ + id: 'ping', + type: 'parallel', + states: { + one: { + initial: 'active', + states: { + active: { + invoke: { + id: 'pong', + src: pongMachine, + onDone: ({ event }) => { + if (event.output.secret === 'pingpong') { + return { target: 'success' }; + } + } + } + }, + success: { + type: 'final' + } + } + } + } + }); + + const actor = createActor(pingMachine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + it('should not reinvoke root-level invocations on root non-reentering transitions', () => { + // https://github.com/statelyai/xstate/issues/2147 + + let invokeCount = 0; + let invokeDisposeCount = 0; + let actionsCount = 0; + let entryActionsCount = 0; + + const machine = createMachine({ + invoke: { + src: fromCallback(() => { + invokeCount++; + + return () => { + invokeDisposeCount++; + }; + }) + }, + entry2: (_, enq) => { + enq.action(() => { + entryActionsCount++; + }); + }, + on: { + UPDATE: (_, enq) => { + enq.action(() => { + actionsCount++; + }); + } + } + }); + + const service = createActor(machine).start(); + expect(entryActionsCount).toEqual(1); + expect(invokeCount).toEqual(1); + expect(invokeDisposeCount).toEqual(0); + expect(actionsCount).toEqual(0); + + service.send({ type: 'UPDATE' }); + expect(entryActionsCount).toEqual(1); + expect(invokeCount).toEqual(1); + expect(invokeDisposeCount).toEqual(0); + expect(actionsCount).toEqual(1); + + service.send({ type: 'UPDATE' }); + expect(entryActionsCount).toEqual(1); + expect(invokeCount).toEqual(1); + expect(invokeDisposeCount).toEqual(0); + expect(actionsCount).toEqual(2); + }); + + it('should stop a child actor when reaching a final state', () => { + let actorStopped = false; + + const machine = createMachine({ + id: 'machine', + invoke: { + src: fromCallback(() => () => (actorStopped = true)) + }, + initial: 'running', + states: { + running: { + on: { + finished: 'complete' + } + }, + complete: { type: 'final' } + } + }); + + const service = createActor(machine).start(); + + service.send({ + type: 'finished' + }); + + expect(actorStopped).toBe(true); + }); + + it('child should not invoke an actor when it transitions to an invoking state when it gets stopped by its parent', (done) => { + let invokeCount = 0; + + const child = createMachine({ + id: 'child', + initial: 'idle', + states: { + idle: { + invoke: { + src: fromCallback(({ sendBack }) => { + invokeCount++; + + if (invokeCount > 1) { + // prevent a potential infinite loop + throw new Error('This should be impossible.'); + } + + // it's important for this test to send the event back when the parent is *not* currently processing an event + // this ensures that the parent can process the received event immediately and can stop the child immediately + setTimeout(() => sendBack({ type: 'STARTED' })); + }) + }, + on: { + STARTED: 'active' + } + }, + active: { + invoke: { + src: fromCallback(({ sendBack }) => { + sendBack({ type: 'STOPPED' }); + }) + }, + on: { + STOPPED: ({ parent, event }) => { + parent?.send(event); + return { target: 'idle' }; + } + } + } + } + }); + const parent = createMachine({ + id: 'parent', + initial: 'idle', + states: { + idle: { + on: { + START: 'active' + } + }, + active: { + invoke: { src: child }, + on: { + STOPPED: 'done' + } + }, + done: { + type: 'final' + } + } + }); + + const service = createActor(parent); + service.subscribe({ + complete: () => { + expect(invokeCount).toBe(1); + done(); + } + }); + service.start(); + + service.send({ type: 'START' }); + }); + }); + + type PromiseExecutor = ( + resolve: (value?: any) => void, + reject: (reason?: any) => void + ) => void; + + const promiseTypes = [ + { + type: 'Promise', + createPromise(executor: PromiseExecutor): Promise { + return new Promise(executor); + } + }, + { + type: 'PromiseLike', + createPromise(executor: PromiseExecutor): PromiseLike { + // Simulate a Promise/A+ thenable / polyfilled Promise. + function createThenable(promise: Promise): PromiseLike { + return { + then(onfulfilled, onrejected) { + return createThenable(promise.then(onfulfilled, onrejected)); + } + }; + } + return createThenable(new Promise(executor)); + } + } + ]; + + promiseTypes.forEach(({ type, createPromise }) => { + describe(`with promises (${type})`, () => { + const invokePromiseMachine = createMachine({ + types: {} as { context: { id: number; succeed: boolean } }, + id: 'invokePromise', + initial: 'pending', + context: ({ + input + }: { + input: { id?: number; succeed?: boolean }; + }) => ({ + id: 42, + succeed: true, + ...input + }), + states: { + pending: { + invoke: { + src: fromPromise(({ input }) => + createPromise((resolve) => { + if (input.succeed) { + resolve(input.id); + } else { + throw new Error(`failed on purpose for: ${input.id}`); + } + }) + ), + input: ({ context }: any) => context, + onDone: ({ context, event }) => { + if (event.output === context.id) { + return { target: 'success' }; + } + }, + onError: 'failure' + } + }, + success: { + type: 'final' + }, + failure: { + type: 'final' + } + } + }); + + it('should be invoked with a promise factory and resolve through onDone', (done) => { + const machine = createMachine({ + initial: 'pending', + states: { + pending: { + invoke: { + src: fromPromise(() => + createPromise((resolve) => { + resolve(); + }) + ), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + const service = createActor(machine); + service.subscribe({ + complete: () => { + done(); + } + }); + service.start(); + }); + + it('should be invoked with a promise factory and reject with ErrorExecution', (done) => { + const actor = createActor(invokePromiseMachine, { + input: { id: 31, succeed: false } + }); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + + it('should be invoked with a promise factory and surface any unhandled errors', (done) => { + const promiseMachine = createMachine({ + id: 'invokePromise', + initial: 'pending', + states: { + pending: { + invoke: { + src: fromPromise(() => + createPromise(() => { + throw new Error('test'); + }) + ), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const service = createActor(promiseMachine); + service.subscribe({ + error(err) { + expect((err as any).message).toEqual(expect.stringMatching(/test/)); + done(); + } + }); + + service.start(); + }); + + it('should be invoked with a promise factory and stop on unhandled onError target', (done) => { + const completeSpy = jest.fn(); + + const promiseMachine = createMachine({ + id: 'invokePromise', + initial: 'pending', + states: { + pending: { + invoke: { + src: fromPromise(() => + createPromise(() => { + throw new Error('test'); + }) + ), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const actor = createActor(promiseMachine); + + actor.subscribe({ + error: (err) => { + expect(err).toBeInstanceOf(Error); + expect((err as any).message).toBe('test'); + expect(completeSpy).not.toHaveBeenCalled(); + done(); + }, + complete: completeSpy + }); + actor.start(); + }); + + it('should be invoked with a promise factory and resolve through onDone for compound state nodes', (done) => { + const promiseMachine = createMachine({ + id: 'promise', + initial: 'parent', + states: { + parent: { + initial: 'pending', + states: { + pending: { + invoke: { + src: fromPromise(() => + createPromise((resolve) => resolve()) + ), + onDone: 'success' + } + }, + success: { + type: 'final' + } + }, + onDone: 'success' + }, + success: { + type: 'final' + } + } + }); + const actor = createActor(promiseMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + + it('should be invoked with a promise service and resolve through onDone for compound state nodes', (done) => { + const promiseMachine = createMachine( + { + id: 'promise', + initial: 'parent', + states: { + parent: { + initial: 'pending', + states: { + pending: { + invoke: { + src: 'somePromise', + onDone: 'success' + } + }, + success: { + type: 'final' + } + }, + onDone: 'success' + }, + success: { + type: 'final' + } + } + }, + { + actors: { + somePromise: fromPromise(() => + createPromise((resolve) => resolve()) + ) + } + } + ); + const actor = createActor(promiseMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + it('should assign the resolved data when invoked with a promise factory', (done) => { + const promiseMachine = createMachine({ + types: {} as { context: { count: number } }, + id: 'promise', + context: { count: 0 }, + initial: 'pending', + states: { + pending: { + invoke: { + src: fromPromise(() => + createPromise((resolve) => resolve({ count: 1 })) + ), + onDone: ({ context, event }) => ({ + context: { + ...context, + count: event.output.count + }, + target: 'success' + }) + } + }, + success: { + type: 'final' + } + } + }); + + const actor = createActor(promiseMachine); + actor.subscribe({ + complete: () => { + expect(actor.getSnapshot().context.count).toEqual(1); + done(); + } + }); + actor.start(); + }); + + it('should assign the resolved data when invoked with a promise service', (done) => { + const promiseMachine = createMachine( + { + types: {} as { context: { count: number } }, + id: 'promise', + context: { count: 0 }, + initial: 'pending', + states: { + pending: { + invoke: { + src: 'somePromise', + onDone: ({ context, event }) => ({ + context: { + ...context, + count: event.output.count + }, + target: 'success' + }) + } + }, + success: { + type: 'final' + } + } + }, + { + actors: { + somePromise: fromPromise(() => + createPromise((resolve) => resolve({ count: 1 })) + ) + } + } + ); + + const actor = createActor(promiseMachine); + actor.subscribe({ + complete: () => { + expect(actor.getSnapshot().context.count).toEqual(1); + done(); + } + }); + actor.start(); + }); + + it('should provide the resolved data when invoked with a promise factory', (done) => { + let count = 0; + + const promiseMachine = createMachine({ + id: 'promise', + context: { count: 0 }, + initial: 'pending', + states: { + pending: { + invoke: { + src: fromPromise(() => + createPromise((resolve) => resolve({ count: 1 })) + ), + onDone: ({ context, event }) => { + count = (event.output as any).count; + return { + context: { + ...context, + count: (event.output as any).count + }, + target: 'success' + }; + } + } + }, + success: { + type: 'final' + } + } + }); + + const actor = createActor(promiseMachine); + actor.subscribe({ + complete: () => { + expect(count).toEqual(1); + done(); + } + }); + actor.start(); + }); + + it('should provide the resolved data when invoked with a promise service', (done) => { + let count = 0; + + const promiseMachine = createMachine( + { + id: 'promise', + initial: 'pending', + states: { + pending: { + invoke: { + src: 'somePromise', + onDone: ({ event }, enq) => { + enq.action(() => { + count = event.output.count; + }); + return { + target: 'success' + }; + } + } + }, + success: { + type: 'final' + } + } + }, + { + actors: { + somePromise: fromPromise(() => + createPromise((resolve) => resolve({ count: 1 })) + ) + } + } + ); + + const actor = createActor(promiseMachine); + actor.subscribe({ + complete: () => { + expect(count).toEqual(1); + done(); + } + }); + actor.start(); + }); + + it('should be able to specify a Promise as a service', (done) => { + interface BeginEvent { + type: 'BEGIN'; + payload: boolean; + } + + const promiseActor = fromPromise( + ({ input }: { input: { foo: boolean; event: { payload: any } } }) => { + return createPromise((resolve, reject) => { + input.foo && input.event.payload ? resolve() : reject(); + }); + } + ); + + const promiseMachine = createMachine( + { + id: 'promise', + types: {} as { + context: { foo: boolean }; + events: BeginEvent; + actors: { + src: 'somePromise'; + logic: typeof promiseActor; + }; + }, + initial: 'pending', + context: { + foo: true + }, + states: { + pending: { + on: { + BEGIN: 'first' + } + }, + first: { + invoke: { + src: 'somePromise', + input: ({ context, event }) => ({ + foo: context.foo, + event: event + }), + onDone: 'last' + } + }, + last: { + type: 'final' + } + } + }, + { + actors: { + somePromise: promiseActor + } + } + ); + + const actor = createActor(promiseMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + actor.send({ + type: 'BEGIN', + payload: true + }); + }); + + it('should be able to reuse the same promise logic multiple times and create unique promise for each created actor', (done) => { + const machine = createMachine( + { + types: {} as { + context: { + result1: number | null; + result2: number | null; + }; + actors: { + src: 'getRandomNumber'; + logic: PromiseActorLogic<{ result: number }>; + }; + }, + context: { + result1: null, + result2: null + }, + initial: 'pending', + states: { + pending: { + type: 'parallel', + states: { + state1: { + initial: 'active', + states: { + active: { + invoke: { + src: 'getRandomNumber', + onDone: ({ context, event }) => { + // TODO: we get DoneInvokeEvent here, this gets fixed with https://github.com/microsoft/TypeScript/pull/48838 + return { + context: { + ...context, + result1: event.output.result + }, + target: 'success' + }; + } + } + }, + success: { + type: 'final' + } + } + }, + state2: { + initial: 'active', + states: { + active: { + invoke: { + src: 'getRandomNumber', + onDone: ({ context, event }) => ({ + context: { + ...context, + result2: event.output.result + }, + target: 'success' + }) + } + }, + success: { + type: 'final' + } + } + } + }, + onDone: 'done' + }, + done: { + type: 'final' + } + } + }, + { + actors: { + // it's important for this actor to be reused, this test shouldn't use a factory or anything like that + getRandomNumber: fromPromise(() => { + return createPromise((resolve) => + resolve({ result: Math.random() }) + ); + }) + } + } + ); + + const service = createActor(machine); + service.subscribe({ + complete: () => { + const snapshot = service.getSnapshot(); + expect(typeof snapshot.context.result1).toBe('number'); + expect(typeof snapshot.context.result2).toBe('number'); + expect(snapshot.context.result1).not.toBe(snapshot.context.result2); + done(); + } + }); + service.start(); + }); + + it('should not emit onSnapshot if stopped', (done) => { + const machine = createMachine({ + initial: 'active', + states: { + active: { + invoke: { + src: fromPromise(() => + createPromise((res) => { + setTimeout(() => res(42), 5); + }) + ), + onSnapshot: {} + }, + on: { + deactivate: 'inactive' + } + }, + inactive: { + on: { + '*': ({ event }) => { + if (event.snapshot) { + throw new Error(`Received unexpected event: ${event.type}`); + } + } + } + } + } + }); + + const actor = createActor(machine).start(); + actor.send({ type: 'deactivate' }); + + setTimeout(() => { + done(); + }, 10); + }); + }); + }); + + describe('with callbacks', () => { + it('should be able to specify a callback as a service', (done) => { + interface BeginEvent { + type: 'BEGIN'; + payload: boolean; + } + interface CallbackEvent { + type: 'CALLBACK'; + data: number; + } + + const someCallback = fromCallback( + ({ + sendBack, + input + }: { + sendBack: (event: BeginEvent | CallbackEvent) => void; + input: { foo: boolean; event: BeginEvent | CallbackEvent }; + }) => { + if (input.foo && input.event.type === 'BEGIN') { + sendBack({ + type: 'CALLBACK', + data: 40 + }); + sendBack({ + type: 'CALLBACK', + data: 41 + }); + sendBack({ + type: 'CALLBACK', + data: 42 + }); + } + } + ); + + const callbackMachine = createMachine( + { + id: 'callback', + types: {} as { + context: { foo: boolean }; + events: BeginEvent | CallbackEvent; + actors: { + src: 'someCallback'; + logic: typeof someCallback; + }; + }, + initial: 'pending', + context: { + foo: true + }, + states: { + pending: { + on: { + BEGIN: 'first' + } + }, + first: { + invoke: { + src: 'someCallback', + input: ({ context, event }) => ({ + foo: context.foo, + event: event + }) + }, + on: { + CALLBACK: ({ event }) => { + if (event.data === 42) { + return { target: 'last' }; + } + } + } + }, + last: { + type: 'final' + } + } + }, + { + actors: { + someCallback + } + } + ); + + const actor = createActor(callbackMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + actor.send({ + type: 'BEGIN', + payload: true + }); + }); + + it('should transition correctly if callback function sends an event', () => { + const callbackMachine = createMachine( + { + id: 'callback', + initial: 'pending', + context: { foo: true }, + states: { + pending: { + on: { BEGIN: 'first' } + }, + first: { + invoke: { + src: 'someCallback' + }, + on: { CALLBACK: 'intermediate' } + }, + intermediate: { + on: { NEXT: 'last' } + }, + last: { + type: 'final' + } + } + }, + { + actors: { + someCallback: fromCallback(({ sendBack }) => { + sendBack({ type: 'CALLBACK' }); + }) + } + } + ); + + const expectedStateValues = ['pending', 'first', 'intermediate']; + const stateValues: StateValue[] = []; + const actor = createActor(callbackMachine); + actor.subscribe((current) => stateValues.push(current.value)); + actor.start().send({ type: 'BEGIN' }); + for (let i = 0; i < expectedStateValues.length; i++) { + expect(stateValues[i]).toEqual(expectedStateValues[i]); + } + }); + + it('should transition correctly if callback function invoked from start and sends an event', () => { + const callbackMachine = createMachine( + { + id: 'callback', + initial: 'idle', + context: { foo: true }, + states: { + idle: { + invoke: { + src: 'someCallback' + }, + on: { CALLBACK: 'intermediate' } + }, + intermediate: { + on: { NEXT: 'last' } + }, + last: { + type: 'final' + } + } + }, + { + actors: { + someCallback: fromCallback(({ sendBack }) => { + sendBack({ type: 'CALLBACK' }); + }) + } + } + ); + + const expectedStateValues = ['idle', 'intermediate']; + const stateValues: StateValue[] = []; + const actor = createActor(callbackMachine); + actor.subscribe((current) => stateValues.push(current.value)); + actor.start().send({ type: 'BEGIN' }); + for (let i = 0; i < expectedStateValues.length; i++) { + expect(stateValues[i]).toEqual(expectedStateValues[i]); + } + }); + + // tslint:disable-next-line:max-line-length + it('should transition correctly if transient transition happens before current state invokes callback function and sends an event', () => { + const callbackMachine = createMachine( + { + id: 'callback', + initial: 'pending', + context: { foo: true }, + states: { + pending: { + on: { BEGIN: 'first' } + }, + first: { + always: 'second' + }, + second: { + invoke: { + src: 'someCallback' + }, + on: { CALLBACK: 'third' } + }, + third: { + on: { NEXT: 'last' } + }, + last: { + type: 'final' + } + } + }, + { + actors: { + someCallback: fromCallback(({ sendBack }) => { + sendBack({ type: 'CALLBACK' }); + }) + } + } + ); + + const expectedStateValues = ['pending', 'second', 'third']; + const stateValues: StateValue[] = []; + const actor = createActor(callbackMachine); + actor.subscribe((current) => { + stateValues.push(current.value); + }); + actor.start().send({ type: 'BEGIN' }); + + for (let i = 0; i < expectedStateValues.length; i++) { + expect(stateValues[i]).toEqual(expectedStateValues[i]); + } + }); + + it('should treat a callback source as an event stream', (done) => { + const intervalMachine = createMachine({ + types: {} as { context: { count: number } }, + id: 'interval', + initial: 'counting', + context: { + count: 0 + }, + states: { + counting: { + invoke: { + id: 'intervalService', + src: fromCallback(({ sendBack }) => { + const ivl = setInterval(() => { + sendBack({ type: 'INC' }); + }, 10); + + return () => clearInterval(ivl); + }) + }, + always: ({ context }) => { + if (context.count === 3) { + return { target: 'finished' }; + } + }, + on: { + INC: ({ context }) => ({ + context: { + count: context.count + 1 + } + }) + } + }, + finished: { + type: 'final' + } + } + }); + const actor = createActor(intervalMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + + it('should dispose of the callback (if disposal function provided)', () => { + const spy = jest.fn(); + const intervalMachine = createMachine({ + id: 'interval', + initial: 'counting', + states: { + counting: { + invoke: { + id: 'intervalService', + src: fromCallback(() => spy) + }, + on: { + NEXT: 'idle' + } + }, + idle: {} + } + }); + const actorRef = createActor(intervalMachine).start(); + + actorRef.send({ type: 'NEXT' }); + + expect(spy).toHaveBeenCalled(); + }); + + it('callback should be able to receive messages from parent', (done) => { + const pingPongMachine = createMachine({ + id: 'ping-pong', + initial: 'active', + states: { + active: { + invoke: { + id: 'child', + src: fromCallback(({ sendBack, receive }) => { + receive((e) => { + if (e.type === 'PING') { + sendBack({ type: 'PONG' }); + } + }); + }) + }, + entry2: ({ children }) => { + children['child']?.send({ type: 'PING' }); + }, + on: { + PONG: 'done' + } + }, + done: { + type: 'final' + } + } + }); + const actor = createActor(pingPongMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + + it('should call onError upon error (sync)', (done) => { + const errorMachine = createMachine({ + id: 'error', + initial: 'safe', + states: { + safe: { + invoke: { + src: fromCallback(() => { + throw new Error('test'); + }), + onError: ({ event }) => { + if ( + event.error instanceof Error && + event.error.message === 'test' + ) { + return { target: 'failed' }; + } + } + } + }, + failed: { + type: 'final' + } + } + }); + const actor = createActor(errorMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + + it('should transition correctly upon error (sync)', () => { + const errorMachine = createMachine({ + id: 'error', + initial: 'safe', + states: { + safe: { + invoke: { + src: fromCallback(() => { + throw new Error('test'); + }), + onError: 'failed' + } + }, + failed: { + on: { RETRY: 'safe' } + } + } + }); + + const expectedStateValue = 'failed'; + const service = createActor(errorMachine).start(); + expect(service.getSnapshot().value).toEqual(expectedStateValue); + }); + + it('should call onError only on the state which has invoked failed service', () => { + const errorMachine = createMachine({ + initial: 'start', + states: { + start: { + on: { + FETCH: 'fetch' + } + }, + fetch: { + type: 'parallel', + states: { + first: { + initial: 'waiting', + states: { + waiting: { + invoke: { + src: fromCallback(() => { + throw new Error('test'); + }), + onError: { + target: 'failed' + } + } + }, + failed: {} + } + }, + second: { + initial: 'waiting', + states: { + waiting: { + invoke: { + src: fromCallback(() => { + // empty + return () => {}; + }), + onError: { + target: 'failed' + } + } + }, + failed: {} + } + } + } + } + } + }); + + const actorRef = createActor(errorMachine).start(); + actorRef.send({ type: 'FETCH' }); + + expect(actorRef.getSnapshot().value).toEqual({ + fetch: { first: 'failed', second: 'waiting' } + }); + }); + + it('should be able to be stringified', () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + GO_TO_WAITING: 'waiting' + } + }, + waiting: { + invoke: { + src: fromCallback(() => {}) + } + } + } + }); + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'GO_TO_WAITING' }); + const waitingState = actorRef.getSnapshot(); + + expect(() => { + JSON.stringify(waitingState); + }).not.toThrow(); + }); + + it('should result in an error notification if callback actor throws when it starts and the error stays unhandled by the machine', () => { + const errorMachine = createMachine({ + initial: 'safe', + states: { + safe: { + invoke: { + src: fromCallback(() => { + throw new Error('test'); + }) + } + }, + failed: { + type: 'final' + } + } + }); + const spy = jest.fn(); + + const actorRef = createActor(errorMachine); + actorRef.subscribe({ + error: spy + }); + actorRef.start(); + expect(spy.mock.calls).toMatchInlineSnapshot(` + [ + [ + [Error: test], + ], + ] + `); + }); + + it('should work with input', (done) => { + const machine = createMachine({ + types: {} as { + context: { foo: string }; + }, + initial: 'start', + context: { foo: 'bar' }, + states: { + start: { + invoke: { + src: fromCallback(({ input }) => { + expect(input).toEqual({ foo: 'bar' }); + done(); + }), + input: ({ context }: any) => context + } + } + } + }); + + createActor(machine).start(); + }); + + it('sub invoke race condition ends on the completed state', () => { + const anotherChildMachine = createMachine({ + id: 'child', + initial: 'start', + states: { + start: { + on: { STOP: 'end' } + }, + end: { + type: 'final' + } + } + }); + + const anotherParentMachine = createMachine({ + id: 'parent', + initial: 'begin', + states: { + begin: { + invoke: { + src: anotherChildMachine, + id: 'invoked.child', + onDone: 'completed' + }, + on: { + STOPCHILD: ({ children }) => { + children['invoked.child'].send({ type: 'STOP' }); + } + } + }, + completed: { + type: 'final' + } + } + }); + + const actorRef = createActor(anotherParentMachine).start(); + actorRef.send({ type: 'STOPCHILD' }); + + expect(actorRef.getSnapshot().value).toEqual('completed'); + }); + }); + + describe('with observables', () => { + it('should work with an infinite observable', (done) => { + interface Events { + type: 'COUNT'; + value: number; + } + const obsMachine = createMachine({ + types: {} as { context: { count: number | undefined }; events: Events }, + id: 'infiniteObs', + initial: 'counting', + context: { count: undefined }, + states: { + counting: { + invoke: { + src: fromObservable(() => interval(10)), + onSnapshot: ({ event }) => ({ + context: { + count: event.snapshot.context + } + }) + }, + always: ({ context }) => { + if (context.count === 5) { + return { target: 'counted' }; + } + } + }, + counted: { + type: 'final' + } + } + }); + + const service = createActor(obsMachine); + service.subscribe({ + complete: () => { + done(); + } + }); + service.start(); + }); + + it('should work with a finite observable', (done) => { + interface Ctx { + count: number | undefined; + } + interface Events { + type: 'COUNT'; + value: number; + } + const obsMachine = createMachine({ + types: {} as { context: Ctx; events: Events }, + id: 'obs', + initial: 'counting', + context: { + count: undefined + }, + states: { + counting: { + invoke: { + src: fromObservable(() => interval(10).pipe(take(5))), + onSnapshot: ({ event }) => ({ + context: { + count: event.snapshot.context + } + }), + onDone: ({ context }) => { + if (context.count === 4) { + return { target: 'counted' }; + } + } + } + }, + counted: { + type: 'final' + } + } + }); + + const actor = createActor(obsMachine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + it('should receive an emitted error', (done) => { + interface Ctx { + count: number | undefined; + } + interface Events { + type: 'COUNT'; + value: number; + } + const obsMachine = createMachine({ + types: {} as { context: Ctx; events: Events }, + id: 'obs', + initial: 'counting', + context: { count: undefined }, + states: { + counting: { + invoke: { + src: fromObservable(() => + interval(10).pipe( + map((value) => { + if (value === 5) { + throw new Error('some error'); + } + + return value; + }) + ) + ), + onSnapshot: ({ event }) => ({ + context: { + count: event.snapshot.context + } + }), + onError: ({ context, event }) => { + expect((event.error as any).message).toEqual('some error'); + if ( + context.count === 4 && + (event.error as any).message === 'some error' + ) { + return { target: 'success' }; + } + } + } + }, + success: { + type: 'final' + } + } + }); + + const actor = createActor(obsMachine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + it('should work with input', (done) => { + const childLogic = fromObservable(({ input }: { input: number }) => + of(input) + ); + + const machine = createMachine( + { + types: {} as { + actors: { + src: 'childLogic'; + logic: typeof childLogic; + }; + }, + context: { received: undefined }, + invoke: { + src: 'childLogic', + input: 42, + onSnapshot: ({ event }, enq) => { + if ( + event.snapshot.status === 'active' && + event.snapshot.context === 42 + ) { + enq.action(() => { + done(); + }); + } + } + } + }, + { + actors: { + childLogic + } + } + ); + + createActor(machine).start(); + }); + }); + + describe('with event observables', () => { + it('should work with an infinite event observable', (done) => { + interface Events { + type: 'COUNT'; + value: number; + } + const obsMachine = createMachine({ + types: {} as { context: { count: number | undefined }; events: Events }, + id: 'obs', + initial: 'counting', + context: { count: undefined }, + states: { + counting: { + invoke: { + src: fromEventObservable(() => + interval(10).pipe(map((value) => ({ type: 'COUNT', value }))) + ) + }, + on: { + COUNT: ({ context, event }) => ({ + context: { + ...context, + count: event.value + } + }) + }, + always: ({ context }) => { + if (context.count === 5) { + return { target: 'counted' }; + } + } + }, + counted: { + type: 'final' + } + } + }); + + const service = createActor(obsMachine); + service.subscribe({ + complete: () => { + done(); + } + }); + service.start(); + }); + + it('should work with a finite event observable', (done) => { + interface Ctx { + count: number | undefined; + } + interface Events { + type: 'COUNT'; + value: number; + } + const obsMachine = createMachine({ + types: {} as { context: Ctx; events: Events }, + id: 'obs', + initial: 'counting', + context: { + count: undefined + }, + states: { + counting: { + invoke: { + src: fromEventObservable(() => + interval(10).pipe( + take(5), + map((value) => ({ type: 'COUNT', value })) + ) + ), + onDone: ({ context }) => { + if (context.count === 4) { + return { target: 'counted' }; + } + } + }, + on: { + COUNT: ({ context, event }) => ({ + context: { + ...context, + count: event.value + } + }) + } + }, + counted: { + type: 'final' + } + } + }); + + const actor = createActor(obsMachine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + it('should receive an emitted error', (done) => { + interface Ctx { + count: number | undefined; + } + interface Events { + type: 'COUNT'; + value: number; + } + const obsMachine = createMachine({ + types: {} as { context: Ctx; events: Events }, + id: 'obs', + initial: 'counting', + context: { count: undefined }, + states: { + counting: { + invoke: { + src: fromEventObservable(() => + interval(10).pipe( + map((value) => { + if (value === 5) { + throw new Error('some error'); + } + + return { type: 'COUNT', value }; + }) + ) + ), + onError: ({ context, event }) => { + expect((event.error as any).message).toEqual('some error'); + if ( + context.count === 4 && + (event.error as any).message === 'some error' + ) { + return { target: 'success' }; + } + } + }, + on: { + COUNT: ({ context, event }) => ({ + context: { + ...context, + count: event.value + } + }) + } + }, + success: { + type: 'final' + } + } + }); + + const actor = createActor(obsMachine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + it('should work with input', (done) => { + const machine = createMachine({ + invoke: { + src: fromEventObservable(({ input }) => + of({ + type: 'obs.event', + value: input + }) + ), + input: 42 + }, + on: { + 'obs.event': ({ event }, enq) => { + expect(event.value).toEqual(42); + enq.action(() => { + done(); + }); + } + } + }); + + createActor(machine).start(); + }); + }); + + describe('with logic', () => { + it('should work with actor logic', (done) => { + const countLogic: ActorLogic< + Snapshot & { context: number }, + EventObject + > = { + transition: (state, event) => { + if (event.type === 'INC') { + return { + ...state, + context: state.context + 1 + }; + } else if (event.type === 'DEC') { + return { + ...state, + context: state.context - 1 + }; + } + return state; + }, + getInitialSnapshot: () => ({ + status: 'active', + output: undefined, + error: undefined, + context: 0 + }), + getPersistedSnapshot: (s) => s + }; + + const countMachine = createMachine({ + invoke: { + id: 'count', + src: countLogic + }, + on: { + INC: ({ children, event }) => { + children['count'].send(event); + } + } + }); + + const countService = createActor(countMachine); + countService.subscribe((state) => { + if (state.children['count']?.getSnapshot().context === 2) { + done(); + } + }); + countService.start(); + + countService.send({ type: 'INC' }); + countService.send({ type: 'INC' }); + }); + + it('logic should have reference to the parent', (done) => { + const pongLogic: ActorLogic, EventObject> = { + transition: (state, event, { self }) => { + if (event.type === 'PING') { + self._parent?.send({ type: 'PONG' }); + } + + return state; + }, + getInitialSnapshot: () => ({ + status: 'active', + output: undefined, + error: undefined + }), + getPersistedSnapshot: (s) => s + }; + + const pingMachine = createMachine({ + initial: 'waiting', + states: { + waiting: { + entry2: ({ children }) => { + children['ponger']?.send({ type: 'PING' }); + }, + invoke: { + id: 'ponger', + src: pongLogic + }, + on: { + PONG: 'success' + } + }, + success: { + type: 'final' + } + } + }); + + const pingService = createActor(pingMachine); + pingService.subscribe({ + complete: () => { + done(); + } + }); + pingService.start(); + }); + }); + + describe('with transition functions', () => { + it('should work with a transition function', (done) => { + const countReducer = ( + count: number, + event: { type: 'INC' } | { type: 'DEC' } + ): number => { + if (event.type === 'INC') { + return count + 1; + } else if (event.type === 'DEC') { + return count - 1; + } + return count; + }; + + const countMachine = createMachine({ + invoke: { + id: 'count', + src: fromTransition(countReducer, 0) + }, + on: { + INC: ({ children, event }) => { + children['count'].send(event); + } + } + }); + + const countService = createActor(countMachine); + countService.subscribe((state) => { + if (state.children['count']?.getSnapshot().context === 2) { + done(); + } + }); + countService.start(); + + countService.send({ type: 'INC' }); + countService.send({ type: 'INC' }); + }); + + it('should schedule events in a FIFO queue', (done) => { + type CountEvents = { type: 'INC' } | { type: 'DOUBLE' }; + + const countReducer = ( + count: number, + event: CountEvents, + { self }: ActorScope + ): number => { + if (event.type === 'INC') { + self.send({ type: 'DOUBLE' }); + return count + 1; + } + if (event.type === 'DOUBLE') { + return count * 2; + } + + return count; + }; + + const countMachine = createMachine({ + invoke: { + id: 'count', + src: fromTransition(countReducer, 0) + }, + on: { + INC: ({ children, event }) => { + children['count'].send(event); + } + } + }); + + const countService = createActor(countMachine); + countService.subscribe((state) => { + if (state.children['count']?.getSnapshot().context === 2) { + done(); + } + }); + countService.start(); + + countService.send({ type: 'INC' }); + }); + + it('should emit onSnapshot', (done) => { + const doublerLogic = fromTransition( + (_, event: { type: 'update'; value: number }) => event.value * 2, + 0 + ); + const machine = createMachine( + { + types: {} as { + actors: { src: 'doublerLogic'; logic: typeof doublerLogic }; + }, + invoke: { + id: 'doubler', + src: 'doublerLogic', + onSnapshot: ({ event }, enq) => { + if (event.snapshot.context === 42) { + enq.action(() => { + done(); + }); + } + } + }, + entry2: ({ children }) => { + children['doubler'].send({ type: 'update', value: 21 }); + } + }, + { + actors: { + doublerLogic + } + } + ); + + createActor(machine).start(); + }); + }); + + describe('with machines', () => { + const pongMachine = createMachine({ + id: 'pong', + initial: 'active', + states: { + active: { + on: { + PING: ({ parent }) => { + // Sends 'PONG' event to parent machine + parent?.send({ type: 'PONG' }); + } + } + } + } + }); + + // Parent machine + const pingMachine = createMachine({ + id: 'ping', + initial: 'innerMachine', + states: { + innerMachine: { + initial: 'active', + states: { + active: { + invoke: { + id: 'pong', + src: pongMachine + }, + // Sends 'PING' event to child machine with ID 'pong' + entry2: ({ children }) => { + children['pong']?.send({ type: 'PING' }); + }, + on: { + PONG: 'innerSuccess' + } + }, + innerSuccess: { + type: 'final' + } + }, + onDone: 'success' + }, + success: { type: 'final' } + } + }); + + it('should create invocations from machines in nested states', (done) => { + const actor = createActor(pingMachine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + + it('should emit onSnapshot', (done) => { + const childMachine = createMachine({ + initial: 'a', + states: { + a: { + after: { + 10: 'b' + } + }, + b: {} + } + }); + const machine = createMachine( + { + types: {} as { + actors: { src: 'childMachine'; logic: typeof childMachine }; + }, + invoke: { + src: 'childMachine', + onSnapshot: ({ event }, enq) => { + if (event.snapshot.value === 'b') { + enq.action(() => { + done(); + }); + } + } + } + }, + { + actors: { + childMachine + } + } + ); + + createActor(machine).start(); + }); + }); + + describe('multiple simultaneous services', () => { + const multiple = createMachine({ + types: {} as { context: { one?: string; two?: string } }, + id: 'machine', + initial: 'one', + context: {}, + on: { + ONE: ({ context }) => ({ + context: { + ...context, + one: 'one' + } + }), + + TWO: ({ context }) => ({ + context: { + ...context, + two: 'two' + }, + target: '.three' + }) + }, + + states: { + one: { + initial: 'two', + states: { + two: { + invoke: [ + { + id: 'child', + src: fromCallback(({ sendBack }) => sendBack({ type: 'ONE' })) + }, + { + id: 'child2', + src: fromCallback(({ sendBack }) => sendBack({ type: 'TWO' })) + } + ] + } + } + }, + three: { + type: 'final' + } + } + }); + + it('should start all services at once', (done) => { + const service = createActor(multiple); + service.subscribe({ + complete: () => { + expect(service.getSnapshot().context).toEqual({ + one: 'one', + two: 'two' + }); + done(); + } + }); + + service.start(); + }); + + const parallel = createMachine({ + types: {} as { context: { one?: string; two?: string } }, + id: 'machine', + initial: 'one', + + context: {}, + + on: { + ONE: ({ context }) => ({ + context: { + ...context, + one: 'one' + } + }), + + TWO: ({ context }) => ({ + context: { + ...context, + two: 'two' + } + }) + }, + + after: { + // allow both invoked services to get a chance to send their events + // and don't depend on a potential race condition (with an immediate transition) + 10: '.three' + }, + + states: { + one: { + initial: 'two', + states: { + two: { + type: 'parallel', + states: { + a: { + invoke: { + id: 'child', + src: fromCallback(({ sendBack }) => + sendBack({ type: 'ONE' }) + ) + } + }, + b: { + invoke: { + id: 'child2', + src: fromCallback(({ sendBack }) => + sendBack({ type: 'TWO' }) + ) + } + } + } + } + } + }, + three: { + type: 'final' + } + } + }); + + it('should run services in parallel', (done) => { + const service = createActor(parallel); + service.subscribe({ + complete: () => { + expect(service.getSnapshot().context).toEqual({ + one: 'one', + two: 'two' + }); + done(); + } + }); + + service.start(); + }); + + it('should not invoke an actor if it gets stopped immediately by transitioning away in immediate microstep', () => { + // Since an actor will be canceled when the state machine leaves the invoking state + // it does not make sense to start an actor in a state that will be exited immediately + let actorStarted = false; + + const transientMachine = createMachine({ + id: 'transient', + initial: 'active', + states: { + active: { + invoke: { + id: 'doNotInvoke', + src: fromCallback(() => { + actorStarted = true; + }) + }, + always: 'inactive' + }, + inactive: {} + } + }); + + const service = createActor(transientMachine); + + service.start(); + + expect(actorStarted).toBe(false); + }); + + // tslint:disable-next-line: max-line-length + it('should not invoke an actor if it gets stopped immediately by transitioning away in subsequent microstep', () => { + // Since an actor will be canceled when the state machine leaves the invoking state + // it does not make sense to start an actor in a state that will be exited immediately + let actorStarted = false; + + const transientMachine = createMachine({ + initial: 'withNonLeafInvoke', + states: { + withNonLeafInvoke: { + invoke: { + id: 'doNotInvoke', + src: fromCallback(() => { + actorStarted = true; + }) + }, + initial: 'first', + states: { + first: { + always: 'second' + }, + second: { + always: '#inactive' + } + } + }, + inactive: { + id: 'inactive' + } + } + }); + + const service = createActor(transientMachine); + + service.start(); + + expect(actorStarted).toBe(false); + }); + + it('should invoke a service if other service gets stopped in subsequent microstep (#1180)', (done) => { + const machine = createMachine({ + initial: 'running', + states: { + running: { + type: 'parallel', + states: { + one: { + initial: 'active', + on: { + STOP_ONE: '.idle' + }, + states: { + idle: {}, + active: { + invoke: { + id: 'active', + src: fromCallback(() => { + /* ... */ + }) + }, + on: { + NEXT: (_, enq) => { + enq.raise({ type: 'STOP_ONE' }); + } + } + } + } + }, + two: { + initial: 'idle', + on: { + NEXT: '.active' + }, + states: { + idle: {}, + active: { + invoke: { + id: 'post', + src: fromPromise(() => Promise.resolve(42)), + onDone: '#done' + } + } + } + } + } + }, + done: { + id: 'done', + type: 'final' + } + } + }); + + const service = createActor(machine); + service.subscribe({ complete: () => done() }); + service.start(); + + service.send({ type: 'NEXT' }); + }); + + it('should invoke an actor when reentering invoking state within a single macrostep', () => { + let actorStartedCount = 0; + + const transientMachine = createMachine({ + types: {} as { context: { counter: number } }, + initial: 'active', + context: { counter: 0 }, + states: { + active: { + invoke: { + src: fromCallback(() => { + actorStartedCount++; + }) + }, + always: ({ context }) => { + if (context.counter === 0) { + return { target: 'inactive' }; + } + } + }, + inactive: { + entry2: ({ context }) => ({ + context: { + ...context, + counter: context.counter + 1 + } + }), + always: 'active' + } + } + }); + + const service = createActor(transientMachine); + + service.start(); + + expect(actorStartedCount).toBe(1); + }); + }); + + it('invoke `src` can be used with invoke `input`', (done) => { + const machine = createMachine( + { + types: {} as { + actors: { + src: 'search'; + logic: PromiseActorLogic< + number, + { + endpoint: string; + } + >; + }; + }, + initial: 'searching', + states: { + searching: { + invoke: { + src: 'search', + input: { + endpoint: 'example.com' + }, + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }, + { + actors: { + search: fromPromise(async ({ input }) => { + expect(input.endpoint).toEqual('example.com'); + + return 42; + }) + } + } + ); + const actor = createActor(machine); + actor.subscribe({ complete: () => done() }); + actor.start(); + }); + + it('invoke `src` can be used with dynamic invoke `input`', async () => { + const machine = createMachine( + { + types: {} as { + context: { url: string }; + actors: { + src: 'search'; + logic: PromiseActorLogic< + number, + { + endpoint: string; + } + >; + }; + }, + initial: 'searching', + context: { + url: 'example.com' + }, + states: { + searching: { + invoke: { + src: 'search', + input: ({ context }) => ({ endpoint: context.url }), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }, + { + actors: { + search: fromPromise(async ({ input }) => { + expect(input.endpoint).toEqual('example.com'); + + return 42; + }) + } + } + ); + + await new Promise((res) => { + const actor = createActor(machine); + actor.subscribe({ complete: () => res() }); + actor.start(); + }); + }); + + it('invoke generated ID should be predictable based on the state node where it is defined', (done) => { + const machine = createMachine( + { + initial: 'a', + states: { + a: { + invoke: { + src: 'someSrc', + onDone: ({ event }) => { + // invoke ID should not be 'someSrc' + const expectedType = 'xstate.done.actor.0.(machine).a'; + expect(event.type).toEqual(expectedType); + if (event.type === expectedType) { + return { target: 'b' }; + } + } + } + }, + b: { + type: 'final' + } + } + }, + { + actors: { + someSrc: fromPromise(() => Promise.resolve()) + } + } + ); + + const actor = createActor(machine); + actor.subscribe({ + complete: () => { + done(); + } + }); + actor.start(); + }); + + it.each([ + ['src with string reference', { src: 'someSrc' }], + // ['machine', createMachine({ id: 'someId' })], + [ + 'src containing a machine directly', + { src: createMachine({ id: 'someId' }) } + ], + [ + 'src containing a callback actor directly', + { + src: fromCallback(() => { + /* ... */ + }) + } + ] + ])( + 'invoke config defined as %s should register unique and predictable child in state', + (_type, invokeConfig) => { + const machine = createMachine( + { + id: 'machine', + initial: 'a', + states: { + a: { + invoke: invokeConfig + } + } + }, + { + actors: { + someSrc: fromCallback(() => { + /* ... */ + }) + } + } + ); + + expect( + createActor(machine).getSnapshot().children['0.machine.a'] + ).toBeDefined(); + } + ); + + // https://github.com/statelyai/xstate/issues/464 + it('xstate.done.actor events should only select onDone transition on the invoking state when invokee is referenced using a string', (done) => { + let counter = 0; + let invoked = false; + + const handleSuccess = () => { + ++counter; + }; + + const createSingleState = (): any => ({ + initial: 'fetch', + states: { + fetch: { + invoke: { + src: 'fetchSmth', + onDone: (_, enq) => { + enq.action(handleSuccess); + } + } + } + } + }); + + const testMachine = createMachine( + { + type: 'parallel', + states: { + first: createSingleState(), + second: createSingleState() + } + }, + { + actors: { + fetchSmth: fromPromise(() => { + if (invoked) { + // create a promise that won't ever resolve for the second invoking state + return new Promise(() => { + /* ... */ + }); + } + invoked = true; + return Promise.resolve(42); + }) + } + } + ); + + createActor(testMachine).start(); + + // check within a macrotask so all promise-induced microtasks have a chance to resolve first + setTimeout(() => { + expect(counter).toEqual(1); + done(); + }, 0); + }); + + it('xstate.done.actor events should have unique names when invokee is a machine with an id property', (done) => { + const actual: AnyEventObject[] = []; + + const childMachine = createMachine({ + id: 'child', + initial: 'a', + states: { + a: { + invoke: { + src: fromPromise(() => { + return Promise.resolve(42); + }), + onDone: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const createSingleState = (): any => ({ + initial: 'fetch', + states: { + fetch: { + invoke: { + src: childMachine + } + } + } + }); + + const testMachine = createMachine({ + type: 'parallel', + states: { + first: createSingleState(), + second: createSingleState() + }, + on: { + '*': ({ event }, enq) => { + enq.action(() => { + actual.push(event); + }); + } + } + }); + + createActor(testMachine).start(); + + // check within a macrotask so all promise-induced microtasks have a chance to resolve first + setTimeout(() => { + expect(actual).toEqual([ + { + type: 'xstate.done.actor.0.(machine).first.fetch', + output: undefined, + actorId: '0.(machine).first.fetch' + }, + { + type: 'xstate.done.actor.0.(machine).second.fetch', + output: undefined, + actorId: '0.(machine).second.fetch' + } + ]); + done(); + }, 100); + }); + + it('should get reinstantiated after reentering the invoking state in a microstep', () => { + let invokeCount = 0; + + const machine = createMachine({ + initial: 'a', + states: { + a: { + invoke: { + src: fromCallback(() => { + invokeCount++; + }) + }, + on: { + GO_AWAY_AND_REENTER: 'b' + } + }, + b: { + always: 'a' + } + } + }); + const service = createActor(machine).start(); + + service.send({ type: 'GO_AWAY_AND_REENTER' }); + + expect(invokeCount).toBe(2); + }); + + it('invocations should be stopped when the machine reaches done state', () => { + let disposed = false; + const machine = createMachine({ + initial: 'a', + invoke: { + src: fromCallback(() => { + return () => { + disposed = true; + }; + }) + }, + states: { + a: { + on: { + FINISH: 'b' + } + }, + b: { + type: 'final' + } + } + }); + const service = createActor(machine).start(); + + service.send({ type: 'FINISH' }); + expect(disposed).toBe(true); + }); + + it('deep invocations should be stopped when the machine reaches done state', () => { + let disposed = false; + const childMachine = createMachine({ + invoke: { + src: fromCallback(() => { + return () => { + disposed = true; + }; + }) + } + }); + + const machine = createMachine({ + initial: 'a', + invoke: { + src: childMachine + }, + states: { + a: { + on: { + FINISH: 'b' + } + }, + b: { + type: 'final' + } + } + }); + const service = createActor(machine).start(); + + service.send({ type: 'FINISH' }); + expect(disposed).toBe(true); + }); + + it('root invocations should restart on root reentering transitions', () => { + let count = 0; + + const machine = createMachine({ + id: 'root', + invoke: { + src: fromPromise(() => { + count++; + return Promise.resolve(42); + }) + }, + on: { + EVENT: { + target: '#two', + reenter: true + } + }, + initial: 'one', + states: { + one: {}, + two: { + id: 'two' + } + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'EVENT' }); + + expect(count).toEqual(2); + }); + + it('should be able to restart an invoke when reentering the invoking state', () => { + const actual: string[] = []; + let invokeCounter = 0; + + const machine = createMachine({ + initial: 'inactive', + states: { + inactive: { + on: { ACTIVATE: 'active' } + }, + active: { + invoke: { + src: fromCallback(() => { + const localId = ++invokeCounter; + actual.push(`start ${localId}`); + return () => { + actual.push(`stop ${localId}`); + }; + }) + }, + on: { + REENTER: { + target: 'active', + reenter: true + } + } + } + } + }); + + const service = createActor(machine).start(); + + service.send({ + type: 'ACTIVATE' + }); + + actual.length = 0; + + service.send({ + type: 'REENTER' + }); + + expect(actual).toEqual(['stop 1', 'start 2']); + }); + + it.skip('should be able to receive a delayed event sent by the entry action of the invoking state', async () => { + const child = createMachine({ + types: {} as { + events: { + type: 'PING'; + origin: ActorRef, { type: 'PONG' }>; + }; + }, + on: { + PING: ({ event }) => { + event.origin.send({ type: 'PONG' }); + } + } + }); + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + invoke: { + id: 'foo', + src: child + }, + entry2: ({ children, self }, enq) => { + // TODO: invoke gets called after entry2 so children.foo does not exist yet + enq.sendTo( + children.foo, + { type: 'PING', origin: self }, + { delay: 1 } + ); + }, + on: { + PONG: 'c' + } + }, + c: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'NEXT' }); + await sleep(3); + expect(actorRef.getSnapshot().status).toBe('done'); + }); +}); + +describe('invoke input', () => { + it('should provide input to an actor creator', (done) => { + const machine = createMachine( + { + types: {} as { + context: { count: number }; + actors: { + src: 'stringService'; + logic: PromiseActorLogic< + boolean, + { + staticVal: string; + newCount: number; + } + >; + }; + }, + initial: 'pending', + context: { + count: 42 + }, + states: { + pending: { + invoke: { + src: 'stringService', + input: ({ context }) => ({ + staticVal: 'hello', + newCount: context.count * 2 + }), + onDone: 'success' + } + }, + success: { + type: 'final' + } + } + }, + { + actors: { + stringService: fromPromise(({ input }) => { + expect(input).toEqual({ newCount: 84, staticVal: 'hello' }); + + return Promise.resolve(true); + }) + } + } + ); + + const service = createActor(machine); + service.subscribe({ + complete: () => { + done(); + } + }); + + service.start(); + }); + + it('should provide self to input mapper', (done) => { + const machine = createMachine({ + invoke: { + src: fromCallback(({ input }) => { + expect(input.responder.send).toBeDefined(); + done(); + }), + input: ({ self }) => ({ + responder: self + }) + } + }); + + createActor(machine).start(); + }); +}); diff --git a/packages/core/test/logger.v6.test.ts b/packages/core/test/logger.v6.test.ts new file mode 100644 index 0000000000..d22ecafa4a --- /dev/null +++ b/packages/core/test/logger.v6.test.ts @@ -0,0 +1,46 @@ +import { createActor, createMachine } from '../src'; + +describe('logger', () => { + it('system logger should be default logger for actors (invoked from machine)', () => { + expect.assertions(1); + const machine = createMachine({ + invoke: { + src: createMachine({ + entry2: (_, enq) => { + enq.log('hello'); + } + }) + } + }); + + const actor = createActor(machine, { + logger: (arg) => { + expect(arg).toEqual('hello'); + } + }).start(); + + actor.start(); + }); + + it('system logger should be default logger for actors (spawned from machine)', () => { + expect.assertions(1); + const machine = createMachine({ + entry2: (_, enq) => + void enq.spawn( + createMachine({ + entry2: (_, enq) => { + enq.log('hello'); + } + }) + ) + }); + + const actor = createActor(machine, { + logger: (arg) => { + expect(arg).toEqual('hello'); + } + }).start(); + + actor.start(); + }); +}); diff --git a/packages/core/test/machine.v6.test.ts b/packages/core/test/machine.v6.test.ts new file mode 100644 index 0000000000..e830dff574 --- /dev/null +++ b/packages/core/test/machine.v6.test.ts @@ -0,0 +1,352 @@ +import { + createActor, + next_createMachine, + assign, + setup +} from '../src/index.ts'; + +const pedestrianStates = { + initial: 'walk', + states: { + walk: { + on: { + PED_COUNTDOWN: 'wait' + } + }, + wait: { + on: { + PED_COUNTDOWN: 'stop' + } + }, + stop: {} + } +}; + +const lightMachine = next_createMachine({ + initial: 'green', + states: { + green: { + on: { + TIMER: 'yellow', + POWER_OUTAGE: 'red', + FORBIDDEN_EVENT: undefined + } + }, + yellow: { + on: { + TIMER: 'red', + POWER_OUTAGE: 'red' + } + }, + red: { + on: { + TIMER: 'green', + POWER_OUTAGE: 'red' + }, + ...pedestrianStates + } + } +}); + +describe('machine', () => { + describe('machine.states', () => { + it('should properly register machine states', () => { + expect(Object.keys(lightMachine.states)).toEqual([ + 'green', + 'yellow', + 'red' + ]); + }); + }); + + describe('machine.events', () => { + it('should return the set of events accepted by machine', () => { + expect(lightMachine.events).toEqual([ + 'TIMER', + 'POWER_OUTAGE', + 'PED_COUNTDOWN' + ]); + }); + }); + + describe('machine.config', () => { + it('state node config should reference original machine config', () => { + const machine = next_createMachine({ + initial: 'one', + states: { + one: { + initial: 'deep', + states: { + deep: {} + } + } + } + }); + + const oneState = machine.states.one; + + expect(oneState.config).toBe(machine.config.states!.one); + + const deepState = machine.states.one.states.deep; + + expect(deepState.config).toBe(machine.config.states!.one.states!.deep); + + deepState.config.meta = 'testing meta'; + + expect(machine.config.states!.one.states!.deep.meta).toEqual( + 'testing meta' + ); + }); + }); + + describe('machine.provide', () => { + // https://github.com/davidkpiano/xstate/issues/674 + it('should throw if initial state is missing in a compound state', () => { + expect(() => { + next_createMachine({ + initial: 'first', + states: { + first: { + states: { + second: {}, + third: {} + } + } + } + }); + }).toThrow(); + }); + + it('machines defined without context should have a default empty object for context', () => { + expect(createActor(next_createMachine({})).getSnapshot().context).toEqual( + {} + ); + }); + + it('should lazily create context for all interpreter instances created from the same machine template created by `provide`', () => { + const machine = next_createMachine({ + types: {} as { context: { foo: { prop: string } } }, + context: () => ({ + foo: { prop: 'baz' } + }) + }); + + const copiedMachine = machine.provide({}); + + const a = createActor(copiedMachine).start(); + const b = createActor(copiedMachine).start(); + + expect(a.getSnapshot().context.foo).not.toBe(b.getSnapshot().context.foo); + }); + }); + + describe('machine function context', () => { + it('context from a function should be lazily evaluated', () => { + const config = { + initial: 'active', + context: () => ({ + foo: { bar: 'baz' } + }), + states: { + active: {} + } + }; + const testMachine1 = next_createMachine(config); + const testMachine2 = next_createMachine(config); + + const initialState1 = createActor(testMachine1).getSnapshot(); + const initialState2 = createActor(testMachine2).getSnapshot(); + + expect(initialState1.context).not.toBe(initialState2.context); + + expect(initialState1.context).toEqual({ + foo: { bar: 'baz' } + }); + + expect(initialState2.context).toEqual({ + foo: { bar: 'baz' } + }); + }); + }); + + describe('machine.resolveState()', () => { + const resolveMachine = next_createMachine({ + id: 'resolve', + initial: 'foo', + states: { + foo: { + initial: 'one', + states: { + one: { + type: 'parallel', + states: { + a: { + initial: 'aa', + states: { aa: {} } + }, + b: { + initial: 'bb', + states: { bb: {} } + } + }, + on: { + TO_TWO: 'two' + } + }, + two: { + on: { TO_ONE: 'one' } + } + }, + on: { + TO_BAR: 'bar' + } + }, + bar: { + on: { + TO_FOO: 'foo' + } + } + } + }); + + it('should resolve the state value', () => { + const resolvedState = resolveMachine.resolveState({ value: 'foo' }); + + expect(resolvedState.value).toEqual({ + foo: { one: { a: 'aa', b: 'bb' } } + }); + }); + + it('should resolve `status: done`', () => { + const machine = next_createMachine({ + initial: 'foo', + states: { + foo: { + on: { NEXT: 'bar' } + }, + bar: { + type: 'final' + } + } + }); + + const resolvedState = machine.resolveState({ value: 'bar' }); + + expect(resolvedState.status).toBe('done'); + }); + }); + + describe('initial state', () => { + it('should follow always transition', () => { + const machine = next_createMachine({ + initial: 'a', + states: { + a: { + always: { target: 'b' } + }, + b: {} + } + }); + + expect(createActor(machine).getSnapshot().value).toBe('b'); + }); + }); + + describe('versioning', () => { + it('should allow a version to be specified', () => { + const versionMachine = next_createMachine({ + id: 'version', + version: '1.0.4', + states: {} + }); + + expect(versionMachine.version).toEqual('1.0.4'); + }); + }); + + describe('id', () => { + it('should represent the ID', () => { + const idMachine = next_createMachine({ + id: 'some-id', + initial: 'idle', + states: { idle: {} } + }); + + expect(idMachine.id).toEqual('some-id'); + }); + + it('should represent the ID (state node)', () => { + const idMachine = next_createMachine({ + id: 'some-id', + initial: 'idle', + states: { + idle: { + id: 'idle' + } + } + }); + + expect(idMachine.states.idle.id).toEqual('idle'); + }); + + it('should use the key as the ID if no ID is provided (state node)', () => { + const noStateNodeIDMachine = next_createMachine({ + id: 'some-id', + initial: 'idle', + states: { idle: {} } + }); + + expect(noStateNodeIDMachine.states.idle.id).toEqual('some-id.idle'); + }); + }); + + describe('combinatorial machines', () => { + it('should support combinatorial machines (single-state)', () => { + const testMachine = next_createMachine({ + types: {} as { context: { value: number } }, + context: { value: 42 }, + on: { + INC: ({ context }) => ({ + context: { + value: context.value + 1 + } + }) + } + }); + + const actorRef = createActor(testMachine); + expect(actorRef.getSnapshot().value).toEqual({}); + + actorRef.start(); + actorRef.send({ type: 'INC' }); + + expect(actorRef.getSnapshot().context.value).toEqual(43); + }); + }); + + it('should pass through schemas', () => { + const machine = setup({ + schemas: { + context: { count: { type: 'number' } } + } + }).createMachine({}); + + expect(machine.schemas).toEqual({ + context: { count: { type: 'number' } } + }); + }); +}); + +describe('StateNode', () => { + it('should list transitions', () => { + const greenNode = lightMachine.states.green; + + const transitions = greenNode.transitions; + + expect([...transitions.keys()]).toEqual([ + 'TIMER', + 'POWER_OUTAGE', + 'FORBIDDEN_EVENT' + ]); + }); +}); diff --git a/packages/core/test/microstep.v6.test.ts b/packages/core/test/microstep.v6.test.ts new file mode 100644 index 0000000000..f7928ab0b7 --- /dev/null +++ b/packages/core/test/microstep.v6.test.ts @@ -0,0 +1,176 @@ +import { createMachine } from '../src/index.ts'; +import { raise } from '../src/actions/raise'; +import { createInertActorScope } from '../src/getNextSnapshot.ts'; + +describe('machine.microstep()', () => { + it('should return an array of states from all microsteps', () => { + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + GO: 'a' + } + }, + a: { + entry2: (_, enq) => { + enq.raise({ type: 'NEXT' }); + }, + on: { + NEXT: 'b' + } + }, + b: { + always: 'c' + }, + c: { + entry2: (_, enq) => { + enq.raise({ type: 'NEXT' }); + }, + on: { + NEXT: 'd' + } + }, + d: {} + } + }); + + const actorScope = createInertActorScope(machine); + const states = machine.microstep( + machine.getInitialSnapshot(actorScope), + { type: 'GO' }, + actorScope + ); + + expect(states.map((s) => s.value)).toEqual(['a', 'b', 'c', 'd']); + }); + + it('should return the states from microstep (transient)', () => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + on: { + TRIGGER: 'second' + } + }, + second: { + always: 'third' + }, + third: {} + } + }); + + const actorScope = createInertActorScope(machine); + const states = machine.microstep( + machine.resolveState({ value: 'first' }), + { type: 'TRIGGER' }, + actorScope + ); + + expect(states.map((s) => s.value)).toEqual(['second', 'third']); + }); + + it('should return the states from microstep (raised event)', () => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + on: { + TRIGGER: (_, enq) => { + enq.raise({ type: 'RAISED' }); + return { target: 'second' }; + } + } + }, + second: { + on: { + RAISED: 'third' + } + }, + third: {} + } + }); + + const actorScope = createInertActorScope(machine); + const states = machine.microstep( + machine.resolveState({ value: 'first' }), + { type: 'TRIGGER' }, + actorScope + ); + + expect(states.map((s) => s.value)).toEqual(['second', 'third']); + }); + + it('should return a single-item array for normal transitions', () => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + on: { + TRIGGER: 'second' + } + }, + second: {} + } + }); + + const actorScope = createInertActorScope(machine); + const states = machine.microstep( + machine.getInitialSnapshot(actorScope), + { type: 'TRIGGER' }, + actorScope + ); + + expect(states.map((s) => s.value)).toEqual(['second']); + }); + + it('each state should preserve their internal queue', () => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + on: { + TRIGGER: (_, enq) => { + enq.raise({ type: 'FOO' }); + enq.raise({ type: 'BAR' }); + return { target: 'second' }; + } + } + }, + second: { + on: { + FOO: { + target: 'third' + } + } + }, + third: { + on: { + BAR: { + target: 'fourth' + } + } + }, + fourth: { + always: 'fifth' + }, + fifth: {} + } + }); + + const actorScope = createInertActorScope(machine); + const states = machine.microstep( + machine.getInitialSnapshot(actorScope), + { type: 'TRIGGER' }, + actorScope + ); + + expect(states.map((s) => s.value)).toEqual([ + 'second', + 'third', + 'fourth', + 'fifth' + ]); + }); +}); diff --git a/packages/core/test/multiple.v6.test.ts b/packages/core/test/multiple.v6.test.ts new file mode 100644 index 0000000000..5efa54c2a3 --- /dev/null +++ b/packages/core/test/multiple.v6.test.ts @@ -0,0 +1,212 @@ +import { createMachine, createActor } from '../src/index'; + +describe('multiple', () => { + const machine = createMachine({ + initial: 'simple', + states: { + simple: { + on: { + DEEP_M: 'para.K.M', + DEEP_CM: [{ target: ['para.A.C', 'para.K.M'] }], + DEEP_MR: [{ target: ['para.K.M', 'para.P.R'] }], + DEEP_CMR: [{ target: ['para.A.C', 'para.K.M', 'para.P.R'] }], + BROKEN_SAME_REGION: [{ target: ['para.A.C', 'para.A.B'] }], + BROKEN_DIFFERENT_REGIONS: [ + { target: ['para.A.C', 'para.K.M', 'other'] } + ], + BROKEN_DIFFERENT_REGIONS_2: [{ target: ['para.A.C', 'para2.K2.M2'] }], + BROKEN_DIFFERENT_REGIONS_3: [ + { target: ['para2.K2.L2.L2A', 'other'] } + ], + BROKEN_DIFFERENT_REGIONS_4: [ + { target: ['para2.K2.L2.L2A.L2C', 'para2.K2.M2'] } + ], + INITIAL: 'para' + } + }, + other: { + initial: 'X', + states: { + X: {} + } + }, + para: { + type: 'parallel', + states: { + A: { + initial: 'B', + states: { + B: {}, + C: {} + } + }, + K: { + initial: 'L', + states: { + L: {}, + M: {} + } + }, + P: { + initial: 'Q', + states: { + Q: {}, + R: {} + } + } + } + }, + para2: { + type: 'parallel', + states: { + A2: { + initial: 'B2', + states: { + B2: {}, + C2: {} + } + }, + K2: { + initial: 'L2', + states: { + L2: { + type: 'parallel', + states: { + L2A: { + initial: 'L2B', + states: { + L2B: {}, + L2C: {} + } + }, + L2K: { + initial: 'L2L', + states: { + L2L: {}, + L2M: {} + } + }, + L2P: { + initial: 'L2Q', + states: { + L2Q: {}, + L2R: {} + } + } + } + }, + M2: { + type: 'parallel', + states: { + M2A: { + initial: 'M2B', + states: { + M2B: {}, + M2C: {} + } + }, + M2K: { + initial: 'M2L', + states: { + M2L: {}, + M2M: {} + } + }, + M2P: { + initial: 'M2Q', + states: { + M2Q: {}, + M2R: {} + } + } + } + } + } + }, + P2: { + initial: 'Q2', + states: { + Q2: {}, + R2: {} + } + } + } + } + } + }); + + describe('transitions to parallel states', () => { + it('should enter initial states of parallel states', () => { + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'INITIAL' }); + expect(actorRef.getSnapshot().value).toEqual({ + para: { A: 'B', K: 'L', P: 'Q' } + }); + }); + + it('should enter specific states in one region', () => { + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'DEEP_M' }); + expect(actorRef.getSnapshot().value).toEqual({ + para: { A: 'B', K: 'M', P: 'Q' } + }); + }); + + it('should enter specific states in all regions', () => { + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'DEEP_CMR' }); + expect(actorRef.getSnapshot().value).toEqual({ + para: { A: 'C', K: 'M', P: 'R' } + }); + }); + + it('should enter specific states in some regions', () => { + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'DEEP_MR' }); + expect(actorRef.getSnapshot().value).toEqual({ + para: { A: 'B', K: 'M', P: 'R' } + }); + }); + + it.skip('should reject two targets in the same region', () => { + const actorRef = createActor(machine).start(); + expect(() => actorRef.send({ type: 'BROKEN_SAME_REGION' })).toThrow(); + }); + + it.skip('should reject targets inside and outside a region', () => { + const actorRef = createActor(machine).start(); + expect(() => + actorRef.send({ type: 'BROKEN_DIFFERENT_REGIONS' }) + ).toThrow(); + }); + + it.skip('should reject two targets in different regions', () => { + const actorRef = createActor(machine).start(); + expect(() => + actorRef.send({ type: 'BROKEN_DIFFERENT_REGIONS_2' }) + ).toThrow(); + }); + + it.skip('should reject two targets in different regions at different levels', () => { + const actorRef = createActor(machine).start(); + expect(() => + actorRef.send({ type: 'BROKEN_DIFFERENT_REGIONS_3' }) + ).toThrow(); + }); + + it.skip('should reject two deep targets in different regions at top level', () => { + // TODO: this test has the same body as the one before it, this doesn't look alright + const actorRef = createActor(machine).start(); + expect(() => + actorRef.send({ type: 'BROKEN_DIFFERENT_REGIONS_3' }) + ).toThrow(); + }); + + it.skip('should reject two deep targets in different regions at different levels', () => { + const actorRef = createActor(machine).start(); + expect(() => + actorRef.send({ type: 'BROKEN_DIFFERENT_REGIONS_4' }) + ).toThrow(); + }); + }); +}); diff --git a/packages/core/test/order.v6.test.ts b/packages/core/test/order.v6.test.ts new file mode 100644 index 0000000000..fcee38819d --- /dev/null +++ b/packages/core/test/order.v6.test.ts @@ -0,0 +1,86 @@ +import { createMachine, StateNode } from '../src/index.ts'; + +describe('document order', () => { + it('should specify the correct document order for each state node', () => { + const machine = createMachine({ + id: 'order', + initial: 'one', + states: { + one: { + initial: 'two', + states: { + two: {}, + three: { + initial: 'four', + states: { + four: {}, + five: { + initial: 'six', + states: { + six: {} + } + } + } + } + } + }, + seven: { + type: 'parallel', + states: { + eight: { + initial: 'nine', + states: { + nine: {}, + ten: { + initial: 'eleven', + states: { + eleven: {}, + twelve: {} + } + } + } + }, + thirteen: { + type: 'parallel', + states: { + fourteen: {}, + fifteen: {} + } + } + } + } + } + }); + + function dfs(node: StateNode): StateNode[] { + return [ + node as any, + ...Object.keys(node.states).map((key) => dfs(node.states[key] as any)) + ].flat(); + } + + const allStateNodeOrders = dfs(machine.root).map((sn) => [ + sn.key, + sn.order + ]); + + expect(allStateNodeOrders).toEqual([ + ['order', 0], + ['one', 1], + ['two', 2], + ['three', 3], + ['four', 4], + ['five', 5], + ['six', 6], + ['seven', 7], + ['eight', 8], + ['nine', 9], + ['ten', 10], + ['eleven', 11], + ['twelve', 12], + ['thirteen', 13], + ['fourteen', 14], + ['fifteen', 15] + ]); + }); +}); diff --git a/packages/core/test/parallel.v6.test.ts b/packages/core/test/parallel.v6.test.ts new file mode 100644 index 0000000000..c8a73e5bd4 --- /dev/null +++ b/packages/core/test/parallel.v6.test.ts @@ -0,0 +1,1361 @@ +import { createMachine, createActor, StateValue } from '../src/index.ts'; + +import { testMultiTransition, trackEntries } from './utils.ts'; + +const composerMachine = createMachine({ + initial: 'ReadOnly', + states: { + ReadOnly: { + id: 'ReadOnly', + initial: 'StructureEdit', + entry: ['selectNone'], + states: { + StructureEdit: { + id: 'StructureEditRO', + type: 'parallel', + on: { + switchToProjectManagement: [ + { + target: 'ProjectManagement' + } + ] + }, + states: { + SelectionStatus: { + initial: 'SelectedNone', + on: { + singleClickActivity: [ + { + target: '.SelectedActivity', + actions: ['selectActivity'] + } + ], + singleClickLink: [ + { + target: '.SelectedLink', + actions: ['selectLink'] + } + ] + }, + states: { + SelectedNone: { + entry: ['redraw'] + }, + SelectedActivity: { + entry: ['redraw'], + on: { + singleClickCanvas: [ + { + target: 'SelectedNone', + actions: ['selectNone'] + } + ] + } + }, + SelectedLink: { + entry: ['redraw'], + on: { + singleClickCanvas: [ + { + target: 'SelectedNone', + actions: ['selectNone'] + } + ] + } + } + } + }, + ClipboardStatus: { + initial: 'Empty', + states: { + Empty: { + entry: ['emptyClipboard'], + on: { + cutInClipboardSuccess: [ + { + target: 'FilledByCut' + } + ], + copyInClipboardSuccess: [ + { + target: 'FilledByCopy' + } + ] + } + }, + FilledByCopy: { + on: { + cutInClipboardSuccess: [ + { + target: 'FilledByCut' + } + ], + copyInClipboardSuccess: [ + { + target: 'FilledByCopy' + } + ], + pasteFromClipboardSuccess: [ + { + target: 'FilledByCopy' + } + ] + } + }, + FilledByCut: { + on: { + cutInClipboardSuccess: [ + { + target: 'FilledByCut' + } + ], + copyInClipboardSuccess: [ + { + target: 'FilledByCopy' + } + ], + pasteFromClipboardSuccess: [ + { + target: 'Empty' + } + ] + } + } + } + } + } + }, + ProjectManagement: { + id: 'ProjectManagementRO', + type: 'parallel', + on: { + switchToStructureEdit: [ + { + target: 'StructureEdit' + } + ] + }, + states: { + SelectionStatus: { + initial: 'SelectedNone', + on: { + singleClickActivity: [ + { + target: '.SelectedActivity', + actions: ['selectActivity'] + } + ], + singleClickLink: [ + { + target: '.SelectedLink', + actions: ['selectLink'] + } + ] + }, + states: { + SelectedNone: { + entry: ['redraw'] + }, + SelectedActivity: { + entry: ['redraw'], + on: { + singleClickCanvas: [ + { + target: 'SelectedNone', + actions: ['selectNone'] + } + ] + } + }, + SelectedLink: { + entry: ['redraw'], + on: { + singleClickCanvas: [ + { + target: 'SelectedNone', + actions: ['selectNone'] + } + ] + } + } + } + } + } + } + } + } + } +}); + +const wakMachine = createMachine({ + id: 'wakMachine', + type: 'parallel', + + states: { + wak1: { + initial: 'wak1sonA', + states: { + wak1sonA: { + entry: 'wak1sonAenter', + exit: 'wak1sonAexit' + }, + wak1sonB: { + entry: 'wak1sonBenter', + exit: 'wak1sonBexit' + } + }, + on: { + WAK1: '.wak1sonB' + }, + entry: 'wak1enter', + exit: 'wak1exit' + }, + wak2: { + initial: 'wak2sonA', + states: { + wak2sonA: { + entry: 'wak2sonAenter', + exit: 'wak2sonAexit' + }, + wak2sonB: { + entry: 'wak2sonBenter', + exit: 'wak2sonBexit' + } + }, + on: { + WAK2: '.wak2sonB' + }, + entry: 'wak2enter', + exit: 'wak2exit' + } + } +}); + +const wordMachine = createMachine({ + id: 'word', + type: 'parallel', + states: { + bold: { + initial: 'off', + states: { + on: { + on: { TOGGLE_BOLD: 'off' } + }, + off: { + on: { TOGGLE_BOLD: 'on' } + } + } + }, + underline: { + initial: 'off', + states: { + on: { + on: { TOGGLE_UNDERLINE: 'off' } + }, + off: { + on: { TOGGLE_UNDERLINE: 'on' } + } + } + }, + italics: { + initial: 'off', + states: { + on: { + on: { TOGGLE_ITALICS: 'off' } + }, + off: { + on: { TOGGLE_ITALICS: 'on' } + } + } + }, + list: { + initial: 'none', + states: { + none: { + on: { BULLETS: 'bullets', NUMBERS: 'numbers' } + }, + bullets: { + on: { NONE: 'none', NUMBERS: 'numbers' } + }, + numbers: { + on: { BULLETS: 'bullets', NONE: 'none' } + } + } + } + }, + on: { + RESET: '#word' // TODO: this should be 'word' or [{ internal: false }] + } +}); + +const flatParallelMachine = createMachine({ + type: 'parallel', + states: { + foo: {}, + bar: {}, + baz: { + initial: 'one', + states: { + one: { on: { E: 'two' } }, + two: {} + } + } + } +}); + +const raisingParallelMachine = createMachine({ + type: 'parallel', + states: { + OUTER1: { + initial: 'C', + states: { + A: { + // entry: [raise({ type: 'TURN_OFF' })], + entry2: (_, enq) => { + enq.raise({ type: 'TURN_OFF' }); + }, + on: { + EVENT_OUTER1_B: 'B', + EVENT_OUTER1_C: 'C' + } + }, + B: { + entry2: (_, enq) => { + enq.raise({ type: 'TURN_ON' }); + }, + on: { + EVENT_OUTER1_A: 'A', + EVENT_OUTER1_C: 'C' + } + }, + C: { + entry2: (_, enq) => { + enq.raise({ type: 'CLEAR' }); + }, + on: { + EVENT_OUTER1_A: 'A', + EVENT_OUTER1_B: 'B' + } + } + } + }, + OUTER2: { + type: 'parallel', + states: { + INNER1: { + initial: 'ON', + states: { + OFF: { + on: { + TURN_ON: 'ON' + } + }, + ON: { + on: { + CLEAR: 'OFF' + } + } + } + }, + INNER2: { + initial: 'OFF', + states: { + OFF: { + on: { + TURN_ON: 'ON' + } + }, + ON: { + on: { + TURN_OFF: 'OFF' + } + } + } + } + } + } + } +}); + +const nestedParallelState = createMachine({ + type: 'parallel', + states: { + OUTER1: { + initial: 'STATE_OFF', + states: { + STATE_OFF: { + on: { + EVENT_COMPLEX: 'STATE_ON', + EVENT_SIMPLE: 'STATE_ON' + } + }, + STATE_ON: { + type: 'parallel', + states: { + STATE_NTJ0: { + initial: 'STATE_IDLE_0', + states: { + STATE_IDLE_0: { + on: { + EVENT_STATE_NTJ0_WORK: 'STATE_WORKING_0' + } + }, + STATE_WORKING_0: { + on: { + EVENT_STATE_NTJ0_IDLE: 'STATE_IDLE_0' + } + } + } + }, + STATE_NTJ1: { + initial: 'STATE_IDLE_1', + states: { + STATE_IDLE_1: { + on: { + EVENT_STATE_NTJ1_WORK: 'STATE_WORKING_1' + } + }, + STATE_WORKING_1: { + on: { + EVENT_STATE_NTJ1_IDLE: 'STATE_IDLE_1' + } + } + } + } + } + } + } + }, + OUTER2: { + initial: 'STATE_OFF', + states: { + STATE_OFF: { + on: { + EVENT_COMPLEX: 'STATE_ON_COMPLEX', + EVENT_SIMPLE: 'STATE_ON_SIMPLE' + } + }, + STATE_ON_SIMPLE: {}, + STATE_ON_COMPLEX: { + type: 'parallel', + states: { + STATE_INNER1: { + initial: 'STATE_OFF', + states: { + STATE_OFF: {}, + STATE_ON: {} + } + }, + STATE_INNER2: { + initial: 'STATE_OFF', + states: { + STATE_OFF: {}, + STATE_ON: {} + } + } + } + } + } + } + } +}); + +const deepFlatParallelMachine = createMachine({ + type: 'parallel', + states: { + X: {}, + V: { + initial: 'A', + on: { + a: { + target: 'V.A' + }, + b: { + target: 'V.B' + }, + c: { + target: 'V.C' + } + }, + states: { + A: {}, + B: { + initial: 'BB', + states: { + BB: { + type: 'parallel', + states: { + BBB_A: {}, + BBB_B: {} + } + } + } + }, + C: {} + } + } + } +}); + +describe('parallel states', () => { + it('should have initial parallel states', () => { + const initialState = createActor(wordMachine).getSnapshot(); + + expect(initialState.value).toEqual({ + bold: 'off', + italics: 'off', + underline: 'off', + list: 'none' + }); + }); + + const expected: Record> = { + '{"bold": "off"}': { + TOGGLE_BOLD: { + bold: 'on', + italics: 'off', + underline: 'off', + list: 'none' + } + }, + '{"bold": "on"}': { + TOGGLE_BOLD: { + bold: 'off', + italics: 'off', + underline: 'off', + list: 'none' + } + }, + [JSON.stringify({ + bold: 'off', + italics: 'off', + underline: 'on', + list: 'bullets' + })]: { + 'TOGGLE_BOLD, TOGGLE_ITALICS': { + bold: 'on', + italics: 'on', + underline: 'on', + list: 'bullets' + }, + RESET: { + bold: 'off', + italics: 'off', + underline: 'off', + list: 'none' + } + } + }; + + Object.keys(expected).forEach((fromState) => { + Object.keys(expected[fromState]).forEach((eventTypes) => { + const toState = expected[fromState][eventTypes]; + + it(`should go from ${fromState} to ${JSON.stringify( + toState + )} on ${eventTypes}`, () => { + const resultState = testMultiTransition( + wordMachine, + fromState, + eventTypes + ); + + expect(resultState.value).toEqual(toState); + }); + }); + }); + + it('should have all parallel states represented in the state value', () => { + const machine = createMachine({ + type: 'parallel', + states: { + wak1: { + initial: 'wak1sonA', + states: { + wak1sonA: {}, + wak1sonB: {} + }, + on: { + WAK1: '.wak1sonB' + } + }, + wak2: { + initial: 'wak2sonA', + states: { + wak2sonA: {} + } + } + } + }); + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'WAK1' }); + + expect(actorRef.getSnapshot().value).toEqual({ + wak1: 'wak1sonB', + wak2: 'wak2sonA' + }); + }); + + it('should have all parallel states represented in the state value (2)', () => { + const actorRef = createActor(wakMachine).start(); + actorRef.send({ type: 'WAK2' }); + + expect(actorRef.getSnapshot().value).toEqual({ + wak1: 'wak1sonA', + wak2: 'wak2sonB' + }); + }); + + it('should work with regions without states', () => { + expect(createActor(flatParallelMachine).getSnapshot().value).toEqual({ + foo: {}, + bar: {}, + baz: 'one' + }); + }); + + it('should work with regions without states', () => { + const actorRef = createActor(flatParallelMachine).start(); + actorRef.send({ type: 'E' }); + expect(actorRef.getSnapshot().value).toEqual({ + foo: {}, + bar: {}, + baz: 'two' + }); + }); + + it('should properly transition to relative substate', () => { + const actorRef = createActor(composerMachine).start(); + actorRef.send({ + type: 'singleClickActivity' + }); + + expect(actorRef.getSnapshot().value).toEqual({ + ReadOnly: { + StructureEdit: { + SelectionStatus: 'SelectedActivity', + ClipboardStatus: 'Empty' + } + } + }); + }); + + it('should properly transition according to entry events on an initial state', () => { + const machine = createMachine({ + type: 'parallel', + states: { + OUTER1: { + initial: 'B', + states: { + A: {}, + B: { + // entry: raise({ type: 'CLEAR' }) + entry2: (_, enq) => { + enq.raise({ type: 'CLEAR' }); + } + } + } + }, + OUTER2: { + type: 'parallel', + states: { + INNER1: { + initial: 'ON', + states: { + OFF: {}, + ON: { + on: { + CLEAR: 'OFF' + } + } + } + }, + INNER2: { + initial: 'OFF', + states: { + OFF: {}, + ON: {} + } + } + } + } + } + }); + expect(createActor(machine).getSnapshot().value).toEqual({ + OUTER1: 'B', + OUTER2: { + INNER1: 'OFF', + INNER2: 'OFF' + } + }); + }); + + it('should properly transition when raising events for a parallel state', () => { + const actorRef = createActor(raisingParallelMachine).start(); + actorRef.send({ + type: 'EVENT_OUTER1_B' + }); + + expect(actorRef.getSnapshot().value).toEqual({ + OUTER1: 'B', + OUTER2: { + INNER1: 'ON', + INNER2: 'ON' + } + }); + }); + + it('should handle simultaneous orthogonal transitions', () => { + type Events = { type: 'CHANGE'; value: string } | { type: 'SAVE' }; + const simultaneousMachine = createMachine({ + types: {} as { context: { value: string }; events: Events }, + id: 'yamlEditor', + type: 'parallel', + context: { + value: '' + }, + states: { + editing: { + on: { + CHANGE: ({ context, event }) => ({ + context: { + ...context, + value: event.value + } + }) + } + }, + status: { + initial: 'unsaved', + states: { + unsaved: { + on: { + SAVE: { + target: 'saved' + } + } + }, + saved: { + on: { + CHANGE: 'unsaved' + } + } + } + } + } + }); + + const actorRef = createActor(simultaneousMachine).start(); + actorRef.send({ + type: 'SAVE' + }); + actorRef.send({ + type: 'CHANGE', + value: 'something' + }); + + expect(actorRef.getSnapshot().value).toEqual({ + editing: {}, + status: 'unsaved' + }); + + expect(actorRef.getSnapshot().context).toEqual({ + value: 'something' + }); + }); + + // TODO: skip (initial actions) + it.skip('should execute actions of the initial transition of a parallel region when entering the initial state nodes of a machine', () => { + const spy = jest.fn(); + + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: (_, enq) => { + enq.action(spy); + return { target: 'a1' }; + }, + states: { + a1: {} + } + } + } + }); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + // TODO: fix (initial actions) + it.skip('should execute actions of the initial transition of a parallel region when the parallel state is targeted with an explicit transition', () => { + const spy = jest.fn(); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + entry: () => { + // ... + }, + type: 'parallel', + states: { + c: { + initial: (_, enq) => { + enq.action(spy); + return { target: 'c1' }; + }, + states: { + c1: {} + } + } + } + } + } + }); + + const actorRef = createActor(machine, { + inspect: (ev) => { + ev; + } + }).start(); + + actorRef.send({ type: 'NEXT' }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + describe('transitions with nested parallel states', () => { + it('should properly transition when in a simple nested state', () => { + const actorRef = createActor(nestedParallelState).start(); + actorRef.send({ + type: 'EVENT_SIMPLE' + }); + actorRef.send({ + type: 'EVENT_STATE_NTJ0_WORK' + }); + + expect(actorRef.getSnapshot().value).toEqual({ + OUTER1: { + STATE_ON: { + STATE_NTJ0: 'STATE_WORKING_0', + STATE_NTJ1: 'STATE_IDLE_1' + } + }, + OUTER2: 'STATE_ON_SIMPLE' + }); + }); + + it('should properly transition when in a complex nested state', () => { + const actorRef = createActor(nestedParallelState).start(); + actorRef.send({ + type: 'EVENT_COMPLEX' + }); + actorRef.send({ + type: 'EVENT_STATE_NTJ0_WORK' + }); + + expect(actorRef.getSnapshot().value).toEqual({ + OUTER1: { + STATE_ON: { + STATE_NTJ0: 'STATE_WORKING_0', + STATE_NTJ1: 'STATE_IDLE_1' + } + }, + OUTER2: { + STATE_ON_COMPLEX: { + STATE_INNER1: 'STATE_OFF', + STATE_INNER2: 'STATE_OFF' + } + } + }); + }); + }); + + // https://github.com/statelyai/xstate/issues/191 + describe('nested flat parallel states', () => { + const machine = createMachine({ + initial: 'A', + states: { + A: { + on: { + 'to-B': 'B' + } + }, + B: { + type: 'parallel', + states: { + C: {}, + D: {} + } + } + }, + on: { + 'to-A': '.A' + } + }); + + it('should represent the flat nested parallel states in the state value', () => { + const actorRef = createActor(machine).start(); + actorRef.send({ + type: 'to-B' + }); + + expect(actorRef.getSnapshot().value).toEqual({ + B: { + C: {}, + D: {} + } + }); + }); + }); + + describe('deep flat parallel states', () => { + it('should properly evaluate deep flat parallel states', () => { + const actorRef = createActor(deepFlatParallelMachine).start(); + + actorRef.send({ type: 'a' }); + actorRef.send({ type: 'c' }); + actorRef.send({ type: 'b' }); + + expect(actorRef.getSnapshot().value).toEqual({ + V: { + B: { + BB: { + BBB_A: {}, + BBB_B: {} + } + } + }, + X: {} + }); + }); + + it('should not overlap resolved state nodes in state resolution', () => { + const machine = createMachine({ + id: 'pipeline', + type: 'parallel', + states: { + foo: { + on: { + UPDATE: () => {} + } + }, + bar: { + on: { + UPDATE: '.baz' + }, + initial: 'idle', + states: { + idle: {}, + baz: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + expect(() => { + actorRef.send({ + type: 'UPDATE' + }); + }).not.toThrow(); + }); + }); + + describe('other', () => { + // https://github.com/statelyai/xstate/issues/518 + it('regions should be able to transition to orthogonal regions', () => { + const testMachine = createMachine({ + type: 'parallel', + states: { + Pages: { + initial: 'About', + states: { + About: { + id: 'About' + }, + Dashboard: { + id: 'Dashboard' + } + } + }, + Menu: { + initial: 'Closed', + states: { + Closed: { + id: 'Closed', + on: { + toggle: '#Opened' + } + }, + Opened: { + id: 'Opened', + on: { + toggle: '#Closed', + 'go to dashboard': { + target: ['#Dashboard', '#Opened'] + } + } + } + } + } + } + }); + + const actorRef = createActor(testMachine).start(); + + actorRef.send({ type: 'toggle' }); + actorRef.send({ type: 'go to dashboard' }); + + expect( + actorRef.getSnapshot().matches({ Menu: 'Opened', Pages: 'Dashboard' }) + ).toBe(true); + }); + + // https://github.com/statelyai/xstate/issues/531 + it('should calculate the entry set for reentering transitions in parallel states', () => { + const testMachine = createMachine({ + types: {} as { context: { log: string[] } }, + id: 'test', + context: { log: [] }, + type: 'parallel', + states: { + foo: { + initial: 'foobar', + states: { + foobar: { + on: { + GOTO_FOOBAZ: 'foobaz' + } + }, + foobaz: { + // entry: assign({ + // log: ({ context }) => [...context.log, 'entered foobaz'] + // }), + entry2: ({ context }) => ({ + context: { + log: [...context.log, 'entered foobaz'] + } + }), + on: { + GOTO_FOOBAZ: { + target: 'foobaz', + reenter: true + } + } + } + } + }, + bar: {} + } + }); + + const actorRef = createActor(testMachine).start(); + + actorRef.send({ + type: 'GOTO_FOOBAZ' + }); + actorRef.send({ + type: 'GOTO_FOOBAZ' + }); + + expect(actorRef.getSnapshot().context.log.length).toBe(2); + }); + }); + + it('should raise a "xstate.done.state.*" event when all child states reach final state', (done) => { + const machine = createMachine({ + id: 'test', + initial: 'p', + states: { + p: { + type: 'parallel', + states: { + a: { + initial: 'idle', + states: { + idle: { + on: { + FINISH: 'finished' + } + }, + finished: { + type: 'final' + } + } + }, + b: { + initial: 'idle', + states: { + idle: { + on: { + FINISH: 'finished' + } + }, + finished: { + type: 'final' + } + } + }, + c: { + initial: 'idle', + states: { + idle: { + on: { + FINISH: 'finished' + } + }, + finished: { + type: 'final' + } + } + } + }, + onDone: 'success' + }, + success: { + type: 'final' + } + } + }); + + const service = createActor(machine); + service.subscribe({ + complete: () => { + done(); + } + }); + service.start(); + + service.send({ type: 'FINISH' }); + }); + + it('should raise a "xstate.done.state.*" event when a pseudostate of a history type is directly on a parallel state', () => { + const machine = createMachine({ + initial: 'parallelSteps', + states: { + parallelSteps: { + type: 'parallel', + states: { + hist: { + type: 'history' + }, + one: { + initial: 'wait_one', + states: { + wait_one: { + on: { + finish_one: { + target: 'done' + } + } + }, + done: { + type: 'final' + } + } + }, + two: { + initial: 'wait_two', + states: { + wait_two: { + on: { + finish_two: { + target: 'done' + } + } + }, + done: { + type: 'final' + } + } + } + }, + onDone: 'finished' + }, + finished: {} + } + }); + + const service = createActor(machine).start(); + + service.send({ type: 'finish_one' }); + service.send({ type: 'finish_two' }); + + expect(service.getSnapshot().value).toBe('finished'); + }); + + it('source parallel region should be reentered when a transition within it targets another parallel region (parallel root)', async () => { + const machine = createMachine({ + type: 'parallel', + states: { + Operation: { + initial: 'Waiting', + states: { + Waiting: { + on: { + TOGGLE_MODE: { + target: '#Demo' + } + } + }, + Fetching: {} + } + }, + Mode: { + initial: 'Normal', + states: { + Normal: {}, + Demo: { + id: 'Demo' + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine); + actor.start(); + flushTracked(); + + actor.send({ type: 'TOGGLE_MODE' }); + + expect(flushTracked()).toEqual([ + 'exit: Mode.Normal', + 'exit: Mode', + 'exit: Operation.Waiting', + 'exit: Operation', + 'enter: Operation', + 'enter: Operation.Waiting', + 'enter: Mode', + 'enter: Mode.Demo' + ]); + }); + + it('source parallel region should be reentered when a transition within it targets another parallel region (nested parallel)', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + type: 'parallel', + states: { + Operation: { + initial: 'Waiting', + states: { + Waiting: { + on: { + TOGGLE_MODE: { + target: '#Demo' + } + } + }, + Fetching: {} + } + }, + Mode: { + initial: 'Normal', + states: { + Normal: {}, + Demo: { + id: 'Demo' + } + } + } + } + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine); + actor.start(); + flushTracked(); + + actor.send({ type: 'TOGGLE_MODE' }); + + expect(flushTracked()).toEqual([ + 'exit: a.Mode.Normal', + 'exit: a.Mode', + 'exit: a.Operation.Waiting', + 'exit: a.Operation', + 'enter: a.Operation', + 'enter: a.Operation.Waiting', + 'enter: a.Mode', + 'enter: a.Mode.Demo' + ]); + }); + + it('targetless transition on a parallel state should not enter nor exit any states', () => { + const machine = createMachine({ + id: 'test', + type: 'parallel', + states: { + first: { + initial: 'disabled', + states: { + disabled: {}, + enabled: {} + } + }, + second: {} + }, + on: { + MY_EVENT: (_, enq) => { + enq.action(() => {}); + } + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine); + actor.start(); + flushTracked(); + + actor.send({ type: 'MY_EVENT' }); + + expect(flushTracked()).toEqual([]); + }); + + it('targetless transition in one of the parallel regions should not enter nor exit any states', () => { + const machine = createMachine({ + id: 'test', + type: 'parallel', + states: { + first: { + initial: 'disabled', + states: { + disabled: {}, + enabled: {} + }, + on: { + MY_EVENT: (_, enq) => { + enq.action(() => {}); + } + } + }, + second: {} + } + }); + + const flushTracked = trackEntries(machine); + + const actor = createActor(machine); + actor.start(); + flushTracked(); + + actor.send({ type: 'MY_EVENT' }); + + expect(flushTracked()).toEqual([]); + }); +}); diff --git a/packages/core/test/predictableExec.v6.test.ts b/packages/core/test/predictableExec.v6.test.ts new file mode 100644 index 0000000000..e2804def62 --- /dev/null +++ b/packages/core/test/predictableExec.v6.test.ts @@ -0,0 +1,576 @@ +import { + AnyActor, + assign, + createMachine, + createActor, + sendTo, + waitFor +} from '../src/index.ts'; +import { raise, sendParent, stopChild } from '../src/actions.ts'; +import { fromCallback } from '../src/actors/index.ts'; +import { fromPromise } from '../src/actors/index.ts'; + +describe('predictableExec', () => { + it('should call mixed custom and builtin actions in the definitions order', () => { + const actual: string[] = []; + + const machine = createMachine({ + initial: 'a', + context: {}, + states: { + a: { + on: { NEXT: 'b' } + }, + b: { + entry2: (_, enq) => { + enq.action(() => actual.push('custom')); + enq.action(() => actual.push('assign')); + } + } + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + + expect(actual).toEqual(['custom', 'assign']); + }); + + it('should call initial custom actions when starting a service', () => { + let called = false; + const machine = createMachine({ + entry2: (_, enq) => { + enq.action(() => { + called = true; + }); + } + }); + + expect(called).toBe(false); + + createActor(machine).start(); + + expect(called).toBe(true); + }); + + it('should resolve initial assign actions before starting a service', () => { + const machine = createMachine({ + context: { + called: false + }, + entry2: () => ({ + context: { + called: true + } + }) + }); + + expect(createActor(machine).getSnapshot().context.called).toBe(true); + }); + + it('should call raised transition custom actions with raised event', () => { + let eventArg: any; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + on: { + RAISED: ({ event }, enq) => { + enq.action(() => (eventArg = event)); + return { target: 'c' }; + } + }, + entry2: (_, enq) => { + enq.raise({ type: 'RAISED' }); + } + }, + c: {} + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + + expect(eventArg.type).toBe('RAISED'); + }); + + it('should call raised transition builtin actions with raised event', () => { + let eventArg: any; + const machine = createMachine({ + context: {}, + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + on: { + RAISED: ({ event }, enq) => { + enq.action(() => (eventArg = event)); + return { target: 'c' }; + } + }, + entry2: (_, enq) => { + enq.raise({ type: 'RAISED' }); + } + }, + c: {} + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + + expect(eventArg.type).toBe('RAISED'); + }); + + it('should call invoke creator with raised event', () => { + let eventArg: any; + const machine = createMachine({ + context: {}, + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + on: { + RAISED: 'c' + }, + entry2: (_, enq) => { + enq.raise({ type: 'RAISED' }); + } + }, + c: { + invoke: { + src: fromCallback(({ input }) => { + eventArg = input.event; + }), + input: ({ event }: any) => ({ event }) + } + } + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + + expect(eventArg.type).toBe('RAISED'); + }); + + it('invoked child should be available on the new state', () => { + const machine = createMachine({ + context: {}, + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + invoke: { + id: 'myChild', + src: fromCallback(() => {}) + } + } + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + + expect(service.getSnapshot().children.myChild).toBeDefined(); + }); + + it('invoked child should not be available on the state after leaving invoking state', () => { + const machine = createMachine({ + context: {}, + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + invoke: { + id: 'myChild', + src: fromCallback(() => {}) + }, + on: { + NEXT: 'c' + } + }, + c: {} + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + service.send({ type: 'NEXT' }); + + expect(service.getSnapshot().children.myChild).not.toBeDefined(); + }); + + it('should correctly provide intermediate context value to a custom action executed in between assign actions', () => { + let calledWith = 0; + const machine = createMachine({ + context: { + counter: 0 + }, + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + entry2: (_, enq) => { + const context1 = { counter: 1 }; + enq.action(() => { + calledWith = context1.counter; + }); + return { + context: { + counter: 2 + } + }; + } + } + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + + expect(calledWith).toBe(1); + }); + + it('initial actions should receive context updated only by preceding assign actions', () => { + const actual: number[] = []; + + const machine = createMachine({ + context: { count: 0 }, + entry2: ({ context }, enq) => { + const count0 = context.count; + enq.action(() => actual.push(count0)); + const count1 = count0 + 1; + enq.action(() => actual.push(count1)); + const count2 = count1 + 1; + enq.action(() => actual.push(count2)); + return { + context: { + count: count2 + } + }; + } + }); + + createActor(machine).start(); + + expect(actual).toEqual([0, 1, 2]); + }); + + it('parent should be able to read the updated state of a child when receiving an event from it', (done) => { + const child = createMachine({ + initial: 'a', + states: { + a: { + // we need to clear the call stack before we send the event to the parent + after: { + 1: 'b' + } + }, + b: { + entry: sendParent({ type: 'CHILD_UPDATED' }) + } + } + }); + + let service: AnyActor; + + const machine = createMachine({ + invoke: { + id: 'myChild', + src: child + }, + initial: 'initial', + states: { + initial: { + on: { + CHILD_UPDATED: ({ children }) => { + if (children.myChild?.getSnapshot().value === 'b') { + return { target: 'success' }; + } + return { target: 'fail' }; + } + } + }, + success: { + type: 'final' + }, + fail: { + type: 'final' + } + } + }); + + service = createActor(machine); + service.subscribe({ + complete: () => { + expect(service.getSnapshot().value).toBe('success'); + done(); + } + }); + service.start(); + }); + + it('should be possible to send immediate events to initially invoked actors', () => { + const child = createMachine({ + on: { + PING: ({ parent }) => { + parent?.send({ type: 'PONG' }); + } + } + }); + + const machine = createMachine({ + initial: 'waiting', + states: { + waiting: { + invoke: { + id: 'ponger', + src: child + }, + entry2: ({ children }) => { + children.ponger?.send({ type: 'PING' }); + }, + on: { + PONG: 'done' + } + }, + done: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + expect(service.getSnapshot().value).toBe('done'); + }); + + it.skip('should create invoke based on context updated by entry actions of the same state', (done) => { + const machine = createMachine({ + context: { + updated: false + }, + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + entry2: () => ({ + context: { + updated: true + } + }), + invoke: { + src: fromPromise(({ input }) => { + expect(input.updated).toBe(true); + done(); + return Promise.resolve(); + }), + input: ({ context }: any) => ({ + updated: context.updated + }) + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'NEXT' }); + }); + + it('should deliver events sent from the entry actions to a service invoked in the same state', () => { + let received: any; + + const machine = createMachine({ + context: { + updated: false + }, + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + entry2: ({ children }) => { + children.myChild?.send({ type: 'KNOCK_KNOCK' }); + }, + invoke: { + id: 'myChild', + src: createMachine({ + on: { + '*': { + actions: ({ event }) => { + received = event; + } + } + } + }) + } + } + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'NEXT' }); + + expect(received).toEqual({ type: 'KNOCK_KNOCK' }); + }); + + it('parent should be able to read the updated state of a child when receiving an event from it', (done) => { + const child = createMachine({ + initial: 'a', + states: { + a: { + // we need to clear the call stack before we send the event to the parent + after: { + 1: 'b' + } + }, + b: { + entry2: ({ parent }, enq) => { + // TODO: this should be deferred + setTimeout(() => { + parent?.send({ type: 'CHILD_UPDATED' }); + }, 1); + } + } + } + }); + + let service: AnyActor; + + const machine = createMachine({ + invoke: { + id: 'myChild', + src: child + }, + initial: 'initial', + states: { + initial: { + on: { + CHILD_UPDATED: ({ children }) => { + if (children.myChild?.getSnapshot().value === 'b') { + return { target: 'success' }; + } + return { target: 'fail' }; + } + } + }, + success: { + type: 'final' + }, + fail: { + type: 'final' + } + } + }); + + service = createActor(machine); + service.subscribe({ + complete: () => { + expect(service.getSnapshot().value).toBe('success'); + done(); + } + }); + service.start(); + }); + + it('should be possible to send immediate events to initially invoked actors', async () => { + const child = createMachine({ + on: { + PING: ({ parent }) => { + parent?.send({ type: 'PONG' }); + } + } + }); + + const machine = createMachine({ + initial: 'waiting', + states: { + waiting: { + invoke: { + id: 'ponger', + src: child + }, + entry2: ({ children }) => { + // TODO: this should be deferred + setTimeout(() => { + children.ponger?.send({ type: 'PING' }); + }, 1); + }, + on: { + PONG: 'done' + } + }, + done: { + type: 'final' + } + } + }); + + const service = createActor(machine).start(); + + await waitFor(service, (state) => state.matches('done')); + }); + + // https://github.com/statelyai/xstate/issues/3617 + it('should deliver events sent from the exit actions to a service invoked in the same state', (done) => { + const machine = createMachine({ + initial: 'active', + states: { + active: { + invoke: { + id: 'my-service', + src: fromCallback(({ receive }) => { + receive((event) => { + if (event.type === 'MY_EVENT') { + done(); + } + }); + }) + }, + exit: sendTo('my-service', { type: 'MY_EVENT' }), + on: { + TOGGLE: 'inactive' + } + }, + inactive: {} + } + }); + + const actor = createActor(machine).start(); + + actor.send({ type: 'TOGGLE' }); + }); +}); diff --git a/packages/core/test/rehydration.v6.test.ts b/packages/core/test/rehydration.v6.test.ts new file mode 100644 index 0000000000..9deb85a013 --- /dev/null +++ b/packages/core/test/rehydration.v6.test.ts @@ -0,0 +1,538 @@ +import { BehaviorSubject } from 'rxjs'; +import { + createMachine, + createActor, + fromPromise, + fromObservable +} from '../src/index.ts'; +import { sleep } from '@xstate-repo/jest-utils'; + +describe('rehydration', () => { + describe('using persisted state', () => { + it('should be able to use `hasTag` immediately', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + tags: 'foo' + } + } + }); + + const actorRef = createActor(machine).start(); + const persistedState = JSON.stringify(actorRef.getPersistedSnapshot()); + actorRef.stop(); + + const service = createActor(machine, { + snapshot: JSON.parse(persistedState) + }).start(); + + expect(service.getSnapshot().hasTag('foo')).toBe(true); + }); + + it('should not call exit actions when machine gets stopped immediately', () => { + const actual: string[] = []; + const machine = createMachine({ + exit2: (_, enq) => { + enq.action(() => actual.push('root')); + }, + initial: 'a', + states: { + a: { + exit2: (_, enq) => { + enq.action(() => actual.push('a')); + } + } + } + }); + + const actorRef = createActor(machine).start(); + const persistedState = JSON.stringify(actorRef.getPersistedSnapshot()); + actorRef.stop(); + + createActor(machine, { snapshot: JSON.parse(persistedState) }) + .start() + .stop(); + + expect(actual).toEqual([]); + }); + + it('should get correct result back from `can` immediately', () => { + const machine = createMachine({ + on: { + FOO: (_, enq) => { + enq.action(() => {}); + } + } + }); + + const persistedState = JSON.stringify( + createActor(machine).start().getSnapshot() + ); + const restoredState = JSON.parse(persistedState); + const service = createActor(machine, { + snapshot: restoredState + }).start(); + + expect(service.getSnapshot().can({ type: 'FOO' })).toBe(true); + }); + }); + + describe('using state value', () => { + it('should be able to use `hasTag` immediately', () => { + const machine = createMachine({ + initial: 'inactive', + states: { + inactive: { + on: { NEXT: 'active' } + }, + active: { + tags: 'foo' + } + } + }); + + const activeState = machine.resolveState({ value: 'active' }); + const service = createActor(machine, { + snapshot: activeState + }); + + service.start(); + + expect(service.getSnapshot().hasTag('foo')).toBe(true); + }); + + it('should not call exit actions when machine gets stopped immediately', () => { + const actual: string[] = []; + const machine = createMachine({ + exit2: (_, enq) => { + enq.action(() => actual.push('root')); + }, + initial: 'inactive', + states: { + inactive: { + on: { NEXT: 'active' } + }, + active: { + exit2: (_, enq) => { + enq.action(() => actual.push('active')); + } + } + } + }); + + createActor(machine, { + snapshot: machine.resolveState({ value: 'active' }) + }) + .start() + .stop(); + + expect(actual).toEqual([]); + }); + + it('should error on incompatible state value (shallow)', () => { + const machine = createMachine({ + initial: 'valid', + states: { + valid: {} + } + }); + + expect(() => { + machine.resolveState({ value: 'invalid' }); + }).toThrowError(/invalid/); + }); + + it('should error on incompatible state value (deep)', () => { + const machine = createMachine({ + initial: 'parent', + states: { + parent: { + initial: 'valid', + states: { + valid: {} + } + } + } + }); + + expect(() => { + machine.resolveState({ value: { parent: 'invalid' } }); + }).toThrow(/invalid/); + }); + }); + + it('should not replay actions when starting from a persisted state', () => { + const entrySpy = jest.fn(); + const machine = createMachine({ + entry2: (_, enq) => { + enq.action(entrySpy); + } + }); + + const actor = createActor(machine).start(); + + expect(entrySpy).toHaveBeenCalledTimes(1); + + const persistedState = actor.getPersistedSnapshot(); + + actor.stop(); + + createActor(machine, { snapshot: persistedState }).start(); + + expect(entrySpy).toHaveBeenCalledTimes(1); + }); + + it('should be able to stop a rehydrated child', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + invoke: { + src: fromPromise(() => Promise.resolve(11)), + onDone: 'b' + }, + on: { + NEXT: 'c' + } + }, + b: {}, + c: {} + } + }); + + const actor = createActor(machine).start(); + const persistedState = actor.getPersistedSnapshot(); + actor.stop(); + + const rehydratedActor = createActor(machine, { + snapshot: persistedState + }).start(); + + expect(() => + rehydratedActor.send({ + type: 'NEXT' + }) + ).not.toThrow(); + + expect(rehydratedActor.getSnapshot().value).toBe('c'); + }); + + it('a rehydrated active child should be registered in the system', () => { + const machine = createMachine( + { + context: ({ spawn }) => { + spawn('foo', { + systemId: 'mySystemId' + }); + return {}; + } + }, + { + actors: { + foo: createMachine({}) + } + } + ); + + const actor = createActor(machine).start(); + const persistedState = actor.getPersistedSnapshot(); + actor.stop(); + + const rehydratedActor = createActor(machine, { + snapshot: persistedState + }).start(); + + expect(rehydratedActor.system.get('mySystemId')).not.toBeUndefined(); + }); + + it('a rehydrated done child should not be registered in the system', () => { + const machine = createMachine( + { + context: ({ spawn }) => { + spawn('foo', { + systemId: 'mySystemId' + }); + return {}; + } + }, + { + actors: { + foo: createMachine({ type: 'final' }) + } + } + ); + + const actor = createActor(machine).start(); + const persistedState = actor.getPersistedSnapshot(); + actor.stop(); + + const rehydratedActor = createActor(machine, { + snapshot: persistedState + }).start(); + + expect(rehydratedActor.system.get('mySystemId')).toBeUndefined(); + }); + + it('a rehydrated done child should not re-notify the parent about its completion', () => { + const spy = jest.fn(); + + const machine = createMachine( + { + context: ({ spawn }) => { + spawn('foo', { + systemId: 'mySystemId' + }); + return {}; + }, + on: { + '*': (_, enq) => { + enq.action(spy); + } + } + }, + { + actors: { + foo: createMachine({ type: 'final' }) + } + } + ); + + const actor = createActor(machine).start(); + const persistedState = actor.getPersistedSnapshot(); + actor.stop(); + + spy.mockClear(); + + createActor(machine, { + snapshot: persistedState + }).start(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should be possible to persist a rehydrated actor that got its children rehydrated', () => { + const machine = createMachine( + { + invoke: { + src: 'foo' + } + }, + { + actors: { + foo: fromPromise(() => Promise.resolve(42)) + } + } + ); + + const actor = createActor(machine).start(); + + const rehydratedActor = createActor(machine, { + snapshot: actor.getPersistedSnapshot() + }).start(); + + const persistedChildren = (rehydratedActor.getPersistedSnapshot() as any) + .children; + expect(Object.keys(persistedChildren).length).toBe(1); + expect((Object.values(persistedChildren)[0] as any).src).toBe('foo'); + }); + + it('should complete on a rehydrated final state', () => { + const machine = createMachine({ + initial: 'foo', + states: { + foo: { + on: { NEXT: 'bar' } + }, + bar: { + type: 'final' + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'NEXT' }); + const persistedState = actorRef.getPersistedSnapshot(); + + const spy = jest.fn(); + const actorRef2 = createActor(machine, { snapshot: persistedState }); + actorRef2.subscribe({ + complete: spy + }); + + actorRef2.start(); + expect(spy).toHaveBeenCalled(); + }); + + it('should error on a rehydrated error state', async () => { + const machine = createMachine( + { + invoke: { + src: 'failure' + } + }, + { + actors: { + failure: fromPromise(() => Promise.reject(new Error('failure'))) + } + } + ); + + const actorRef = createActor(machine); + actorRef.subscribe({ error: function preventUnhandledErrorListener() {} }); + actorRef.start(); + + // wait a macrotask for the microtask related to the promise to be processed + await sleep(0); + + const persistedState = actorRef.getPersistedSnapshot(); + + const spy = jest.fn(); + const actorRef2 = createActor(machine, { snapshot: persistedState }); + actorRef2.subscribe({ + error: spy + }); + actorRef2.start(); + + expect(spy).toHaveBeenCalled(); + }); + + it(`shouldn't re-notify the parent about the error when rehydrating`, async () => { + const spy = jest.fn(); + + const machine = createMachine( + { + invoke: { + src: 'failure', + onError: (_, enq) => { + enq.action(spy); + } + } + }, + { + actors: { + failure: fromPromise(() => Promise.reject(new Error('failure'))) + } + } + ); + + const actorRef = createActor(machine); + actorRef.start(); + + // wait a macrotask for the microtask related to the promise to be processed + await sleep(0); + + const persistedState = actorRef.getPersistedSnapshot(); + spy.mockClear(); + + const actorRef2 = createActor(machine, { snapshot: persistedState }); + actorRef2.start(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should continue syncing snapshots', () => { + const subject = new BehaviorSubject(0); + const subjectLogic = fromObservable(() => subject); + + const spy = jest.fn(); + + const machine = createMachine( + { + types: {} as { + actors: { + src: 'service'; + logic: typeof subjectLogic; + }; + }, + + invoke: { + src: 'service', + onSnapshot: ({ event }, enq) => { + enq.action(() => spy(event.snapshot.context)); + } + } + }, + { + actors: { + service: subjectLogic + } + } + ); + + createActor(machine, { + snapshot: createActor(machine).getPersistedSnapshot() + }).start(); + + spy.mockClear(); + + subject.next(42); + subject.next(100); + + expect(spy.mock.calls).toEqual([[42], [100]]); + }); + + it('should be able to rehydrate an actor deep in the tree', () => { + const grandchild = createMachine({ + context: { + count: 0 + }, + on: { + INC: ({ context }) => ({ + context: { + count: context.count + 1 + } + }) + } + }); + const child = createMachine( + { + invoke: { + src: 'grandchild', + id: 'grandchild' + }, + on: { + INC: ({ children }) => { + children.grandchild?.send({ type: 'INC' }); + } + } + }, + { + actors: { + grandchild + } + } + ); + const machine = createMachine( + { + invoke: { + src: 'child', + id: 'child' + }, + on: { + INC: ({ children }) => { + children.child?.send({ type: 'INC' }); + } + } + }, + { + actors: { + child + } + } + ); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'INC' }); + + const persistedState = actorRef.getPersistedSnapshot(); + const actorRef2 = createActor(machine, { snapshot: persistedState }); + + expect( + actorRef2 + .getSnapshot() + .children.child.getSnapshot() + .children.grandchild.getSnapshot().context.count + ).toBe(1); + }); +}); diff --git a/packages/core/test/resolve.v6.test.ts b/packages/core/test/resolve.v6.test.ts new file mode 100644 index 0000000000..5043cc3be8 --- /dev/null +++ b/packages/core/test/resolve.v6.test.ts @@ -0,0 +1,73 @@ +import { createMachine } from '../src/index'; +import { resolveStateValue } from '../src/stateUtils'; + +// from parallel/test3.scxml +const flatParallelMachine = createMachine({ + id: 'fp', + initial: 'p1', + states: { + p1: { + type: 'parallel', + states: { + s1: { + initial: 'p2', + states: { + p2: { + type: 'parallel', + states: { + s3: { + initial: 's3.1', + states: { + 's3.1': {}, + 's3.2': {} + } + }, + s4: {} + } + }, + p3: { + type: 'parallel', + states: { + s5: {}, + s6: {} + } + } + } + }, + s2: { + initial: 'p4', + states: { + p4: { + type: 'parallel', + states: { + s7: {}, + s8: {} + } + }, + p5: { + type: 'parallel', + states: { + s9: {}, + s10: {} + } + } + } + } + } + } + } +}); + +describe('resolve()', () => { + it('should resolve parallel states with flat child states', () => { + const unresolvedStateValue = { p1: { s1: { p2: 's4' }, s2: { p4: 's8' } } }; + + const resolvedStateValue = resolveStateValue( + flatParallelMachine.root, + unresolvedStateValue + ); + expect(resolvedStateValue).toEqual({ + p1: { s1: { p2: { s3: 's3.1', s4: {} } }, s2: { p4: { s7: {}, s8: {} } } } + }); + }); +}); diff --git a/packages/core/test/spawn.v6.test.ts b/packages/core/test/spawn.v6.test.ts new file mode 100644 index 0000000000..af98ed3c70 --- /dev/null +++ b/packages/core/test/spawn.v6.test.ts @@ -0,0 +1,18 @@ +import { ActorRefFrom, createActor, createMachine } from '../src'; + +describe('spawn inside machine', () => { + it('input is required when defined in actor', () => { + const childMachine = createMachine({ + types: { input: {} as { value: number } } + }); + const machine = createMachine({ + types: {} as { context: { ref: ActorRefFrom } }, + context: ({ spawn }) => ({ + ref: spawn(childMachine, { input: { value: 42 }, systemId: 'test' }) + }) + }); + + const actor = createActor(machine).start(); + expect(actor.system.get('test')).toBeDefined(); + }); +}); diff --git a/packages/core/test/spawnChild.v6.test.ts b/packages/core/test/spawnChild.v6.test.ts new file mode 100644 index 0000000000..329eb97ac5 --- /dev/null +++ b/packages/core/test/spawnChild.v6.test.ts @@ -0,0 +1,127 @@ +import { interval } from 'rxjs'; +import { + ActorRefFrom, + createActor, + createMachine, + fromObservable, + fromPromise +} from '../src'; + +describe('spawnChild action', () => { + it('can spawn', () => { + const actor = createActor( + createMachine({ + entry2: (_, enq) => { + enq.spawn( + fromPromise(() => Promise.resolve(42)), + { id: 'child' } + ); + } + }) + ); + + actor.start(); + + expect(actor.getSnapshot().children.child).toBeDefined(); + }); + + it('can spawn from named actor', () => { + const fetchNum = fromPromise(({ input }: { input: number }) => + Promise.resolve(input * 2) + ); + const actor = createActor( + createMachine({ + types: { + actors: {} as { + src: 'fetchNum'; + logic: typeof fetchNum; + } + }, + entry2: (_, enq) => { + enq.spawn(fetchNum, { id: 'child', input: 21 }); + } + }).provide({ + actors: { fetchNum } + }) + ); + + actor.start(); + + expect(actor.getSnapshot().children.child).toBeDefined(); + }); + + it('should accept `syncSnapshot` option', (done) => { + const observableLogic = fromObservable(() => interval(10)); + const observableMachine = createMachine({ + id: 'observable', + initial: 'idle', + context: { + observableRef: undefined! as ActorRefFrom + }, + states: { + idle: { + entry2: (_, enq) => { + enq.spawn(observableLogic, { + id: 'int', + syncSnapshot: true + }); + }, + on: { + 'xstate.snapshot.int': { + target: 'success', + guard: ({ event }) => event.snapshot.context === 5 + } + } + }, + success: { + type: 'final' + } + } + }); + + const observableService = createActor(observableMachine); + observableService.subscribe({ + complete: () => { + done(); + } + }); + + observableService.start(); + }); + + it('should handle a dynamic id', () => { + const spy = jest.fn(); + + const childMachine = createMachine({ + on: { + FOO: (_, enq) => { + enq.action(spy); + } + } + }); + + const machine = createMachine({ + context: { + childId: 'myChild' + }, + entry2: ({ context, self }, enq) => { + // TODO: This should all be abstracted in enq.spawn(…) + const child = createActor(childMachine, { + id: context.childId, + parent: self + }); + enq.action(() => { + child.start(); + }); + + enq.sendTo(child, { + type: 'FOO' + }); + } + }); + + createActor(machine).start(); + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/test/state.v6.test.ts b/packages/core/test/state.v6.test.ts new file mode 100644 index 0000000000..402e9e2b12 --- /dev/null +++ b/packages/core/test/state.v6.test.ts @@ -0,0 +1,508 @@ +import { createMachine, createActor } from '../src/index'; +import { assign } from '../src/actions/assign'; +import { fromCallback } from '../src/actors/callback'; + +type Events = + | { type: 'BAR_EVENT' } + | { type: 'DEEP_EVENT' } + | { type: 'EXTERNAL' } + | { type: 'FOO_EVENT' } + | { type: 'FORBIDDEN_EVENT' } + | { type: 'INERT' } + | { type: 'INTERNAL' } + | { type: 'MACHINE_EVENT' } + | { type: 'P31' } + | { type: 'P32' } + | { type: 'THREE_EVENT' } + | { type: 'TO_THREE' } + | { type: 'TO_TWO'; foo: string } + | { type: 'TO_TWO_MAYBE' } + | { type: 'TO_FINAL' }; + +const exampleMachine = createMachine({ + types: {} as { + events: Events; + }, + initial: 'one', + states: { + one: { + on: { + EXTERNAL: { + target: 'one', + reenter: true + }, + INERT: {}, + INTERNAL: { + // actions: ['doSomething'] + }, + TO_TWO: 'two', + TO_TWO_MAYBE: () => { + if (true) { + return { target: 'two' }; + } + }, + TO_THREE: 'three', + FORBIDDEN_EVENT: undefined, + TO_FINAL: 'success' + } + }, + two: { + initial: 'deep', + states: { + deep: { + initial: 'foo', + states: { + foo: { + on: { + FOO_EVENT: 'bar', + FORBIDDEN_EVENT: undefined + } + }, + bar: { + on: { + BAR_EVENT: 'foo' + } + } + } + } + }, + on: { + DEEP_EVENT: '.' + } + }, + three: { + type: 'parallel', + states: { + first: { + initial: 'p31', + states: { + p31: { + on: { P31: '.' } + } + } + }, + guarded: { + initial: 'p32', + states: { + p32: { + on: { P32: '.' } + } + } + } + }, + on: { + THREE_EVENT: '.' + } + }, + success: { + type: 'final' + } + }, + on: { + MACHINE_EVENT: '.two' + } +}); + +describe('State', () => { + describe('status', () => { + it('should show that a machine has not reached its final state', () => { + expect(createActor(exampleMachine).getSnapshot().status).not.toBe('done'); + }); + + it('should show that a machine has reached its final state', () => { + const actorRef = createActor(exampleMachine).start(); + actorRef.send({ type: 'TO_FINAL' }); + expect(actorRef.getSnapshot().status).toBe('done'); + }); + }); + + describe('.can', () => { + it('should return true for a simple event that results in a transition to a different state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'NEXT' })).toBe( + true + ); + }); + + it('should return true for an event object that results in a transition to a different state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'NEXT' })).toBe( + true + ); + }); + + it('should return true for an event object that results in a new action', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: { + actions: 'newAction' + } + } + } + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'NEXT' })).toBe( + true + ); + }); + + it('should return true for an event object that results in a context change', () => { + const machine = createMachine({ + initial: 'a', + context: { count: 0 }, + states: { + a: { + on: { + NEXT: () => { + return { + context: { + count: 1 + } + }; + } + } + } + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'NEXT' })).toBe( + true + ); + }); + + it('should return true for a reentering self-transition without actions', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: 'a' + } + } + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'EV' })).toBe(true); + }); + + it('should return true for a reentering self-transition with reentry action', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry: () => {}, + on: { + EV: 'a' + } + } + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'EV' })).toBe(true); + }); + + it('should return true for a reentering self-transition with transition action', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: (_, enq) => { + enq.action(() => {}); + return { target: 'a' }; + } + } + } + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'EV' })).toBe(true); + }); + + it('should return true for a targetless transition with actions', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: (_, enq) => { + enq.action(() => {}); + } + } + } + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'EV' })).toBe(true); + }); + + it('should return false for a forbidden transition', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EV: undefined + } + } + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'EV' })).toBe( + false + ); + }); + + it('should return false for an unknown event', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + expect(createActor(machine).getSnapshot().can({ type: 'UNKNOWN' })).toBe( + false + ); + }); + + it('should return true when a guarded transition allows the transition', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + CHECK: () => { + if (true) { + return { target: 'b' }; + } + } + } + }, + b: {} + } + }); + + expect( + createActor(machine).getSnapshot().can({ + type: 'CHECK' + }) + ).toBe(true); + }); + + it('should return false when a guarded transition disallows the transition', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + CHECK: () => { + if (1 + 1 !== 2) { + return { target: 'b' }; + } + } + } + }, + b: {} + } + }); + + expect( + createActor(machine).getSnapshot().can({ + type: 'CHECK' + }) + ).toBe(false); + }); + + it('should not spawn actors when determining if an event is accepted', () => { + let spawned = false; + const machine = createMachine({ + context: {}, + initial: 'a', + states: { + a: { + on: { + SPAWN: (_, enq) => { + return { + context: { + ref: enq.spawn( + fromCallback(() => { + spawned = true; + }) + ) + } + }; + } + } + }, + b: {} + } + }); + + const service = createActor(machine).start(); + service.getSnapshot().can({ type: 'SPAWN' }); + expect(spawned).toBe(false); + }); + + it('should not execute assignments when used with non-started actor', () => { + let executed = false; + const machine = createMachine({ + context: {}, + on: { + EVENT: (_, enq) => { + enq.action(() => (executed = true)); + } + } + }); + + const actorRef = createActor(machine); + + expect(actorRef.getSnapshot().can({ type: 'EVENT' })).toBeTruthy(); + + expect(executed).toBeFalsy(); + }); + + it('should not execute assignments when used with started actor', () => { + let executed = false; + const machine = createMachine({ + context: {}, + on: { + EVENT: (_, enq) => { + enq.action(() => (executed = true)); + } + } + }); + + const actorRef = createActor(machine).start(); + + expect(actorRef.getSnapshot().can({ type: 'EVENT' })).toBeTruthy(); + + expect(executed).toBeFalsy(); + }); + + it('should return true when non-first parallel region changes value', () => { + const machine = createMachine({ + type: 'parallel', + states: { + a: { + initial: 'a1', + states: { + a1: { + id: 'foo', + on: { + // first region doesn't change value here + EVENT: { target: ['#foo', '#bar'] } + } + } + } + }, + b: { + initial: 'b1', + states: { + b1: {}, + b2: { + id: 'bar' + } + } + } + } + }); + + expect( + createActor(machine).getSnapshot().can({ type: 'EVENT' }) + ).toBeTruthy(); + }); + + it('should return true when transition targets a state that is already part of the current configuration but the final state value changes', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + id: 'foo', + initial: 'a1', + states: { + a1: { + on: { + NEXT: 'a2' + } + }, + a2: { + on: { + NEXT: '#foo' + } + } + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'NEXT' }); + + expect(actorRef.getSnapshot().can({ type: 'NEXT' })).toBeTruthy(); + }); + }); + + describe('.hasTag', () => { + it('should be able to check a tag after recreating a persisted state', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + tags: 'foo' + } + } + }); + + const actorRef = createActor(machine).start(); + const persistedState = actorRef.getPersistedSnapshot(); + actorRef.stop(); + const restoredSnapshot = createActor(machine, { + snapshot: persistedState + }).getSnapshot(); + + expect(restoredSnapshot.hasTag('foo')).toBe(true); + }); + }); + + describe('.status', () => { + it("should be 'stopped' after a running actor gets stopped", () => { + const snapshot = createActor(createMachine({})) + .start() + .stop() + .getSnapshot(); + expect(snapshot.status).toBe('stopped'); + }); + }); +}); diff --git a/packages/core/test/transient.v6.test.ts b/packages/core/test/transient.v6.test.ts new file mode 100644 index 0000000000..7e5ce70278 --- /dev/null +++ b/packages/core/test/transient.v6.test.ts @@ -0,0 +1,757 @@ +import { createMachine, createActor, matchesState } from '../src/index'; + +const greetingContext = { hour: 10 }; +const greetingMachine = createMachine({ + types: {} as { context: typeof greetingContext }, + id: 'greeting', + initial: 'pending', + context: greetingContext, + states: { + pending: { + always: ({ context }) => { + if (context.hour < 12) { + return { target: 'morning' }; + } else if (context.hour < 18) { + return { target: 'afternoon' }; + } else { + return { target: 'evening' }; + } + } + }, + morning: {}, + afternoon: {}, + evening: {} + }, + on: { + CHANGE: () => ({ + context: { + hour: 20 + } + }), + RECHECK: '#greeting' + } +}); + +describe('transient states (eventless transitions)', () => { + it('should choose the first candidate target that matches the guard 1', () => { + const machine = createMachine({ + types: {} as { context: { data: boolean } }, + context: { data: false }, + initial: 'G', + states: { + G: { + on: { UPDATE_BUTTON_CLICKED: 'E' } + }, + E: { + always: ({ context }) => { + if (!context.data) { + return { target: 'D' }; + } else { + return { target: 'F' }; + } + } + }, + D: {}, + F: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'UPDATE_BUTTON_CLICKED' }); + + expect(actorRef.getSnapshot().value).toEqual('D'); + }); + + it('should choose the first candidate target that matches the guard 2', () => { + const machine = createMachine({ + types: {} as { context: { data: boolean; status?: string } }, + context: { data: false }, + initial: 'G', + states: { + G: { + on: { UPDATE_BUTTON_CLICKED: 'E' } + }, + E: { + always: ({ context }) => { + if (!context.data) { + return { target: 'D' }; + } else { + return { target: 'F' }; + } + } + }, + D: {}, + F: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'UPDATE_BUTTON_CLICKED' }); + + expect(actorRef.getSnapshot().value).toEqual('D'); + }); + + it('should choose the final candidate without a guard if none others match', () => { + const machine = createMachine({ + types: {} as { context: { data: boolean; status?: string } }, + context: { data: true }, + initial: 'G', + states: { + G: { + on: { UPDATE_BUTTON_CLICKED: 'E' } + }, + E: { + always: ({ context }) => { + if (!context.data) { + return { target: 'D' }; + } else { + return { target: 'F' }; + } + } + }, + D: {}, + F: {} + } + }); + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'UPDATE_BUTTON_CLICKED' }); + + expect(actorRef.getSnapshot().value).toEqual('F'); + }); + + it('should carry actions from previous transitions within same step', () => { + const actual: string[] = []; + const machine = createMachine({ + initial: 'A', + states: { + A: { + exit2: (_, enq) => { + enq.action(() => void actual.push('exit_A')); + }, + on: { + TIMER: (_, enq) => { + enq.action(() => void actual.push('timer')); + return { target: 'T' }; + } + } + }, + T: { + always: { target: 'B' } + }, + B: { + entry2: (_, enq) => { + enq.action(() => void actual.push('enter_B')); + } + } + } + }); + + const actor = createActor(machine).start(); + + actor.send({ type: 'TIMER' }); + + expect(actual).toEqual(['exit_A', 'timer', 'enter_B']); + }); + + it('should execute all internal events one after the other', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A1', + states: { + A1: { + on: { + E: 'A2' + } + }, + A2: { + entry2: (_, enq) => { + enq.raise({ type: 'INT1' }); + } + } + } + }, + + B: { + initial: 'B1', + states: { + B1: { + on: { + E: 'B2' + } + }, + B2: { + entry2: (_, enq) => { + enq.raise({ type: 'INT2' }); + } + } + } + }, + + C: { + initial: 'C1', + states: { + C1: { + on: { + INT1: 'C2', + INT2: 'C3' + } + }, + C2: { + on: { + INT2: 'C4' + } + }, + C3: { + on: { + INT1: 'C4' + } + }, + C4: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'E' }); + + expect(actorRef.getSnapshot().value).toEqual({ A: 'A2', B: 'B2', C: 'C4' }); + }); + + it('should execute all eventless transitions in the same microstep', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A1', + states: { + A1: { + on: { + E: 'A2' // the external event + } + }, + A2: { + always: 'A3' + }, + A3: { + always: ({ value }) => { + if (matchesState({ B: 'B3' }, value)) { + return { target: 'A4' }; + } + } + }, + A4: {} + } + }, + + B: { + initial: 'B1', + states: { + B1: { + on: { + E: 'B2' + } + }, + B2: { + always: ({ value }) => { + if (matchesState({ A: 'A2' }, value)) { + return { target: 'B3' }; + } + } + }, + B3: { + always: ({ value }) => { + if (matchesState({ A: 'A3' }, value)) { + return { target: 'B4' }; + } + } + }, + B4: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'E' }); + + expect(actorRef.getSnapshot().value).toEqual({ A: 'A4', B: 'B4' }); + }); + + it('should check for automatic transitions even after microsteps are done', () => { + const machine = createMachine({ + type: 'parallel', + states: { + A: { + initial: 'A1', + states: { + A1: { + on: { + A: 'A2' + } + }, + A2: {} + } + }, + B: { + initial: 'B1', + states: { + B1: { + always: ({ value }) => { + if (matchesState({ A: 'A2' }, value)) { + return { target: 'B2' }; + } + } + }, + B2: {} + } + }, + C: { + initial: 'C1', + states: { + C1: { + always: ({ value }) => { + if (matchesState({ A: 'A2' }, value)) { + return { target: 'C2' }; + } + } + }, + C2: {} + } + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'A' }); + + expect(actorRef.getSnapshot().value).toEqual({ A: 'A2', B: 'B2', C: 'C2' }); + }); + + it('should determine the resolved initial state from the transient state', () => { + expect(createActor(greetingMachine).getSnapshot().value).toEqual('morning'); + }); + + it('should determine the resolved state from an initial transient state', () => { + const actorRef = createActor(greetingMachine).start(); + + actorRef.send({ type: 'CHANGE' }); + expect(actorRef.getSnapshot().value).toEqual('morning'); + + actorRef.send({ type: 'RECHECK' }); + expect(actorRef.getSnapshot().value).toEqual('evening'); + }); + + it('should select eventless transition before processing raised events', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + FOO: 'b' + } + }, + b: { + entry2: (_, enq) => { + enq.raise({ type: 'BAR' }); + }, + always: 'c', + on: { + BAR: 'd' + } + }, + c: { + on: { + BAR: 'e' + } + }, + d: {}, + e: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'FOO' }); + + expect(actorRef.getSnapshot().value).toBe('e'); + }); + + it('should not select wildcard for eventless transition', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { FOO: 'b' } + }, + b: { + always: 'pass', + on: { + '*': 'fail' + } + }, + fail: {}, + pass: {} + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'FOO' }); + + expect(actorRef.getSnapshot().value).toBe('pass'); + }); + + it('should work with transient transition on root', () => { + const machine = createMachine({ + types: {} as { context: { count: number } }, + id: 'machine', + initial: 'first', + context: { count: 0 }, + states: { + first: { + on: { + ADD: ({ context }) => ({ + context: { + count: context.count + 1 + } + }) + } + }, + success: { + type: 'final' + } + }, + + always: ({ context }) => { + if (context.count > 0) { + return { target: '.success' }; + } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'ADD' }); + + expect(actorRef.getSnapshot().status).toBe('done'); + }); + + it("shouldn't crash when invoking a machine with initial transient transition depending on custom data", () => { + const timerMachine = createMachine({ + initial: 'initial', + context: ({ input }: { input: { duration: number } }) => ({ + duration: input.duration + }), + types: { + context: {} as { duration: number } + }, + states: { + initial: { + always: ({ context }) => { + if (context.duration < 1000) { + return { target: 'finished' }; + } else { + return { target: 'active' }; + } + } + }, + active: {}, + finished: { type: 'final' } + } + }); + + const machine = createMachine({ + initial: 'active', + context: { + customDuration: 3000 + }, + states: { + active: { + invoke: { + src: timerMachine, + input: ({ context }) => ({ + duration: context.customDuration + }) + } + } + } + }); + + const actorRef = createActor(machine); + expect(() => actorRef.start()).not.toThrow(); + }); + + it('should be taken even in absence of other transitions', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + always: ({ event }) => { + if (event.type === 'WHATEVER') { + return { target: 'b' }; + } + } + }, + b: {} + } + }); + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'WHATEVER' }); + + expect(actorRef.getSnapshot().value).toBe('b'); + }); + + it('should select subsequent transient transitions even in absence of other transitions', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + always: ({ event }) => { + if (event.type === 'WHATEVER') { + return { target: 'b' }; + } + } + }, + b: { + always: () => { + if (true) { + return { target: 'c' }; + } + } + }, + c: {} + } + }); + + const actorRef = createActor(machine).start(); + + actorRef.send({ type: 'WHATEVER' }); + + expect(actorRef.getSnapshot().value).toBe('c'); + }); + + it('events that trigger eventless transitions should be preserved in guards', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: 'b' + } + }, + b: { + always: 'c' + }, + c: { + always: ({ event }) => { + expect(event.type).toEqual('EVENT'); + if (event.type === 'EVENT') { + return { target: 'd' }; + } + } + }, + d: { type: 'final' } + } + }); + + const actorRef = createActor(machine).start(); + actorRef.send({ type: 'EVENT' }); + + expect(actorRef.getSnapshot().status).toBe('done'); + }); + + it('events that trigger eventless transitions should be preserved in actions', () => { + expect.assertions(2); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: 'b' + } + }, + b: { + always: ({ event }, enq) => { + enq.action( + () => void expect(event).toEqual({ type: 'EVENT', value: 42 }) + ); + return { target: 'c' }; + } + }, + c: { + entry2: ({ event }, enq) => { + enq.action( + () => void expect(event).toEqual({ type: 'EVENT', value: 42 }) + ); + } + } + } + }); + + const service = createActor(machine).start(); + service.send({ type: 'EVENT', value: 42 }); + }); + + it("shouldn't end up in an infinite loop when selecting the fallback target", () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + event: 'active' + } + }, + active: { + initial: 'a', + states: { + a: {}, + b: {} + }, + always: () => { + if (1 + 1 === 3) { + return { target: '.a' }; + } else { + return { target: '.b' }; + } + } + } + } + }); + const actorRef = createActor(machine).start(); + actorRef.send({ + type: 'event' + }); + + expect(actorRef.getSnapshot().value).toEqual({ active: 'b' }); + }); + + it("shouldn't end up in an infinite loop when selecting a guarded target", () => { + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + event: 'active' + } + }, + active: { + initial: 'a', + states: { + a: {}, + b: {} + }, + always: () => { + if (true) { + return { target: '.a' }; + } else { + return { target: '.b' }; + } + } + } + } + }); + const actorRef = createActor(machine).start(); + actorRef.send({ + type: 'event' + }); + + expect(actorRef.getSnapshot().value).toEqual({ active: 'a' }); + }); + + it("shouldn't end up in an infinite loop when executing a fire-and-forget action that doesn't change state", () => { + let count = 0; + const machine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { + event: 'active' + } + }, + active: { + initial: 'a', + states: { + a: {} + }, + always: (_, enq) => { + enq.action(() => { + count++; + if (count > 5) { + throw new Error('Infinite loop detected'); + } + }); + return { target: '.a' }; + } + } + } + }); + + const actorRef = createActor(machine); + + actorRef.start(); + actorRef.send({ + type: 'event' + }); + + expect(actorRef.getSnapshot().value).toEqual({ active: 'a' }); + expect(count).toBe(1); + }); + + it('should loop (but not infinitely) for assign actions', () => { + const machine = createMachine({ + context: { count: 0 }, + initial: 'counting', + states: { + counting: { + always: ({ context }) => { + if (context.count < 5) { + return { + context: { + count: context.count + 1 + } + }; + } + } + } + } + }); + + const actorRef = createActor(machine).start(); + + expect(actorRef.getSnapshot().context.count).toEqual(5); + }); + + it("should execute an always transition after a raised transition even if that raised transition doesn't change the state", () => { + const spy = jest.fn(); + let counter = 0; + const machine = createMachine({ + always: (_, enq) => { + enq.action((...args) => { + spy(...args); + }, counter); + }, + on: { + EV: (_, enq) => { + enq.raise({ type: 'RAISED' }); + }, + RAISED: (_, enq) => { + enq.action(() => { + ++counter; + }); + } + } + }); + const actorRef = createActor(machine).start(); + spy.mockClear(); + actorRef.send({ type: 'EV' }); + + expect(spy.mock.calls).toEqual([ + // called in response to the `EV` event + [0], + // called in response to the `RAISED` event + [1] + ]); + }); +}); diff --git a/packages/core/test/transition.v6.test.ts b/packages/core/test/transition.v6.test.ts new file mode 100644 index 0000000000..e76e09b835 --- /dev/null +++ b/packages/core/test/transition.v6.test.ts @@ -0,0 +1,567 @@ +import { sleep } from '@xstate-repo/jest-utils'; +import { + createActor, + createMachine, + EventFrom, + ExecutableActionsFrom, + ExecutableSpawnAction, + fromPromise, + fromTransition, + setup, + toPromise, + transition +} from '../src'; +import { createDoneActorEvent } from '../src/eventUtils'; +import { initialTransition } from '../src/transition'; +import assert from 'node:assert'; +import { resolveReferencedActor } from '../src/utils'; + +describe('transition function', () => { + it('should capture actions', () => { + const actionWithParams = jest.fn(); + const actionWithDynamicParams = jest.fn(); + const stringAction = jest.fn(); + + const machine = setup({ + types: { + context: {} as { count: number }, + events: {} as { type: 'event'; msg: string } + }, + actions: { + actionWithParams, + actionWithDynamicParams: (_, params: { msg: string }) => { + actionWithDynamicParams(params); + }, + stringAction + } + }).createMachine({ + entry2: (_, enq) => { + enq.action(actionWithParams, { a: 1 }); + enq.action(stringAction); + return { + context: { count: 100 } + }; + }, + context: { count: 0 }, + on: { + event: ({ event }, enq) => { + enq.action(actionWithDynamicParams, { msg: event.msg }); + } + } + }); + + const [state0, actions0] = initialTransition(machine); + + expect(state0.context.count).toBe(100); + expect(actions0).toEqual([ + expect.objectContaining({ args: [{ a: 1 }] }), + expect.objectContaining({ args: [] }) + ]); + + expect(actionWithParams).not.toHaveBeenCalled(); + expect(stringAction).not.toHaveBeenCalled(); + + const [state1, actions1] = transition(machine, state0, { + type: 'event', + msg: 'hello' + }); + + expect(state1.context.count).toBe(100); + expect(actions1).toEqual([ + expect.objectContaining({ + args: [{ msg: 'hello' }] + }) + ]); + + expect(actionWithDynamicParams).not.toHaveBeenCalled(); + }); + + it('should not execute a referenced serialized action', () => { + const foo = jest.fn(); + + const machine = setup({ + actions: { + foo + } + }).createMachine({ + entry: 'foo', + context: { count: 0 } + }); + + const [, actions] = initialTransition(machine); + + expect(foo).not.toHaveBeenCalled(); + }); + + it('should capture enqueued actions', () => { + const machine = createMachine({ + entry2: (_, enq) => { + enq.emit({ type: 'stringAction' }); + enq.emit({ type: 'objectAction' }); + } + }); + + const [_state, actions] = initialTransition(machine); + + expect(actions).toEqual([ + expect.objectContaining({ type: 'stringAction' }), + expect.objectContaining({ type: 'objectAction' }) + ]); + }); + + it('delayed raise actions should be returned', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry2: (_, enq) => { + enq.raise({ type: 'NEXT' }, { delay: 10 }); + }, + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + const [state, actions] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + expect(actions[0]).toEqual( + expect.objectContaining({ + type: 'xstate.raise', + params: expect.objectContaining({ + delay: 10, + event: { type: 'NEXT' } + }) + }) + ); + }); + + it('raise actions related to delayed transitions should be returned', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + after: { 10: 'b' } + }, + b: {} + } + }); + + const [state, actions] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + expect(actions[0]).toEqual( + expect.objectContaining({ + type: 'xstate.raise', + params: expect.objectContaining({ + delay: 10, + event: { type: 'xstate.after.10.(machine).a' } + }) + }) + ); + }); + + it('cancel action should be returned', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + entry2: (_, enq) => { + enq.raise({ type: 'NEXT' }, { delay: 10, id: 'myRaise' }); + }, + on: { + NEXT: (_, enq) => { + enq.cancel('myRaise'); + return { target: 'b' }; + } + } + }, + b: {} + } + }); + + const [state] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + const [, actions] = transition(machine, state, { type: 'NEXT' }); + + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'xstate.cancel', + params: expect.objectContaining({ + sendId: 'myRaise' + }) + }) + ); + }); + + it('sendTo action should be returned', async () => { + const machine = createMachine({ + initial: 'a', + invoke: { + src: createMachine({}), + id: 'someActor' + }, + states: { + a: { + on: { + NEXT: ({ children }, enq) => { + enq.sendTo(children.someActor, { type: 'someEvent' }); + } + } + } + } + }); + + const [state0, actions0] = initialTransition(machine); + + expect(state0.value).toEqual('a'); + + expect(actions0).toContainEqual( + expect.objectContaining({ + type: 'xstate.spawnChild', + params: expect.objectContaining({ + id: 'someActor' + }) + }) + ); + + const [state1, actions1] = transition(machine, state0, { type: 'NEXT' }); + + expect(actions1).toContainEqual( + expect.objectContaining({ + type: 'xstate.sendTo', + params: expect.objectContaining({ + to: state1.children.someActor, + event: { type: 'someEvent' } + }) + }) + ); + }); + + it('emit actions should be returned', async () => { + const machine = createMachine({ + types: { + emitted: {} as { type: 'counted'; count: number } + }, + initial: 'a', + context: { count: 10 }, + states: { + a: { + on: { + NEXT: ({ context }, enq) => { + enq.emit({ + type: 'counted', + count: context.count + }); + } + } + } + } + }); + + const [state] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + const [, nextActions] = transition(machine, state, { type: 'NEXT' }); + + expect(nextActions).toContainEqual( + expect.objectContaining({ + type: 'counted', + params: { + count: 10 + } + }) + ); + }); + + it('log actions should be returned', async () => { + const machine = createMachine({ + initial: 'a', + context: { count: 10 }, + states: { + a: { + on: { + NEXT: ({ context }, enq) => { + enq.log(`count: ${context.count}`); + } + } + } + } + }); + + const [state] = initialTransition(machine); + + expect(state.value).toEqual('a'); + + const [, nextActions] = transition(machine, state, { type: 'NEXT' }); + + expect(nextActions).toContainEqual( + expect.objectContaining({ + type: 'xstate.log', + params: expect.objectContaining({ + value: 'count: 10' + }) + }) + ); + }); + + it('should calculate the next snapshot for transition logic', () => { + const logic = fromTransition( + (state, event) => { + if (event.type === 'next') { + return { count: state.count + 1 }; + } else { + return state; + } + }, + { count: 0 } + ); + + const [init] = initialTransition(logic); + const [s1] = transition(logic, init, { type: 'next' }); + expect(s1.context.count).toEqual(1); + const [s2] = transition(logic, s1, { type: 'next' }); + expect(s2.context.count).toEqual(2); + }); + + it('should calculate the next snapshot for machine logic', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + on: { + NEXT: 'c' + } + }, + c: {} + } + }); + + const [init] = initialTransition(machine); + const [s1] = transition(machine, init, { type: 'NEXT' }); + + expect(s1.value).toEqual('b'); + + const [s2] = transition(machine, s1, { type: 'NEXT' }); + + expect(s2.value).toEqual('c'); + }); + + it('should not execute entry actions', () => { + const fn = jest.fn(); + + const machine = createMachine({ + initial: 'a', + entry: fn, + states: { + a: {}, + b: {} + } + }); + + initialTransition(machine); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('should not execute transition actions', () => { + const fn = jest.fn(); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + event: { + target: 'b', + actions: fn + } + } + }, + b: {} + } + }); + + const [init] = initialTransition(machine); + const [nextSnapshot] = transition(machine, init, { type: 'event' }); + + expect(fn).not.toHaveBeenCalled(); + expect(nextSnapshot.value).toEqual('b'); + }); + + it('delayed events example (experimental)', async () => { + const db = { + state: undefined as any + }; + + const machine = createMachine({ + initial: 'start', + states: { + start: { + on: { + next: 'waiting' + } + }, + waiting: { + after: { + 10: 'done' + } + }, + done: { + type: 'final' + } + } + }); + + async function execute(action: ExecutableActionsFrom) { + if (action.type === 'xstate.raise' && action.params.delay) { + const currentTime = Date.now(); + const startedAt = currentTime; + const elapsed = currentTime - startedAt; + const timeRemaining = Math.max(0, action.params.delay - elapsed); + + await new Promise((res) => setTimeout(res, timeRemaining)); + postEvent(action.params.event); + } + } + + // POST /workflow + async function postStart() { + const [state, actions] = initialTransition(machine); + + db.state = JSON.stringify(state); + + // execute actions + for (const action of actions) { + await execute(action); + } + } + + // POST /workflow/{sessionId} + async function postEvent(event: EventFrom) { + const [nextState, actions] = transition( + machine, + machine.resolveState(JSON.parse(db.state)), + event + ); + + db.state = JSON.stringify(nextState); + + for (const action of actions) { + await execute(action); + } + } + + await postStart(); + postEvent({ type: 'next' }); + + await sleep(15); + expect(JSON.parse(db.state).status).toBe('done'); + }); + + it('serverless workflow example (experimental)', async () => { + const db = { + state: undefined as any + }; + + const machine = setup({ + actors: { + sendWelcomeEmail: fromPromise(async () => { + calls.push('sendWelcomeEmail'); + return { + status: 'sent' + }; + }) + } + }).createMachine({ + initial: 'sendingWelcomeEmail', + states: { + sendingWelcomeEmail: { + invoke: { + src: 'sendWelcomeEmail', + input: () => ({ message: 'hello world', subject: 'hi' }), + onDone: 'logSent' + } + }, + logSent: { + invoke: { + src: fromPromise(async () => {}), + onDone: 'finish' + } + }, + finish: {} + } + }); + + const calls: string[] = []; + + async function execute(action: ExecutableActionsFrom) { + switch (action.type) { + case 'xstate.spawnChild': { + const spawnAction = action as ExecutableSpawnAction; + const logic = + typeof spawnAction.params.src === 'string' + ? resolveReferencedActor(machine, spawnAction.params.src) + : spawnAction.params.src; + assert('transition' in logic); + const output = await toPromise( + createActor(logic, spawnAction.params).start() + ); + postEvent(createDoneActorEvent(spawnAction.params.id, output)); + } + default: + break; + } + } + + // POST /workflow + async function postStart() { + const [state, actions] = initialTransition(machine); + + db.state = JSON.stringify(state); + + // execute actions + for (const action of actions) { + await execute(action); + } + } + + // POST /workflow/{sessionId} + async function postEvent(event: EventFrom) { + const [nextState, actions] = transition( + machine, + machine.resolveState(JSON.parse(db.state)), + event + ); + + db.state = JSON.stringify(nextState); + + // "sync" built-in actions: assign, raise, cancel, stop + // "external" built-in actions: sendTo, raise w/delay, log + for (const action of actions) { + await execute(action); + } + } + + await postStart(); + postEvent({ type: 'sent' }); + + expect(calls).toEqual(['sendWelcomeEmail']); + + await sleep(10); + expect(JSON.parse(db.state).value).toBe('finish'); + }); +}); diff --git a/packages/core/test/v6.test.ts b/packages/core/test/v6.test.ts new file mode 100644 index 0000000000..b481a30b7f --- /dev/null +++ b/packages/core/test/v6.test.ts @@ -0,0 +1,151 @@ +import { initialTransition, transition } from '../src'; +import { createMachine } from '../src/createMachine'; + +it('should work with fn targets', () => { + const machine = createMachine({ + initial: 'active', + states: { + active: { + on: { + toggle: () => ({ target: 'inactive' }) + } + }, + inactive: {} + } + }); + + const [initialState] = initialTransition(machine); + + const [nextState] = transition(machine, initialState, { type: 'toggle' }); + + expect(nextState.value).toEqual('inactive'); +}); + +it('should work with fn actions', () => { + const machine = createMachine({ + initial: 'active', + states: { + active: { + on: { + toggle: (_, enq) => { + enq.emit({ type: 'something' }); + } + } + }, + inactive: {} + } + }); + + const [initialState] = initialTransition(machine); + + const [, actions] = transition(machine, initialState, { type: 'toggle' }); + + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'something' + }) + ); +}); + +it('should work with both fn actions and target', () => { + const machine = createMachine({ + initial: 'active', + states: { + active: { + on: { + toggle: (_, enq) => { + enq.emit({ type: 'something' }); + + return { + target: 'inactive' + }; + } + } + }, + inactive: {} + } + }); + + const [initialState] = initialTransition(machine); + + const [nextState, actions] = transition(machine, initialState, { + type: 'toggle' + }); + + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'something' + }) + ); + + expect(nextState.value).toEqual('inactive'); +}); + +it('should work with conditions', () => { + const machine = createMachine({ + initial: 'active', + context: { + count: 0 + }, + states: { + active: { + on: { + increment: ({ context }) => ({ + context: { + ...context, + count: context.count + 1 + } + }), + toggle: ({ context }, enq) => { + enq.emit({ type: 'something' }); + + if (context.count > 0) { + return { target: 'inactive' }; + } + + enq.emit({ type: 'invalid' }); + + return undefined; + } + } + }, + inactive: {} + } + }); + + const [initialState] = initialTransition(machine); + + const [nextState, actions] = transition(machine, initialState, { + type: 'toggle' + }); + + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'something' + }) + ); + + expect(actions).toContainEqual( + expect.objectContaining({ + type: 'invalid' + }) + ); + + expect(nextState.value).toEqual('active'); + + const [nextState2] = transition(machine, nextState, { + type: 'increment' + }); + + const [nextState3, actions3] = transition(machine, nextState2, { + type: 'toggle' + }); + + expect(nextState3.value).toEqual('inactive'); + + expect(actions3).toContainEqual( + expect.objectContaining({ + type: 'something' + }) + ); +}); diff --git a/packages/core/test/waitFor.v6.test.ts b/packages/core/test/waitFor.v6.test.ts new file mode 100644 index 0000000000..b8fb5521a1 --- /dev/null +++ b/packages/core/test/waitFor.v6.test.ts @@ -0,0 +1,401 @@ +import { createActor, waitFor } from '../src/index.ts'; +import { createMachine } from '../src/index.ts'; + +describe('waitFor', () => { + it('should wait for a condition to be true and return the emitted value', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + setTimeout(() => actor.send({ type: 'NEXT' }), 10); + + const state = await waitFor(actor, (s) => s.matches('b')); + + expect(state.value).toEqual('b'); + }); + + it('should throw an error after a timeout', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: { + on: { NEXT: 'c' } + }, + c: {} + } + }); + + const actor = createActor(machine).start(); + + try { + await waitFor(actor, (state) => state.matches('c'), { timeout: 10 }); + } catch (e) { + expect(e).toBeInstanceOf(Error); + } + }); + + it('should not reject immediately when passing Infinity as timeout', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: { + on: { NEXT: 'c' } + }, + c: {} + } + }); + const actor = createActor(machine).start(); + const result = await Promise.race([ + waitFor(actor, (state) => state.matches('c'), { + timeout: Infinity + }), + new Promise((res) => setTimeout(res, 10)).then(() => 'timeout') + ]); + + expect(result).toBe('timeout'); + actor.stop(); + }); + + it('should throw an error when reaching a final state that does not match the predicate', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: { + type: 'final' + } + } + }); + + const actor = createActor(machine).start(); + + setTimeout(() => { + actor.send({ type: 'NEXT' }); + }, 10); + + await expect( + waitFor(actor, (state) => state.matches('never')) + ).rejects.toMatchInlineSnapshot( + `[Error: Actor terminated without satisfying predicate]` + ); + }); + + it('should resolve correctly when the predicate immediately matches the current state', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: {} + } + }); + + const actor = createActor(machine).start(); + + await expect( + waitFor(actor, (state) => state.matches('a')) + ).resolves.toHaveProperty('value', 'a'); + }); + + it('should not subscribe when the predicate immediately matches', () => { + const machine = createMachine({}); + + const actorRef = createActor(machine).start(); + const spy = jest.fn(); + actorRef.subscribe = spy; + + waitFor(actorRef, () => true).then(() => {}); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should internally unsubscribe when the predicate immediately matches the current state', async () => { + let count = 0; + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + + await waitFor(actor, (state) => { + count++; + return state.matches('a'); + }); + + actor.send({ type: 'NEXT' }); + + expect(count).toBe(1); + }); + + it('should immediately resolve for an actor in its final state that matches the predicate', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const actor = createActor(machine).start(); + actor.send({ type: 'NEXT' }); + + await expect( + waitFor(actor, (state) => state.matches('b')) + ).resolves.toHaveProperty('value', 'b'); + }); + + it('should immediately reject for an actor in its final state that does not match the predicate', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const actor = createActor(machine).start(); + actor.send({ type: 'NEXT' }); + + await expect( + waitFor(actor, (state) => state.matches('a')) + ).rejects.toMatchInlineSnapshot( + `[Error: Actor terminated without satisfying predicate]` + ); + }); + + it('should not subscribe to the actor when it receives an aborted signal', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const actor = createActor(machine).start(); + actor.send({ type: 'NEXT' }); + + const controller = new AbortController(); + const { signal } = controller; + controller.abort(new Error('Aborted!')); + const spy = jest.fn(); + actor.subscribe = spy; + try { + await waitFor(actor, (state) => state.matches('b'), { signal }); + fail('should have rejected'); + } catch { + expect(spy).not.toHaveBeenCalled(); + } + }); + + it('should not listen for the "abort" event when it receives an aborted signal', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const actor = createActor(machine).start(); + actor.send({ type: 'NEXT' }); + + const controller = new AbortController(); + const { signal } = controller; + controller.abort(new Error('Aborted!')); + + const spy = jest.fn(); + signal.addEventListener = spy; + + try { + await waitFor(actor, (state) => state.matches('b'), { signal }); + fail('should have rejected'); + } catch { + expect(spy).not.toHaveBeenCalled(); + } + }); + + it('should not listen for the "abort" event for actor in its final state that matches the predicate', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const actor = createActor(machine).start(); + actor.send({ type: 'NEXT' }); + + const controller = new AbortController(); + const { signal } = controller; + + const spy = jest.fn(); + signal.addEventListener = spy; + + await waitFor(actor, (state) => state.matches('b'), { signal }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should immediately reject when it receives an aborted signal', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const actor = createActor(machine).start(); + actor.send({ type: 'NEXT' }); + + const controller = new AbortController(); + const { signal } = controller; + controller.abort(new Error('Aborted!')); + + await expect( + waitFor(actor, (state) => state.matches('b'), { signal }) + ).rejects.toMatchInlineSnapshot(`[Error: Aborted!]`); + }); + + it('should reject when the signal is aborted while waiting', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: {} + } + }); + + const actor = createActor(machine).start(); + const controller = new AbortController(); + const { signal } = controller; + setTimeout(() => controller.abort(new Error('Aborted!')), 10); + + await expect( + waitFor(actor, (state) => state.matches('b'), { signal }) + ).rejects.toMatchInlineSnapshot(`[Error: Aborted!]`); + }); + + it('should stop listening for the "abort" event upon successful completion', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b' + } + }, + b: { + type: 'final' + } + } + }); + + const actor = createActor(machine).start(); + setTimeout(() => { + actor.send({ type: 'NEXT' }); + }, 10); + + const controller = new AbortController(); + const { signal } = controller; + const spy = jest.fn(); + signal.removeEventListener = spy; + + await waitFor(actor, (state) => state.matches('b'), { signal }); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should stop listening for the "abort" event upon failure', async () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: { + type: 'final' + } + } + }); + + const actor = createActor(machine).start(); + + setTimeout(() => { + actor.send({ type: 'NEXT' }); + }, 10); + + const controller = new AbortController(); + const { signal } = controller; + const spy = jest.fn(); + signal.removeEventListener = spy; + + try { + await waitFor(actor, (state) => state.matches('never'), { signal }); + fail('should have rejected'); + } catch { + expect(spy).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49010d0d3a..69ae371a39 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: xml-js: specifier: ^1.6.11 version: 1.6.11 + zod: + specifier: ^3.25.51 + version: 3.25.51 packages/xstate-graph: devDependencies: @@ -9004,12 +9007,12 @@ packages: peerDependencies: zod: ^3.18.0 - zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} - zod@3.24.4: resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + zod@3.25.51: + resolution: {integrity: sha512-TQSnBldh+XSGL+opiSIq0575wvDPqu09AqWe1F7JhUMKY+M91/aGlK4MhpVNO7MgYfHcVCB1ffwAUTJzllKJqg==} + snapshots: '@ampproject/remapping@2.3.0': @@ -10655,7 +10658,7 @@ snapshots: semver: 7.6.3 terser: 5.31.3 v8-compile-cache: 2.4.0 - zod: 3.23.8 + zod: 3.24.4 transitivePeerDependencies: - supports-color @@ -16154,8 +16157,8 @@ snapshots: strip-json-comments: 5.0.1 summary: 2.1.0 typescript: 5.7.3 - zod: 3.23.8 - zod-validation-error: 3.3.0(zod@3.23.8) + zod: 3.24.4 + zod-validation-error: 3.3.0(zod@3.24.4) last-call-webpack-plugin@3.0.0: dependencies: @@ -19927,10 +19930,10 @@ snapshots: dependencies: zod: 3.24.4 - zod-validation-error@3.3.0(zod@3.23.8): + zod-validation-error@3.3.0(zod@3.24.4): dependencies: - zod: 3.23.8 - - zod@3.23.8: {} + zod: 3.24.4 zod@3.24.4: {} + + zod@3.25.51: {}