diff --git a/.changeset/config.json b/.changeset/config.json index 447b5e1133..b1620ffee5 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,12 +5,7 @@ "linked": [], "access": "public", "baseBranch": "main", - "ignore": [ - "@xstate/immer", - "@xstate/graph", - "@xstate/inspect", - "@xstate/test" - ], + "ignore": ["@xstate/immer", "@xstate/inspect", "@xstate/test"], "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true, "useCalculatedVersionForSnapshots": true diff --git a/.changeset/eighty-papayas-exist.md b/.changeset/eighty-papayas-exist.md new file mode 100644 index 0000000000..b3698a6070 --- /dev/null +++ b/.changeset/eighty-papayas-exist.md @@ -0,0 +1,5 @@ +--- +'@xstate/graph': patch +--- + +The `@xstate/graph` package now includes everything from `@xstate/test`. diff --git a/packages/xstate-graph/src/TestModel.ts b/packages/xstate-graph/src/TestModel.ts new file mode 100644 index 0000000000..46fab7d949 --- /dev/null +++ b/packages/xstate-graph/src/TestModel.ts @@ -0,0 +1,500 @@ +import { + getPathsFromEvents, + getAdjacencyMap, + joinPaths, + AdjacencyValue, + serializeSnapshot +} from '@xstate/graph'; +import type { + AdjacencyMap, + SerializedEvent, + SerializedSnapshot, + StatePath, + Step, + TraversalOptions +} from '@xstate/graph'; +import { + EventObject, + ActorLogic, + Snapshot, + isMachineSnapshot, + __unsafe_getAllOwnEventDescriptors, + AnyActorRef, + AnyEventObject, + AnyStateMachine, + EventFromLogic, + MachineContext, + MachineSnapshot, + SnapshotFrom, + StateValue, + TODO, + InputFrom +} from 'xstate'; +import { deduplicatePaths } from './deduplicatePaths.ts'; +import { + createShortestPathsGen, + createSimplePathsGen +} from './pathGenerators.ts'; +import type { + EventExecutor, + PathGenerator, + TestModelOptions, + TestParam, + TestPath, + TestPathResult, + TestStepResult +} from './types.ts'; +import { + formatPathTestResult, + getDescription, + simpleStringify +} from './utils.ts'; +import { validateMachine } from './validateMachine.ts'; + +type GetPathOptions< + TSnapshot extends Snapshot, + TEvent extends EventObject, + TInput +> = Partial> & { + /** + * Whether to allow deduplicate paths so that paths that are contained by longer paths + * are included. + * + * @default false + */ + allowDuplicatePaths?: boolean; +}; + +/** + * Creates a test model that represents an abstract model of a + * system under test (SUT). + * + * The test model is used to generate test paths, which are used to + * verify that states in the model are reachable in the SUT. + */ +export class TestModel< + TSnapshot extends Snapshot, + TEvent extends EventObject, + TInput +> { + public options: TestModelOptions; + public defaultTraversalOptions?: TraversalOptions; + public getDefaultOptions(): TestModelOptions { + return { + serializeState: (state) => simpleStringify(state) as SerializedSnapshot, + serializeEvent: (event) => simpleStringify(event) as SerializedEvent, + // For non-state-machine test models, we cannot identify + // separate transitions, so just use event type + serializeTransition: (state, event) => + `${simpleStringify(state)}|${event?.type}`, + events: [], + stateMatcher: (_, stateKey) => stateKey === '*', + logger: { + log: console.log.bind(console), + error: console.error.bind(console) + } + }; + } + + constructor( + public testLogic: ActorLogic, + options?: Partial> + ) { + this.options = { + ...this.getDefaultOptions(), + ...options + }; + } + + public getPaths( + pathGenerator: PathGenerator, + options?: GetPathOptions + ): Array> { + const allowDuplicatePaths = options?.allowDuplicatePaths ?? false; + const paths = pathGenerator(this.testLogic, this._resolveOptions(options)); + return (allowDuplicatePaths ? paths : deduplicatePaths(paths)).map( + this._toTestPath + ); + } + + public getShortestPaths( + options?: GetPathOptions + ): Array> { + return this.getPaths(createShortestPathsGen(), options); + } + + public getShortestPathsFrom( + paths: Array>, + options?: GetPathOptions + ): Array> { + const resultPaths: TestPath[] = []; + + for (const path of paths) { + const shortestPaths = this.getShortestPaths({ + ...options, + fromState: path.state + }); + for (const shortestPath of shortestPaths) { + resultPaths.push(this._toTestPath(joinPaths(path, shortestPath))); + } + } + + return resultPaths; + } + + public getSimplePaths( + options?: GetPathOptions + ): Array> { + return this.getPaths(createSimplePathsGen(), options); + } + + public getSimplePathsFrom( + paths: Array>, + options?: GetPathOptions + ): Array> { + const resultPaths: TestPath[] = []; + + for (const path of paths) { + const shortestPaths = this.getSimplePaths({ + ...options, + fromState: path.state + }); + for (const shortestPath of shortestPaths) { + resultPaths.push(this._toTestPath(joinPaths(path, shortestPath))); + } + } + + return resultPaths; + } + + private _toTestPath = ( + statePath: StatePath + ): TestPath => { + function formatEvent(event: EventObject): string { + const { type, ...other } = event; + + const propertyString = Object.keys(other).length + ? ` (${JSON.stringify(other)})` + : ''; + + return `${type}${propertyString}`; + } + + const eventsString = statePath.steps + .map((s) => formatEvent(s.event)) + .join(' → '); + return { + ...statePath, + test: (params: TestParam) => + this.testPath(statePath, params), + description: isMachineSnapshot(statePath.state) + ? `Reaches ${getDescription( + statePath.state as any + ).trim()}: ${eventsString}` + : JSON.stringify(statePath.state) + }; + }; + + public getPathsFromEvents( + events: TEvent[], + options?: GetPathOptions + ): Array> { + const paths = getPathsFromEvents(this.testLogic, events, options); + + return paths.map(this._toTestPath); + } + + /** + * An array of adjacencies, which are objects that represent each `state` with the `nextState` + * given the `event`. + */ + public getAdjacencyMap(): AdjacencyMap { + const adjMap = getAdjacencyMap(this.testLogic, this.options); + return adjMap; + } + + public async testPath( + path: StatePath, + params: TestParam, + options?: Partial> + ): Promise { + const testPathResult: TestPathResult = { + steps: [], + state: { + error: null + } + }; + + try { + for (const step of path.steps) { + const testStepResult: TestStepResult = { + step, + state: { error: null }, + event: { error: null } + }; + + testPathResult.steps.push(testStepResult); + + try { + await this.testTransition(params, step); + } catch (err: any) { + testStepResult.event.error = err; + + throw err; + } + + try { + await this.testState(params, step.state, options); + } catch (err: any) { + testStepResult.state.error = err; + + throw err; + } + } + } catch (err: any) { + // TODO: make option + err.message += formatPathTestResult(path, testPathResult, this.options); + throw err; + } + + return testPathResult; + } + + public async testState( + params: TestParam, + state: TSnapshot, + options?: Partial> + ): Promise { + const resolvedOptions = this._resolveOptions(options); + + const stateTestKeys = this._getStateTestKeys( + params, + state, + resolvedOptions + ); + + for (const stateTestKey of stateTestKeys) { + await params.states?.[stateTestKey](state); + } + } + + private _getStateTestKeys( + params: TestParam, + state: TSnapshot, + resolvedOptions: TestModelOptions + ) { + const states = params.states || {}; + const stateTestKeys = Object.keys(states).filter((stateKey) => { + return resolvedOptions.stateMatcher(state, stateKey); + }); + + // Fallthrough state tests + if (!stateTestKeys.length && '*' in states) { + stateTestKeys.push('*'); + } + + return stateTestKeys; + } + + private _getEventExec( + params: TestParam, + step: Step + ) { + const eventExec = + params.events?.[(step.event as any).type as TEvent['type']]; + + return eventExec; + } + + public async testTransition( + params: TestParam, + step: Step + ): Promise { + const eventExec = this._getEventExec(params, step); + await (eventExec as EventExecutor)?.(step); + } + + private _resolveOptions( + options?: Partial> + ): TestModelOptions { + return { ...this.defaultTraversalOptions, ...this.options, ...options }; + } +} + +function stateValuesEqual( + a: StateValue | undefined, + b: StateValue | undefined +): boolean { + if (a === b) { + return true; + } + + if (a === undefined || b === undefined) { + return false; + } + + if (typeof a === 'string' || typeof b === 'string') { + return a === b; + } + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => stateValuesEqual(a[key], b[key])) + ); +} + +function serializeMachineTransition( + snapshot: MachineSnapshot< + MachineContext, + EventObject, + Record, + StateValue, + string, + unknown, + TODO // TMeta + >, + event: AnyEventObject | undefined, + previousSnapshot: + | MachineSnapshot< + MachineContext, + EventObject, + Record, + StateValue, + string, + unknown, + TODO // TMeta + > + | undefined, + { serializeEvent }: { serializeEvent: (event: AnyEventObject) => string } +): string { + // TODO: the stateValuesEqual check here is very likely not exactly correct + // but I'm not sure what the correct check is and what this is trying to do + if ( + !event || + (previousSnapshot && + stateValuesEqual(previousSnapshot.value, snapshot.value)) + ) { + return ''; + } + + const prevStateString = previousSnapshot + ? ` from ${simpleStringify(previousSnapshot.value)}` + : ''; + + return ` via ${serializeEvent(event)}${prevStateString}`; +} + +/** + * Creates a test model that represents an abstract model of a + * system under test (SUT). + * + * The test model is used to generate test paths, which are used to + * verify that states in the `machine` are reachable in the SUT. + * + * @example + * + * ```js + * const toggleModel = createModel(toggleMachine).withEvents({ + * TOGGLE: { + * exec: async page => { + * await page.click('input'); + * } + * } + * }); + * ``` + * + * @param machine The state machine used to represent the abstract model. + * @param options Options for the created test model: + * - `events`: an object mapping string event types (e.g., `SUBMIT`) + * to an event test config (e.g., `{exec: () => {...}, cases: [...]}`) + */ +export function createTestModel( + machine: TMachine, + options?: Partial< + TestModelOptions< + SnapshotFrom, + EventFromLogic, + InputFrom + > + > +): TestModel, EventFromLogic, unknown> { + validateMachine(machine); + + const serializeEvent = (options?.serializeEvent ?? simpleStringify) as ( + event: AnyEventObject + ) => string; + const serializeTransition = + options?.serializeTransition ?? serializeMachineTransition; + const { events: getEvents, ...otherOptions } = options ?? {}; + + const testModel = new TestModel< + SnapshotFrom, + EventFromLogic, + unknown + >(machine as any, { + serializeState: (state, event, prevState) => { + // Only consider the `state` if `serializeTransition()` is opted out (empty string) + return `${serializeSnapshot(state)}${serializeTransition( + state, + event, + prevState, + { + serializeEvent + } + )}` as SerializedSnapshot; + }, + stateMatcher: (state, key) => { + return key.startsWith('#') + ? (state as any)._nodes.includes(machine.getStateNodeById(key)) + : (state as any).matches(key); + }, + events: (state) => { + const events = + typeof getEvents === 'function' ? getEvents(state) : getEvents ?? []; + + return __unsafe_getAllOwnEventDescriptors(state).flatMap( + (eventType: string) => { + if (events.some((e) => (e as EventObject).type === eventType)) { + return events.filter((e) => (e as EventObject).type === eventType); + } + + return [{ type: eventType } as any]; // TODO: fix types + } + ); + }, + ...otherOptions + }); + + return testModel; +} + +export function adjacencyMapToArray( + adjMap: AdjacencyMap +): Array<{ + state: TSnapshot; + event: TEvent; + nextState: TSnapshot; +}> { + const adjList: Array<{ + state: TSnapshot; + event: TEvent; + nextState: TSnapshot; + }> = []; + + for (const adjValue of Object.values(adjMap)) { + for (const transition of Object.values( + (adjValue as AdjacencyValue).transitions + )) { + adjList.push({ + state: (adjValue as AdjacencyValue).state, + event: transition.event, + nextState: transition.state + }); + } + } + + return adjList; +} diff --git a/packages/xstate-graph/src/adjacency.ts b/packages/xstate-graph/src/adjacency.ts index a94d531506..9757e818c8 100644 --- a/packages/xstate-graph/src/adjacency.ts +++ b/packages/xstate-graph/src/adjacency.ts @@ -5,7 +5,7 @@ import { EventObject, Snapshot } from 'xstate'; -import { SerializedEvent, SerializedState, TraversalOptions } from './types'; +import { SerializedEvent, SerializedSnapshot, TraversalOptions } from './types'; import { AdjacencyMap, resolveTraversalOptions } from './graph'; import { createMockActorScope } from './actorScope'; @@ -16,16 +16,16 @@ export function getAdjacencyMap< TSystem extends ActorSystem = ActorSystem >( logic: ActorLogic, - options: TraversalOptions + options: TraversalOptions ): AdjacencyMap { const { transition } = logic; const { serializeEvent, serializeState, events: getEvents, - traversalLimit: limit, + limit, fromState: customFromState, - stopCondition + stopWhen } = resolveTraversalOptions(logic, options); const actorScope = createMockActorScope() as ActorScope< TSnapshot, @@ -37,7 +37,7 @@ export function getAdjacencyMap< logic.getInitialSnapshot( actorScope, // TODO: fix this - undefined as TInput + options.input as TInput ); const adj: AdjacencyMap = {}; @@ -47,7 +47,7 @@ export function getAdjacencyMap< event: TEvent | undefined; prevState: TSnapshot | undefined; }> = [{ nextState: fromState, event: undefined, prevState: undefined }]; - const stateMap = new Map(); + const stateMap = new Map(); while (queue.length) { const { nextState: state, event, prevState } = queue.shift()!; @@ -60,7 +60,7 @@ export function getAdjacencyMap< state, event, prevState - ) as SerializedState; + ) as SerializedSnapshot; if (adj[serializedState]) { continue; } @@ -71,7 +71,7 @@ export function getAdjacencyMap< transitions: {} }; - if (stopCondition && stopCondition(state)) { + if (stopWhen && stopWhen(state)) { continue; } @@ -81,19 +81,17 @@ export function getAdjacencyMap< for (const nextEvent of events) { const nextSnapshot = transition(state, nextEvent, actorScope); - if (!options.filter || options.filter(nextSnapshot, nextEvent)) { - adj[serializedState].transitions[ - serializeEvent(nextEvent) as SerializedEvent - ] = { - event: nextEvent, - state: nextSnapshot - }; - queue.push({ - nextState: nextSnapshot, - event: nextEvent, - prevState: state - }); - } + adj[serializedState].transitions[ + serializeEvent(nextEvent) as SerializedEvent + ] = { + event: nextEvent, + state: nextSnapshot + }; + queue.push({ + nextState: nextSnapshot, + event: nextEvent, + prevState: state + }); } } diff --git a/packages/xstate-graph/src/deduplicatePaths.ts b/packages/xstate-graph/src/deduplicatePaths.ts new file mode 100644 index 0000000000..c5d7c5de13 --- /dev/null +++ b/packages/xstate-graph/src/deduplicatePaths.ts @@ -0,0 +1,75 @@ +import { StatePath } from '@xstate/graph'; +import { EventObject, Snapshot } from 'xstate'; +import { simpleStringify } from './utils.ts'; + +/** + * Deduplicates your paths so that A -> B + * is not executed separately to A -> B -> C + */ +export const deduplicatePaths = < + TSnapshot extends Snapshot, + TEvent extends EventObject +>( + paths: StatePath[], + serializeEvent: (event: TEvent) => string = simpleStringify +): StatePath[] => { + /** + * Put all paths on the same level so we can dedup them + */ + const allPathsWithEventSequence: Array<{ + path: StatePath; + eventSequence: string[]; + }> = []; + + paths.forEach((path) => { + allPathsWithEventSequence.push({ + path, + eventSequence: path.steps.map((step) => serializeEvent(step.event)) + }); + }); + + // Sort by path length, descending + allPathsWithEventSequence.sort( + (a, z) => z.path.steps.length - a.path.steps.length + ); + + const superpathsWithEventSequence: typeof allPathsWithEventSequence = []; + + /** + * Filter out the paths that are subpaths of superpaths + */ + pathLoop: for (const pathWithEventSequence of allPathsWithEventSequence) { + // Check each existing superpath to see if the path is a subpath of it + superpathLoop: for (const superpathWithEventSequence of superpathsWithEventSequence) { + for (const i in pathWithEventSequence.eventSequence) { + // Check event sequence to determine if path is subpath, e.g.: + // + // This will short-circuit the check + // ['a', 'b', 'c', 'd'] (superpath) + // ['a', 'b', 'x'] (path) + // + // This will not short-circuit; path is subpath + // ['a', 'b', 'c', 'd'] (superpath) + // ['a', 'b', 'c'] (path) + if ( + pathWithEventSequence.eventSequence[i] !== + superpathWithEventSequence.eventSequence[i] + ) { + // If the path is different from the superpath, + // continue to the next superpath + continue superpathLoop; + } + } + + // If we reached here, path is subpath of superpath + // Continue & do not add path to superpaths + continue pathLoop; + } + + // If we reached here, path is not a subpath of any existing superpaths + // So add it to the superpaths + superpathsWithEventSequence.push(pathWithEventSequence); + } + + return superpathsWithEventSequence.map((path) => path.path); +}; diff --git a/packages/xstate-graph/src/graph.ts b/packages/xstate-graph/src/graph.ts index 91b93bd6f6..6561657fd5 100644 --- a/packages/xstate-graph/src/graph.ts +++ b/packages/xstate-graph/src/graph.ts @@ -9,11 +9,12 @@ import { SnapshotFrom, EventFromLogic, Snapshot, - __unsafe_getAllOwnEventDescriptors + __unsafe_getAllOwnEventDescriptors, + InputFrom } from 'xstate'; import type { SerializedEvent, - SerializedState, + SerializedSnapshot, StatePath, DirectedGraphEdge, DirectedGraphNode, @@ -54,14 +55,12 @@ export function getChildren(stateNode: AnyStateNode): AnyStateNode[] { return children; } -export function serializeMachineState( - state: ReturnType -): SerializedState { - const { value, context } = state; +export function serializeSnapshot(snapshot: Snapshot): SerializedSnapshot { + const { value, context } = snapshot as any; return JSON.stringify({ value, - context: Object.keys(context).length ? context : undefined - }) as SerializedState; + context: Object.keys(context ?? {}).length ? context : undefined + }) as SerializedSnapshot; } export function serializeEvent( @@ -74,18 +73,21 @@ export function createDefaultMachineOptions( machine: TMachine, options?: TraversalOptions< ReturnType, - EventFromLogic + EventFromLogic, + InputFrom > ): TraversalOptions< ReturnType, - EventFromLogic + EventFromLogic, + InputFrom > { const { events: getEvents, ...otherOptions } = options ?? {}; const traversalOptions: TraversalOptions< ReturnType, - EventFromLogic + EventFromLogic, + InputFrom > = { - serializeState: serializeMachineState, + serializeState: serializeSnapshot, serializeEvent, events: (state) => { const events = @@ -98,16 +100,17 @@ export function createDefaultMachineOptions( return [{ type }]; }) as any[]; }, - fromState: machine.getInitialSnapshot(createMockActorScope()) as ReturnType< - TMachine['transition'] - >, + fromState: machine.getInitialSnapshot( + createMockActorScope(), + options?.input + ) as ReturnType, ...otherOptions }; return traversalOptions; } -export function createDefaultLogicOptions(): TraversalOptions { +export function createDefaultLogicOptions(): TraversalOptions { return { serializeState: (state) => JSON.stringify(state), serializeEvent @@ -171,7 +174,7 @@ export interface AdjacencyValue { } export interface AdjacencyMap { - [key: SerializedState]: AdjacencyValue; + [key: SerializedSnapshot]: AdjacencyValue; } function isMachineLogic(logic: AnyActorLogic): logic is AnyStateMachine { @@ -182,11 +185,13 @@ export function resolveTraversalOptions( logic: TLogic, traversalOptions?: TraversalOptions< ReturnType, - EventFromLogic + EventFromLogic, + InputFrom >, defaultOptions?: TraversalOptions< ReturnType, - EventFromLogic + EventFromLogic, + InputFrom > ): TraversalConfig, EventFromLogic> { const resolvedDefaultOptions = @@ -197,7 +202,8 @@ export function resolveTraversalOptions( traversalOptions as any ) as TraversalOptions< ReturnType, - EventFromLogic + EventFromLogic, + InputFrom >) : undefined); const serializeState = @@ -210,14 +216,13 @@ export function resolveTraversalOptions( > = { serializeState, serializeEvent, - filter: () => true, events: [], - traversalLimit: Infinity, + limit: Infinity, fromState: undefined, toState: undefined, // Traversal should not continue past the `toState` predicate // since the target state has already been reached at that point - stopCondition: traversalOptions?.toState, + stopWhen: traversalOptions?.toState, ...resolvedDefaultOptions, ...traversalOptions }; diff --git a/packages/xstate-graph/src/index.ts b/packages/xstate-graph/src/index.ts index f5d387004d..5b0e6d1d0e 100644 --- a/packages/xstate-graph/src/index.ts +++ b/packages/xstate-graph/src/index.ts @@ -2,7 +2,7 @@ export type { AdjacencyMap, AdjacencyValue } from './graph.ts'; export { getStateNodes, serializeEvent, - serializeMachineState as serializeState, + serializeSnapshot, toDirectedGraph, joinPaths } from './graph.ts'; @@ -10,5 +10,10 @@ export { getSimplePaths } from './simplePaths.ts'; export { getShortestPaths } from './shortestPaths.ts'; export { getPathsFromEvents } from './pathFromEvents.ts'; export { getAdjacencyMap } from './adjacency.ts'; - +export { + TestModel, + createTestModel, + adjacencyMapToArray +} from './TestModel.ts'; +export * from './pathGenerators.ts'; export * from './types.ts'; diff --git a/packages/xstate-graph/src/pathFromEvents.ts b/packages/xstate-graph/src/pathFromEvents.ts index 1b8cb509bd..6cbc16f9cf 100644 --- a/packages/xstate-graph/src/pathFromEvents.ts +++ b/packages/xstate-graph/src/pathFromEvents.ts @@ -9,7 +9,7 @@ import { import { getAdjacencyMap } from './adjacency'; import { SerializedEvent, - SerializedState, + SerializedSnapshot, StatePath, Steps, TraversalOptions @@ -34,7 +34,7 @@ export function getPathsFromEvents< >( logic: ActorLogic, events: TEvent[], - options?: TraversalOptions + options?: TraversalOptions ): Array> { const resolvedOptions = resolveTraversalOptions( logic, @@ -44,7 +44,11 @@ export function getPathsFromEvents< }, (isMachine(logic) ? createDefaultMachineOptions(logic) - : createDefaultLogicOptions()) as TraversalOptions + : createDefaultLogicOptions()) as TraversalOptions< + TSnapshot, + TEvent, + TInput + > ); const actorScope = createMockActorScope() as ActorScope< TSnapshot, @@ -56,21 +60,21 @@ export function getPathsFromEvents< logic.getInitialSnapshot( actorScope, // TODO: fix this - undefined as TInput + options?.input as TInput ); const { serializeState, serializeEvent } = resolvedOptions; const adjacency = getAdjacencyMap(logic, resolvedOptions); - const stateMap = new Map(); + const stateMap = new Map(); const steps: Steps = []; const serializedFromState = serializeState( fromState, undefined, undefined - ) as SerializedState; + ) as SerializedSnapshot; stateMap.set(serializedFromState, fromState); let stateSerial = serializedFromState; @@ -95,7 +99,7 @@ export function getPathsFromEvents< nextState, event, prevState - ) as SerializedState; + ) as SerializedSnapshot; stateMap.set(nextStateSerial, nextState); stateSerial = nextStateSerial; diff --git a/packages/xstate-graph/src/pathGenerators.ts b/packages/xstate-graph/src/pathGenerators.ts new file mode 100644 index 0000000000..9c2ba69e80 --- /dev/null +++ b/packages/xstate-graph/src/pathGenerators.ts @@ -0,0 +1,27 @@ +import { getShortestPaths, getSimplePaths } from '@xstate/graph'; +import { EventObject, Snapshot } from 'xstate'; +import { PathGenerator } from './types.ts'; + +export const createShortestPathsGen = + < + TSnapshot extends Snapshot, + TEvent extends EventObject, + TInput + >(): PathGenerator => + (logic, defaultOptions) => { + const paths = getShortestPaths(logic, defaultOptions); + + return paths; + }; + +export const createSimplePathsGen = + < + TSnapshot extends Snapshot, + TEvent extends EventObject, + TInput + >(): PathGenerator => + (logic, defaultOptions) => { + const paths = getSimplePaths(logic, defaultOptions); + + return paths; + }; diff --git a/packages/xstate-graph/src/shortestPaths.ts b/packages/xstate-graph/src/shortestPaths.ts index 58fa008926..772c214320 100644 --- a/packages/xstate-graph/src/shortestPaths.ts +++ b/packages/xstate-graph/src/shortestPaths.ts @@ -1,10 +1,10 @@ -import { AnyActorLogic, EventFromLogic } from 'xstate'; +import { AnyActorLogic, EventFromLogic, InputFrom } from 'xstate'; import { getAdjacencyMap } from './adjacency'; import { alterPath } from './alterPath'; import { resolveTraversalOptions } from './graph'; import { SerializedEvent, - SerializedState, + SerializedSnapshot, StatePath, StatePlanMap, TraversalOptions @@ -15,7 +15,8 @@ export function getShortestPaths( logic: TLogic, options?: TraversalOptions< ReturnType, - EventFromLogic + EventFromLogic, + InputFrom > ): Array, EventFromLogic>> { type TInternalState = ReturnType; @@ -24,22 +25,22 @@ export function getShortestPaths( const resolvedOptions = resolveTraversalOptions(logic, options); const serializeState = resolvedOptions.serializeState as ( ...args: Parameters - ) => SerializedState; + ) => SerializedSnapshot; const fromState = resolvedOptions.fromState ?? - logic.getInitialSnapshot(createMockActorScope(), undefined); + logic.getInitialSnapshot(createMockActorScope(), options?.input); const adjacency = getAdjacencyMap(logic, resolvedOptions); // weight, state, event const weightMap = new Map< - SerializedState, + SerializedSnapshot, { weight: number; - state: SerializedState | undefined; + state: SerializedSnapshot | undefined; event: TEvent | undefined; } >(); - const stateMap = new Map(); + const stateMap = new Map(); const serializedFromState = serializeState(fromState, undefined, undefined); stateMap.set(serializedFromState, fromState); @@ -48,8 +49,8 @@ export function getShortestPaths( state: undefined, event: undefined }); - const unvisited = new Set(); - const visited = new Set(); + const unvisited = new Set(); + const visited = new Set(); unvisited.add(serializedFromState); for (const serializedState of unvisited) { diff --git a/packages/xstate-graph/src/simplePaths.ts b/packages/xstate-graph/src/simplePaths.ts index 6a8a19659d..c50123c4c8 100644 --- a/packages/xstate-graph/src/simplePaths.ts +++ b/packages/xstate-graph/src/simplePaths.ts @@ -1,7 +1,7 @@ -import { AnyActorLogic, EventFromLogic } from 'xstate'; +import { AnyActorLogic, EventFromLogic, InputFrom } from 'xstate'; import { SerializedEvent, - SerializedState, + SerializedSnapshot, StatePath, Steps, TraversalOptions, @@ -16,7 +16,8 @@ export function getSimplePaths( logic: TLogic, options?: TraversalOptions< ReturnType, - EventFromLogic + EventFromLogic, + InputFrom > ): Array, EventFromLogic>> { type TState = ReturnType; @@ -26,25 +27,25 @@ export function getSimplePaths( const actorScope = createMockActorScope(); const fromState = resolvedOptions.fromState ?? - logic.getInitialSnapshot(actorScope, undefined); + logic.getInitialSnapshot(actorScope, options?.input); const serializeState = resolvedOptions.serializeState as ( ...args: Parameters - ) => SerializedState; + ) => SerializedSnapshot; const adjacency = getAdjacencyMap(logic, resolvedOptions); - const stateMap = new Map(); + const stateMap = new Map(); const visitCtx: VisitedContext = { vertices: new Set(), edges: new Set() }; const steps: Steps = []; const pathMap: Record< - SerializedState, + SerializedSnapshot, { state: TState; paths: Array> } > = {}; function util( - fromStateSerial: SerializedState, - toStateSerial: SerializedState + fromStateSerial: SerializedSnapshot, + toStateSerial: SerializedSnapshot ) { const fromState = stateMap.get(fromStateSerial)!; visitCtx.vertices.add(fromStateSerial); @@ -99,7 +100,9 @@ export function getSimplePaths( const fromStateSerial = serializeState(fromState, undefined); stateMap.set(fromStateSerial, fromState); - for (const nextStateSerial of Object.keys(adjacency) as SerializedState[]) { + for (const nextStateSerial of Object.keys( + adjacency + ) as SerializedSnapshot[]) { util(fromStateSerial, nextStateSerial); } diff --git a/packages/xstate-graph/src/types.ts b/packages/xstate-graph/src/types.ts index 1f4fc4734e..2604f5b89e 100644 --- a/packages/xstate-graph/src/types.ts +++ b/packages/xstate-graph/src/types.ts @@ -3,7 +3,14 @@ import { StateValue, StateNode, TransitionDefinition, - Snapshot + Snapshot, + MachineContext, + ActorLogic, + MachineSnapshot, + ParameterizedObject, + StateNodeConfig, + TODO, + TransitionConfig } from 'xstate'; export type AnyStateNode = StateNode; @@ -58,8 +65,8 @@ export type DirectedGraphNode = JSONSerializable< >; export interface ValueAdjacencyMap { - [stateId: SerializedState]: Record< - SerializedState, + [stateId: SerializedSnapshot]: Record< + SerializedSnapshot, { state: TState; event: TEvent; @@ -142,7 +149,7 @@ export interface ValueAdjacencyMapOptions { } export interface VisitedContext { - vertices: Set; + vertices: Set; edges: Set; a?: TState | TEvent; // TODO: remove } @@ -171,17 +178,15 @@ export type SerializationOptions< export type TraversalOptions< TSnapshot extends Snapshot, - TEvent extends EventObject -> = SerializationOptions & + TEvent extends EventObject, + TInput +> = { + input?: TInput; +} & SerializationOptions & Partial< Pick< TraversalConfig, - | 'filter' - | 'events' - | 'traversalLimit' - | 'fromState' - | 'stopCondition' - | 'toState' + 'events' | 'limit' | 'fromState' | 'stopWhen' | 'toState' > >; @@ -189,11 +194,6 @@ export interface TraversalConfig< TSnapshot extends Snapshot, TEvent extends EventObject > extends SerializationConfig { - /** - * Determines whether to traverse a transition from `state` on - * `event` when building the adjacency map. - */ - filter: (state: TSnapshot, event: TEvent) => boolean; events: readonly TEvent[] | ((state: TSnapshot) => readonly TEvent[]); /** * The maximum number of traversals to perform when calculating @@ -201,17 +201,209 @@ export interface TraversalConfig< * * @default `Infinity` */ - traversalLimit: number; + limit: number; fromState: TSnapshot | undefined; /** * When true, traversal of the adjacency map will stop * for that current state. */ - stopCondition: ((state: TSnapshot) => boolean) | undefined; + stopWhen: ((state: TSnapshot) => boolean) | undefined; toState: ((state: TSnapshot) => boolean) | undefined; } type Brand = T & { __tag: Tag }; -export type SerializedState = Brand; +export type SerializedSnapshot = Brand; export type SerializedEvent = Brand; + +// XState Test + +export type GetPathsOptions< + TSnapshot extends Snapshot, + TEvent extends EventObject, + TInput +> = Partial< + TraversalOptions & { + pathGenerator?: PathGenerator; + } +>; + +export interface TestStateNodeConfig< + TContext extends MachineContext, + TEvent extends EventObject +> extends Pick< + StateNodeConfig< + TContext, + TEvent, + TODO, + TODO, + ParameterizedObject, + TODO, + TODO, + TODO, + TODO, // emitted + TODO // meta + >, + | 'type' + | 'history' + | 'on' + | 'onDone' + | 'entry' + | 'exit' + | 'meta' + | 'always' + | 'output' + | 'id' + | 'tags' + | 'description' + > { + initial?: string; + states?: Record>; +} + +export interface TestMeta { + test?: ( + testContext: T, + state: MachineSnapshot< + TContext, + any, + any, + any, + any, + any, + any // TMeta + > + ) => Promise | void; + description?: + | string + | (( + state: MachineSnapshot< + TContext, + any, + any, + any, + any, + any, + any // TMeta + > + ) => string); + skip?: boolean; +} +interface TestStateResult { + error: null | Error; +} +export interface TestStepResult { + step: Step; + state: TestStateResult; + event: { + error: null | Error; + }; +} + +export interface TestParam< + TSnapshot extends Snapshot, + TEvent extends EventObject +> { + states?: { + [key: string]: (state: TSnapshot) => void | Promise; + }; + events?: { + [TEventType in TEvent['type']]?: EventExecutor< + TSnapshot, + { type: ExtractEvent['type'] } + >; + }; +} + +export interface TestPath< + TSnapshot extends Snapshot, + TEvent extends EventObject +> extends StatePath { + description: string; + /** + * Tests and executes each step in `steps` sequentially, and then + * tests the postcondition that the `state` is reached. + */ + test: (params: TestParam) => Promise; +} +export interface TestPathResult { + steps: TestStepResult[]; + state: TestStateResult; +} + +export type StatePredicate = (state: TState) => boolean; +/** + * Executes an effect using the `testContext` and `event` + * that triggers the represented `event`. + */ +export type EventExecutor< + TSnapshot extends Snapshot, + TEvent extends EventObject +> = (step: Step) => Promise | void; + +export interface TestModelOptions< + TSnapshot extends Snapshot, + TEvent extends EventObject, + TInput +> extends TraversalOptions { + stateMatcher: (state: TSnapshot, stateKey: string) => boolean; + logger: { + log: (msg: string) => void; + error: (msg: string) => void; + }; + serializeTransition: ( + state: TSnapshot, + event: TEvent | undefined, + prevState?: TSnapshot + ) => string; +} + +export interface TestTransitionConfig< + TContext extends MachineContext, + TEvent extends EventObject, + TTestContext +> extends TransitionConfig< + TContext, + TEvent, + TEvent, + TODO, + TODO, + TODO, + string, + TODO, // TEmitted + TODO // TMeta + > { + test?: ( + state: MachineSnapshot< + TContext, + TEvent, + any, + any, + any, + any, + any // TMeta + >, + testContext: TTestContext + ) => void; +} + +export type TestTransitionsConfig< + TContext extends MachineContext, + TEvent extends EventObject, + TTestContext +> = { + [K in TEvent['type'] | '' | '*']?: K extends '' | '*' + ? TestTransitionConfig | string + : + | TestTransitionConfig, TTestContext> + | string; +}; + +export type PathGenerator< + TSnapshot extends Snapshot, + TEvent extends EventObject, + TInput +> = ( + behavior: ActorLogic, + options: TraversalOptions +) => Array>; diff --git a/packages/xstate-graph/src/utils.ts b/packages/xstate-graph/src/utils.ts new file mode 100644 index 0000000000..2525d709fc --- /dev/null +++ b/packages/xstate-graph/src/utils.ts @@ -0,0 +1,106 @@ +import { SerializationConfig, StatePath } from '@xstate/graph'; +import { AnyMachineSnapshot, MachineContext } from 'xstate'; +import { TestMeta, TestPathResult } from './types.ts'; + +interface TestResultStringOptions extends SerializationConfig { + formatColor: (color: string, string: string) => string; +} + +export function simpleStringify(value: any): string { + return JSON.stringify(value); +} + +export function formatPathTestResult( + path: StatePath, + testPathResult: TestPathResult, + options?: Partial +): string { + const resolvedOptions: TestResultStringOptions = { + formatColor: (_color, string) => string, + serializeState: simpleStringify, + serializeEvent: simpleStringify, + ...options + }; + + const { formatColor, serializeState, serializeEvent } = resolvedOptions; + + const { state } = path; + + const targetStateString = serializeState( + state, + path.steps.length ? path.steps[path.steps.length - 1].event : undefined + ); + + let errMessage = ''; + let hasFailed = false; + errMessage += + '\nPath:\n' + + testPathResult.steps + .map((s, i, steps) => { + const stateString = serializeState( + s.step.state, + i > 0 ? steps[i - 1].step.event : undefined + ); + const eventString = serializeEvent(s.step.event); + + const stateResult = `\tState: ${ + hasFailed + ? formatColor('gray', stateString) + : s.state.error + ? ((hasFailed = true), formatColor('redBright', stateString)) + : formatColor('greenBright', stateString) + }`; + const eventResult = `\tEvent: ${ + hasFailed + ? formatColor('gray', eventString) + : s.event.error + ? ((hasFailed = true), formatColor('red', eventString)) + : formatColor('green', eventString) + }`; + + return [stateResult, eventResult].join('\n'); + }) + .concat( + `\tState: ${ + hasFailed + ? formatColor('gray', targetStateString) + : testPathResult.state.error + ? formatColor('red', targetStateString) + : formatColor('green', targetStateString) + }` + ) + .join('\n\n'); + + return errMessage; +} + +export function getDescription( + snapshot: AnyMachineSnapshot +): string { + const contextString = !Object.keys(snapshot.context).length + ? '' + : `(${JSON.stringify(snapshot.context)})`; + + const stateStrings = snapshot._nodes + .filter((sn) => sn.type === 'atomic' || sn.type === 'final') + .map(({ id, path }) => { + const meta = snapshot.getMeta()[id] as TestMeta; + if (!meta) { + return `"${path.join('.')}"`; + } + + const { description } = meta; + + if (typeof description === 'function') { + return description(snapshot); + } + + return description ? `"${description}"` : JSON.stringify(snapshot.value); + }); + + return ( + `state${stateStrings.length === 1 ? '' : 's'} ` + + stateStrings.join(', ') + + ` ${contextString}`.trim() + ); +} diff --git a/packages/xstate-graph/src/validateMachine.ts b/packages/xstate-graph/src/validateMachine.ts new file mode 100644 index 0000000000..1aa45ac88b --- /dev/null +++ b/packages/xstate-graph/src/validateMachine.ts @@ -0,0 +1,35 @@ +import { AnyStateMachine, AnyStateNode } from 'xstate'; + +const validateState = (state: AnyStateNode) => { + if (state.invoke.length > 0) { + throw new Error('Invocations on test machines are not supported'); + } + if (state.after.length > 0) { + throw new Error('After events on test machines are not supported'); + } + // TODO: this doesn't account for always transitions + [ + ...state.entry, + ...state.exit, + ...[...state.transitions.values()].flatMap((t) => + t.flatMap((t) => t.actions) + ) + ].forEach((action) => { + // TODO: this doesn't check referenced actions, only the inline ones + if ( + typeof action === 'function' && + 'resolve' in action && + typeof (action as any).delay === 'number' + ) { + throw new Error('Delayed actions on test machines are not supported'); + } + }); + + for (const child of Object.values(state.states)) { + validateState(child); + } +}; + +export const validateMachine = (machine: AnyStateMachine) => { + validateState(machine.root); +}; diff --git a/packages/xstate-graph/test/adjacency.test.ts b/packages/xstate-graph/test/adjacency.test.ts new file mode 100644 index 0000000000..745db74037 --- /dev/null +++ b/packages/xstate-graph/test/adjacency.test.ts @@ -0,0 +1,70 @@ +import { createMachine } from 'xstate'; +import { adjacencyMapToArray, createTestModel } from '../src'; + +describe('model.getAdjacencyMap()', () => { + it('generates an adjacency map (converted to an array)', () => { + const machine = createMachine({ + initial: 'standing', + states: { + standing: { + on: { + left: 'walking', + right: 'walking', + down: 'crouching', + up: 'jumping' + } + }, + walking: { + on: { + up: 'jumping', + stop: 'standing' + } + }, + jumping: { + on: { + land: 'standing' + } + }, + crouching: { + on: { + release_down: 'standing' + } + } + } + }); + const model = createTestModel(machine); + + expect( + adjacencyMapToArray(model.getAdjacencyMap()).map( + ({ state, event, nextState }) => + `Given Mario is ${state.value}, when ${event.type}, then ${nextState.value}` + ) + ).toMatchInlineSnapshot(` + [ + "Given Mario is standing, when left, then walking", + "Given Mario is standing, when right, then walking", + "Given Mario is standing, when down, then crouching", + "Given Mario is standing, when up, then jumping", + "Given Mario is walking, when up, then jumping", + "Given Mario is walking, when stop, then standing", + "Given Mario is walking, when up, then jumping", + "Given Mario is walking, when stop, then standing", + "Given Mario is crouching, when release_down, then standing", + "Given Mario is jumping, when land, then standing", + "Given Mario is jumping, when land, then standing", + "Given Mario is standing, when left, then walking", + "Given Mario is standing, when right, then walking", + "Given Mario is standing, when down, then crouching", + "Given Mario is standing, when up, then jumping", + "Given Mario is standing, when left, then walking", + "Given Mario is standing, when right, then walking", + "Given Mario is standing, when down, then crouching", + "Given Mario is standing, when up, then jumping", + "Given Mario is standing, when left, then walking", + "Given Mario is standing, when right, then walking", + "Given Mario is standing, when down, then crouching", + "Given Mario is standing, when up, then jumping", + ] + `); + }); +}); diff --git a/packages/xstate-graph/test/dieHard.test.ts b/packages/xstate-graph/test/dieHard.test.ts new file mode 100644 index 0000000000..2d5c56616a --- /dev/null +++ b/packages/xstate-graph/test/dieHard.test.ts @@ -0,0 +1,309 @@ +import { StateFrom, assign, createMachine } from 'xstate'; +import { createTestModel } from '../src/index.ts'; +import { getDescription } from '../src/utils'; + +describe('die hard example', () => { + interface DieHardContext { + three: number; + five: number; + } + + class Jugs { + public version = 0; + public three = 0; + public five = 0; + + public fillThree() { + this.three = 3; + } + public fillFive() { + this.five = 5; + } + public emptyThree() { + this.three = 0; + } + public emptyFive() { + this.five = 0; + } + public transferThree() { + const poured = Math.min(5 - this.five, this.three); + + this.three = this.three - poured; + this.five = this.five + poured; + } + public transferFive() { + const poured = Math.min(3 - this.three, this.five); + + this.three = this.three + poured; + this.five = this.five - poured; + } + } + let jugs: Jugs; + + const createDieHardModel = () => { + const dieHardMachine = createMachine( + { + types: {} as { context: DieHardContext }, + id: 'dieHard', + initial: 'pending', + context: { three: 0, five: 0 }, + states: { + pending: { + always: { + target: 'success', + guard: 'weHave4Gallons' + }, + on: { + POUR_3_TO_5: { + actions: assign(({ context }) => { + const poured = Math.min(5 - context.five, context.three); + + return { + three: context.three - poured, + five: context.five + poured + }; + }) + }, + POUR_5_TO_3: { + actions: assign(({ context }) => { + const poured = Math.min(3 - context.three, context.five); + + const res = { + three: context.three + poured, + five: context.five - poured + }; + + return res; + }) + }, + FILL_3: { + actions: assign({ three: 3 }) + }, + FILL_5: { + actions: assign({ five: 5 }) + }, + EMPTY_3: { + actions: assign({ three: 0 }) + }, + EMPTY_5: { + actions: assign({ five: 0 }) + } + } + }, + success: { + type: 'final' + } + } + }, + { + guards: { + weHave4Gallons: ({ context }) => context.five === 4 + } + } + ); + + return { + model: createTestModel(dieHardMachine), + options: { + states: { + pending: ( + state: ReturnType<(typeof dieHardMachine)['transition']> + ) => { + expect(jugs.five).not.toEqual(4); + expect(jugs.three).toEqual(state.context.three); + expect(jugs.five).toEqual(state.context.five); + }, + success: () => { + expect(jugs.five).toEqual(4); + } + }, + events: { + POUR_3_TO_5: async () => { + await jugs.transferThree(); + }, + POUR_5_TO_3: async () => { + await jugs.transferFive(); + }, + EMPTY_3: async () => { + await jugs.emptyThree(); + }, + EMPTY_5: async () => { + await jugs.emptyFive(); + }, + FILL_3: async () => { + await jugs.fillThree(); + }, + FILL_5: async () => { + await jugs.fillFive(); + } + } + } + }; + }; + + beforeEach(() => { + jugs = new Jugs(); + jugs.version = Math.random(); + }); + + describe('testing a model (shortestPathsTo)', () => { + const dieHardModel = createDieHardModel(); + + const paths = dieHardModel.model.getShortestPaths({ + toState: (state) => state.matches('success') + }); + + it('should generate the right number of paths', () => { + expect(paths.length).toEqual(2); + }); + + paths.forEach((path) => { + describe(`path ${getDescription(path.state)}`, () => { + it(`path ${getDescription(path.state)}`, async () => { + await dieHardModel.model.testPath(path, dieHardModel.options); + }); + }); + }); + }); + + describe('testing a model (simplePathsTo)', () => { + const dieHardModel = createDieHardModel(); + const paths = dieHardModel.model.getSimplePaths({ + toState: (state) => state.matches('success') + }); + + it('should generate the right number of paths', () => { + expect(paths.length).toEqual(14); + }); + + paths.forEach((path) => { + describe(`reaches state ${JSON.stringify( + path.state.value + )} (${JSON.stringify(path.state.context)})`, () => { + it(`path ${getDescription(path.state)}`, async () => { + await dieHardModel.model.testPath(path, dieHardModel.options); + }); + }); + }); + }); + + describe('testing a model (getPathFromEvents)', () => { + const dieHardModel = createDieHardModel(); + + const path = dieHardModel.model.getPathsFromEvents( + [ + { type: 'FILL_5' }, + { type: 'POUR_5_TO_3' }, + { type: 'EMPTY_3' }, + { type: 'POUR_5_TO_3' }, + { type: 'FILL_5' }, + { type: 'POUR_5_TO_3' } + ], + { toState: (state) => state.matches('success') } + )[0]; + + describe(`reaches state ${JSON.stringify( + path.state.value + )} (${JSON.stringify(path.state.context)})`, () => { + it(`path ${getDescription(path.state)}`, async () => { + await dieHardModel.model.testPath(path, dieHardModel.options); + }); + }); + + it('should return no paths if the target does not match the last entered state', () => { + const paths = dieHardModel.model.getPathsFromEvents( + [{ type: 'FILL_5' }], + { + toState: (state) => state.matches('success') + } + ); + + expect(paths).toHaveLength(0); + }); + }); + + describe('.testPath(path)', () => { + const dieHardModel = createDieHardModel(); + const paths = dieHardModel.model.getSimplePaths({ + toState: (state) => { + return state.matches('success') && state.context.three === 0; + } + }); + + it('should generate the right number of paths', () => { + expect(paths.length).toEqual(6); + }); + + paths.forEach((path) => { + describe(`reaches state ${JSON.stringify( + path.state.value + )} (${JSON.stringify(path.state.context)})`, () => { + describe(`path ${getDescription(path.state)}`, () => { + it(`reaches the target state`, async () => { + await dieHardModel.model.testPath(path, dieHardModel.options); + }); + }); + }); + }); + }); +}); +describe('error path trace', () => { + describe('should return trace for failed state', () => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + on: { NEXT_1: 'second' } + }, + second: { + on: { NEXT_2: 'third' } + }, + third: {} + } + }); + + const testModel = createTestModel(machine); + + it('should generate the right number of paths', () => { + expect( + testModel.getShortestPaths({ + toState: (state) => state.matches('third') + }).length + ).toEqual(1); + }); + + it('should show an error path trace', async () => { + const path = testModel.getShortestPaths({ + toState: (state) => state.matches('third') + })[0]; + try { + await testModel.testPath(path, { + states: { + third: () => { + throw new Error('test error'); + } + } + }); + } catch (err: any) { + expect(err.message).toEqual(expect.stringContaining('test error')); + expect(err.message).toMatchInlineSnapshot(` + "test error + Path: + State: {"value":"first"} + Event: {"type":"xstate.init"} + + State: {"value":"second"} via {"type":"xstate.init"} + Event: {"type":"NEXT_1"} + + State: {"value":"third"} via {"type":"NEXT_1"} + Event: {"type":"NEXT_2"} + + State: {"value":"third"} via {"type":"NEXT_2"}" + `); + return; + } + + throw new Error('Should have failed'); + }); + }); +}); diff --git a/packages/xstate-graph/test/events.test.ts b/packages/xstate-graph/test/events.test.ts new file mode 100644 index 0000000000..1a5dfd6ace --- /dev/null +++ b/packages/xstate-graph/test/events.test.ts @@ -0,0 +1,61 @@ +import { createMachine } from 'xstate'; +import { createTestModel } from '../src/index.ts'; +import { testUtils } from './testUtils'; + +describe('events', () => { + it('should execute events (`exec` property)', async () => { + let executed = false; + + const testModel = createTestModel( + createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: 'b' + } + }, + b: {} + } + }) + ); + + await testUtils.testModel(testModel, { + events: { + EVENT: () => { + executed = true; + } + } + }); + + expect(executed).toBe(true); + }); + + it('should execute events (function)', async () => { + let executed = false; + + const testModel = createTestModel( + createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: 'b' + } + }, + b: {} + } + }) + ); + + await testUtils.testModel(testModel, { + events: { + EVENT: () => { + executed = true; + } + } + }); + + expect(executed).toBe(true); + }); +}); diff --git a/packages/xstate-graph/test/forbiddenAttributes.test.ts b/packages/xstate-graph/test/forbiddenAttributes.test.ts new file mode 100644 index 0000000000..8663278ba3 --- /dev/null +++ b/packages/xstate-graph/test/forbiddenAttributes.test.ts @@ -0,0 +1,49 @@ +import { createMachine, raise } from 'xstate'; +import { createTestModel } from '../src/index.ts'; + +describe('Forbidden attributes', () => { + it('Should not let you declare invocations on your test machine', () => { + const machine = createMachine({ + invoke: { + src: 'myInvoke' + } + }); + + expect(() => { + createTestModel(machine); + }).toThrow('Invocations on test machines are not supported'); + }); + + it('Should not let you declare after on your test machine', () => { + const machine = createMachine({ + after: { + 5000: { + actions: () => {} + } + } + }); + + expect(() => { + createTestModel(machine); + }).toThrow('After events on test machines are not supported'); + }); + + it('Should not let you delayed actions on your machine', () => { + const machine = createMachine({ + entry: [ + raise( + { + type: 'EVENT' + }, + { + delay: 1000 + } + ) + ] + }); + + expect(() => { + createTestModel(machine); + }).toThrow('Delayed actions on test machines are not supported'); + }); +}); diff --git a/packages/xstate-graph/test/graph.test.ts b/packages/xstate-graph/test/graph.test.ts index fb6a5bf4e4..ac6e203135 100644 --- a/packages/xstate-graph/test/graph.test.ts +++ b/packages/xstate-graph/test/graph.test.ts @@ -573,30 +573,33 @@ describe('filtering', () => { } }); - const sp = getShortestPaths(machine, { + const shortestPaths = getShortestPaths(machine, { events: [{ type: 'INC' }], - filter: (s) => s.context.count < 5 + stopWhen: (state) => state.context.count === 5 }); - expect(sp.map((p) => p.state.context)).toMatchInlineSnapshot(` - [ - { - "count": 0, - }, - { - "count": 1, - }, - { - "count": 2, - }, - { - "count": 3, - }, - { - "count": 4, - }, - ] - `); + expect(shortestPaths.map((p) => p.state.context)).toMatchInlineSnapshot(` +[ + { + "count": 0, + }, + { + "count": 1, + }, + { + "count": 2, + }, + { + "count": 3, + }, + { + "count": 4, + }, + { + "count": 5, + }, +] +`); }); }); diff --git a/packages/xstate-graph/test/index.test.ts b/packages/xstate-graph/test/index.test.ts new file mode 100644 index 0000000000..6128b7c7bd --- /dev/null +++ b/packages/xstate-graph/test/index.test.ts @@ -0,0 +1,456 @@ +import { assign, createMachine, setup } from 'xstate'; +import { createTestModel } from '../src/index.ts'; +import { testUtils } from './testUtils'; + +describe('events', () => { + it('should allow for representing many cases', async () => { + type Events = + | { type: 'CLICK_BAD' } + | { type: 'CLICK_GOOD' } + | { type: 'CLOSE' } + | { type: 'ESC' } + | { type: 'SUBMIT'; value: string }; + const feedbackMachine = createMachine({ + id: 'feedback', + types: { + events: {} as Events + }, + initial: 'question', + states: { + question: { + on: { + CLICK_GOOD: 'thanks', + CLICK_BAD: 'form', + CLOSE: 'closed', + ESC: 'closed' + } + }, + form: { + on: { + SUBMIT: [ + { + target: 'thanks', + guard: ({ event }) => !!event.value.length + }, + { + target: '.invalid' + } + ], + CLOSE: 'closed', + ESC: 'closed' + }, + initial: 'valid', + states: { + valid: {}, + invalid: {} + } + }, + thanks: { + on: { + CLOSE: 'closed', + ESC: 'closed' + } + }, + closed: { + type: 'final' + } + } + }); + + const testModel = createTestModel(feedbackMachine, { + events: [ + { type: 'SUBMIT', value: 'something' }, + { type: 'SUBMIT', value: '' } + ] + }); + + await testUtils.testModel(testModel, {}); + }); + + it('should not throw an error for unimplemented events', () => { + const testMachine = createMachine({ + initial: 'idle', + states: { + idle: { + on: { ACTIVATE: 'active' } + }, + active: {} + } + }); + + const testModel = createTestModel(testMachine); + + expect(async () => { + await testUtils.testModel(testModel, {}); + }).not.toThrow(); + }); + + it('should allow for dynamic generation of cases based on state', async () => { + const values = [1, 2, 3]; + const testMachine = createMachine({ + types: {} as { + context: { values: number[] }; + events: { type: 'EVENT'; value: number }; + }, + initial: 'a', + context: { + values // to be read by generator + }, + states: { + a: { + on: { + EVENT: [ + { guard: ({ event }) => event.value === 1, target: 'b' }, + { guard: ({ event }) => event.value === 2, target: 'c' }, + { guard: ({ event }) => event.value === 3, target: 'd' } + ] + } + }, + b: {}, + c: {}, + d: {} + } + }); + + const testedEvents: any[] = []; + + const testModel = createTestModel(testMachine, { + events: (state) => + state.context.values.map((value) => ({ type: 'EVENT', value }) as const) + }); + + const paths = testModel.getShortestPaths(); + + expect(paths.length).toBe(3); + + await testUtils.testPaths(paths, { + events: { + EVENT: ({ event }) => { + testedEvents.push(event); + } + } + }); + + expect(testedEvents).toMatchInlineSnapshot(` + [ + { + "type": "EVENT", + "value": 1, + }, + { + "type": "EVENT", + "value": 2, + }, + { + "type": "EVENT", + "value": 3, + }, + ] + `); + }); +}); + +describe('state limiting', () => { + it('should limit states with filter option', () => { + const machine = createMachine({ + types: {} as { context: { count: number } }, + initial: 'counting', + context: { count: 0 }, + states: { + counting: { + on: { + INC: { + actions: assign({ + count: ({ context }) => context.count + 1 + }) + } + } + } + } + }); + + const testModel = createTestModel(machine); + + const testPaths = testModel.getShortestPaths({ + stopWhen: (state) => { + return state.context.count >= 5; + } + }); + + expect(testPaths).toHaveLength(1); + }); +}); + +// https://github.com/statelyai/xstate/issues/1935 +it('prevents infinite recursion based on a provided limit', () => { + const machine = createMachine({ + types: {} as { context: { count: number } }, + id: 'machine', + context: { + count: 0 + }, + on: { + TOGGLE: { + actions: assign({ count: ({ context }) => context.count + 1 }) + } + } + }); + + const model = createTestModel(machine); + + expect(() => { + model.getShortestPaths({ limit: 100 }); + }).toThrowErrorMatchingInlineSnapshot(`"Traversal limit exceeded"`); +}); + +describe('test model options', () => { + it('options.testState(...) should test state', async () => { + const testedStates: any[] = []; + + const model = createTestModel( + createMachine({ + initial: 'inactive', + states: { + inactive: { + on: { + NEXT: 'active' + } + }, + active: {} + } + }) + ); + + await testUtils.testModel(model, { + states: { + '*': (state) => { + testedStates.push(state.value); + } + } + }); + + expect(testedStates).toEqual(['inactive', 'active']); + }); +}); + +// https://github.com/statelyai/xstate/issues/1538 +it('tests transitions', async () => { + expect.assertions(2); + const machine = createMachine({ + initial: 'first', + states: { + first: { + on: { NEXT: 'second' } + }, + second: {} + } + }); + + const model = createTestModel(machine); + + const paths = model.getShortestPaths({ + toState: (state) => state.matches('second') + }); + + await paths[0].test({ + events: { + NEXT: (step) => { + expect(step).toHaveProperty('event'); + expect(step).toHaveProperty('state'); + } + } + }); +}); + +// https://github.com/statelyai/xstate/issues/982 +it('Event in event executor should contain payload from case', async () => { + const machine = createMachine({ + initial: 'first', + states: { + first: { + on: { NEXT: 'second' } + }, + second: {} + } + }); + + const obj = {}; + + const nonSerializableData = () => 42; + + const model = createTestModel(machine, { + events: [{ type: 'NEXT', payload: 10, fn: nonSerializableData }] + }); + + const paths = model.getShortestPaths({ + toState: (state) => state.matches('second') + }); + + await model.testPath( + paths[0], + { + events: { + NEXT: (step) => { + expect(step.event).toEqual({ + type: 'NEXT', + payload: 10, + fn: nonSerializableData + }); + } + } + }, + obj + ); +}); + +describe('state tests', () => { + it('should test states', async () => { + // a (1) + // a -> b (2) + expect.assertions(2); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: {} + } + }); + + const model = createTestModel(machine); + + await testUtils.testModel(model, { + states: { + a: (state) => { + expect(state.value).toEqual('a'); + }, + b: (state) => { + expect(state.value).toEqual('b'); + } + } + }); + }); + + it('should test wildcard state for non-matching states', async () => { + // a (1) + // a -> b (2) + // a -> c (2) + expect.assertions(4); + + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b', OTHER: 'c' } + }, + b: {}, + c: {} + } + }); + + const model = createTestModel(machine); + + await testUtils.testModel(model, { + states: { + a: (state) => { + expect(state.value).toEqual('a'); + }, + b: (state) => { + expect(state.value).toEqual('b'); + }, + '*': (state) => { + expect(state.value).toEqual('c'); + } + } + }); + }); + + it('should test nested states', async () => { + const testedStateValues: any[] = []; + + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: { + initial: 'b1', + states: { + b1: {} + } + } + } + }); + + const model = createTestModel(machine); + + await testUtils.testModel(model, { + states: { + a: (state) => { + testedStateValues.push('a'); + expect(state.value).toEqual('a'); + }, + b: (state) => { + testedStateValues.push('b'); + expect(state.matches('b')).toBe(true); + }, + 'b.b1': (state) => { + testedStateValues.push('b.b1'); + expect(state.value).toEqual({ b: 'b1' }); + } + } + }); + expect(testedStateValues).toMatchInlineSnapshot(` + [ + "a", + "b", + "b.b1", + ] + `); + }); + + it('should test with input', () => { + const model = createTestModel( + setup({ + types: { + input: {} as { + name: string; + }, + context: {} as { + name: string; + } + } + }).createMachine({ + context: (x) => ({ + name: x.input.name + }), + initial: 'checking', + states: { + checking: { + always: [ + { guard: (x) => x.context.name.length > 3, target: 'longName' }, + { target: 'shortName' } + ] + }, + longName: {}, + shortName: {} + } + }) + ); + + const path1 = model.getShortestPaths({ + input: { name: 'ed' } + }); + + expect(path1[0].steps.map((s) => s.state.value)).toEqual(['shortName']); + + const path2 = model.getShortestPaths({ + input: { name: 'edward' } + }); + + expect(path2[0].steps.map((s) => s.state.value)).toEqual(['longName']); + }); +}); diff --git a/packages/xstate-graph/test/paths.test.ts b/packages/xstate-graph/test/paths.test.ts new file mode 100644 index 0000000000..8dcee07325 --- /dev/null +++ b/packages/xstate-graph/test/paths.test.ts @@ -0,0 +1,351 @@ +import { createMachine, getInitialSnapshot, getNextSnapshot } from 'xstate'; +import { createTestModel } from '../src/index.ts'; +import { testUtils } from './testUtils'; + +const multiPathMachine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: 'b' + } + }, + b: { + on: { + EVENT: 'c' + } + }, + c: { + on: { + EVENT: 'd', + EVENT_2: 'e' + } + }, + d: {}, + e: {} + } +}); + +describe('testModel.testPaths(...)', () => { + it('custom path generators can be provided', async () => { + const testModel = createTestModel( + createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: 'b' + } + }, + b: {} + } + }) + ); + + const paths = testModel.getPaths((logic, options) => { + const initialState = getInitialSnapshot(logic); + const events = + typeof options.events === 'function' + ? options.events(initialState) + : options.events ?? []; + + const nextState = getNextSnapshot(logic, initialState, events[0]); + return [ + { + state: nextState, + steps: [ + { + state: initialState, + event: events[0] + } + ], + weight: 1 + } + ]; + }); + + await testUtils.testPaths(paths, {}); + }); + + describe('When the machine only has one path', () => { + it('Should only follow that path', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: 'b' + } + }, + b: { + on: { + EVENT: 'c' + } + }, + c: {} + } + }); + + const model = createTestModel(machine); + + const paths = model.getShortestPaths(); + + expect(paths).toHaveLength(1); + }); + }); + + describe('getSimplePaths', () => { + it('Should dedup simple path paths', () => { + const model = createTestModel(multiPathMachine); + + const paths = model.getSimplePaths(); + + expect(paths).toHaveLength(2); + }); + + it('Should not dedup simple path paths if deduplicate: false', () => { + const model = createTestModel(multiPathMachine); + + const paths = model.getSimplePaths({ + allowDuplicatePaths: true + }); + + expect(paths).toHaveLength(5); + }); + }); +}); + +describe('path.description', () => { + it('Should write a readable description including the target state and the path', () => { + const model = createTestModel(multiPathMachine); + + const paths = model.getShortestPaths(); + + expect(paths.map((path) => path.description)).toEqual([ + 'Reaches state "d": xstate.init → EVENT → EVENT → EVENT', + 'Reaches state "e": xstate.init → EVENT → EVENT → EVENT_2' + ]); + }); +}); + +describe('transition coverage', () => { + it('path generation should cover all transitions by default', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + NEXT: 'b', + END: 'b' + } + }, + b: { + on: { + PREV: 'a', + RESTART: 'a' + } + } + } + }); + + const model = createTestModel(machine); + + const paths = model.getShortestPaths(); + + expect(paths.map((path) => path.description)).toMatchInlineSnapshot(` + [ + "Reaches state "a": xstate.init → NEXT → PREV", + "Reaches state "a": xstate.init → NEXT → RESTART", + "Reaches state "b": xstate.init → END", + ] + `); + }); + + it('transition coverage should consider guarded transitions', () => { + const machine = createMachine( + { + initial: 'a', + states: { + a: { + on: { + NEXT: [{ guard: 'valid', target: 'b' }, { target: 'b' }] + } + }, + b: {} + } + }, + { + guards: { + valid: ({ event }) => { + return event.value > 10; + } + } + } + ); + + const model = createTestModel(machine); + + const paths = model.getShortestPaths({ + events: [ + { type: 'NEXT', value: 0 }, + { type: 'NEXT', value: 100 }, + { type: 'NEXT', value: 1000 } + ] + }); + + // { value: 1000 } already covered by first guarded transition + expect(paths.map((path) => path.description)).toMatchInlineSnapshot(` + [ + "Reaches state "b": xstate.init → NEXT ({"value":0}) → NEXT ({"value":0})", + "Reaches state "b": xstate.init → NEXT ({"value":100})", + "Reaches state "b": xstate.init → NEXT ({"value":1000})", + ] + `); + }); + + it('transition coverage should consider multiple transitions with the same target', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { + GO_TO_B: 'b', + GO_TO_C: 'c' + } + }, + b: { + on: { + GO_TO_A: 'a' + } + }, + c: { + on: { + GO_TO_A: 'a' + } + } + } + }); + + const model = createTestModel(machine); + + const paths = model.getShortestPaths(); + + expect(paths.map((p) => p.description)).toEqual([ + `Reaches state "a": xstate.init → GO_TO_B → GO_TO_A`, + `Reaches state "a": xstate.init → GO_TO_C → GO_TO_A` + ]); + }); +}); + +describe('getShortestPathsTo', () => { + const machine = createMachine({ + initial: 'open', + states: { + open: { + on: { + CLOSE: 'closed' + } + }, + closed: { + on: { + OPEN: 'open' + } + } + } + }); + it('Should find a path to a non-initial target state', () => { + const closedPaths = createTestModel(machine).getShortestPaths({ + toState: (state) => state.matches('closed') + }); + + expect(closedPaths).toHaveLength(1); + }); + + it('Should find a path to an initial target state', () => { + const openPaths = createTestModel(machine).getShortestPaths({ + toState: (state) => state.matches('open') + }); + + expect(openPaths).toHaveLength(1); + }); +}); + +describe('getShortestPathsFrom', () => { + it('should get shortest paths from array of paths', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b', OTHER: 'b', TO_C: 'c', TO_D: 'd', TO_E: 'e' } + }, + b: { + on: { + TO_C: 'c', + TO_D: 'd' + } + }, + c: {}, + d: {}, + e: {} + } + }); + const model = createTestModel(machine); + const pathsToB = model.getShortestPaths({ + toState: (state) => state.matches('b') + }); + + // a (NEXT) -> b + // a (OTHER) -> b + expect(pathsToB).toHaveLength(2); + + const shortestPaths = model.getShortestPathsFrom(pathsToB); + + // a (NEXT) -> b (TO_C) -> c + // a (OTHER) -> b (TO_C) -> c + // a (NEXT) -> b (TO_D) -> d + // a (OTHER) -> b (TO_D) -> d + expect(shortestPaths).toHaveLength(4); + + expect(shortestPaths.every((path) => path.steps.length === 3)).toBeTruthy(); + }); + + describe('getSimplePathsFrom', () => { + it('should get simple paths from array of paths', () => { + const machine = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b', OTHER: 'b', TO_C: 'c', TO_D: 'd', TO_E: 'e' } + }, + b: { + on: { + TO_C: 'c', + TO_D: 'd' + } + }, + c: {}, + d: {}, + e: {} + } + }); + const model = createTestModel(machine); + const pathsToB = model.getSimplePaths({ + toState: (state) => state.matches('b') + }); + + // a (NEXT) -> b + // a (OTHER) -> b + expect(pathsToB).toHaveLength(2); + + const simplePaths = model.getSimplePathsFrom(pathsToB); + + // a (NEXT) -> b (TO_C) -> c + // a (OTHER) -> b (TO_C) -> c + // a (NEXT) -> b (TO_D) -> d + // a (OTHER) -> b (TO_D) -> d + expect(simplePaths).toHaveLength(4); + + expect(simplePaths.every((path) => path.steps.length === 3)).toBeTruthy(); + }); + }); +}); diff --git a/packages/xstate-graph/test/shortestPaths.test.ts b/packages/xstate-graph/test/shortestPaths.test.ts index 98ced4a29e..0a6397d5c9 100644 --- a/packages/xstate-graph/test/shortestPaths.test.ts +++ b/packages/xstate-graph/test/shortestPaths.test.ts @@ -131,7 +131,7 @@ describe('getShortestPaths', () => { todo: 'two' } as const ], - filter: (state) => state.context.todos.length < 3 + stopWhen: (state) => state.context.todos.length >= 3 }); const pathWithTwoTodos = shortestPaths.filter( diff --git a/packages/xstate-graph/test/states.test.ts b/packages/xstate-graph/test/states.test.ts new file mode 100644 index 0000000000..13ba319907 --- /dev/null +++ b/packages/xstate-graph/test/states.test.ts @@ -0,0 +1,127 @@ +import { StateValue, createMachine } from 'xstate'; +import { createTestModel } from '../src/index.ts'; +import { testUtils } from './testUtils'; + +describe('states', () => { + it('should test states by key', async () => { + const testedStateValues: StateValue[] = []; + const testModel = createTestModel( + createMachine({ + initial: 'a', + states: { + a: { + on: { + EVENT: 'b' + } + }, + b: { + initial: 'b1', + states: { + b1: { on: { NEXT: 'b2' } }, + b2: {} + } + } + } + }) + ); + + await testUtils.testModel(testModel, { + states: { + a: (state) => { + testedStateValues.push(state.value); + }, + b: (state) => { + testedStateValues.push(state.value); + }, + 'b.b1': (state) => { + testedStateValues.push(state.value); + }, + 'b.b2': (state) => { + testedStateValues.push(state.value); + } + } + }); + + expect(testedStateValues).toMatchInlineSnapshot(` + [ + "a", + { + "b": "b1", + }, + { + "b": "b1", + }, + { + "b": "b2", + }, + { + "b": "b2", + }, + ] + `); + }); + it('should test states by ID', async () => { + const testedStateValues: StateValue[] = []; + const testModel = createTestModel( + createMachine({ + initial: 'a', + states: { + a: { + id: 'state_a', + on: { + EVENT: 'b' + } + }, + b: { + id: 'state_b', + initial: 'b1', + states: { + b1: { + id: 'state_b1', + on: { NEXT: 'b2' } + }, + b2: { + id: 'state_b2' + } + } + } + } + }) + ); + + await testUtils.testModel(testModel, { + states: { + '#state_a': (state) => { + testedStateValues.push(state.value); + }, + '#state_b': (state) => { + testedStateValues.push(state.value); + }, + '#state_b1': (state) => { + testedStateValues.push(state.value); + }, + '#state_b2': (state) => { + testedStateValues.push(state.value); + } + } + }); + + expect(testedStateValues).toMatchInlineSnapshot(` + [ + "a", + { + "b": "b1", + }, + { + "b": "b1", + }, + { + "b": "b2", + }, + { + "b": "b2", + }, + ] + `); + }); +}); diff --git a/packages/xstate-graph/test/testModel.test.ts b/packages/xstate-graph/test/testModel.test.ts new file mode 100644 index 0000000000..ba09cf0e77 --- /dev/null +++ b/packages/xstate-graph/test/testModel.test.ts @@ -0,0 +1,80 @@ +import { fromTransition } from 'xstate'; +import { TestModel } from '../src/index.ts'; +import { testUtils } from './testUtils'; + +describe('custom test models', () => { + it('tests any logic', async () => { + const transition = fromTransition((value, event) => { + if (event.type === 'even') { + return value / 2; + } else { + return value * 3 + 1; + } + }, 15); + + const model = new TestModel(transition, { + events: (state) => { + if (state.context % 2 === 0) { + return [{ type: 'even' }]; + } + return [{ type: 'odd' }]; + } + }); + + const paths = model.getShortestPaths({ + toState: (state) => state.context === 1 + }); + + expect(paths.length).toBeGreaterThan(0); + }); + + it('tests states for any logic', async () => { + const testedStateKeys: string[] = []; + + const transition = fromTransition((value, event) => { + if (event.type === 'even') { + return value / 2; + } else { + return value * 3 + 1; + } + }, 15); + + const model = new TestModel(transition, { + events: (state) => { + if (state.context % 2 === 0) { + return [{ type: 'even' }]; + } + return [{ type: 'odd' }]; + }, + stateMatcher: (state, key) => { + if (key === 'even') { + return state.context % 2 === 0; + } + if (key === 'odd') { + return state.context % 2 === 1; + } + return false; + } + }); + + const paths = model.getShortestPaths({ + toState: (state) => state.context === 1 + }); + + await testUtils.testPaths(paths, { + states: { + even: (state) => { + testedStateKeys.push('even'); + expect(state.context % 2).toBe(0); + }, + odd: (state) => { + testedStateKeys.push('odd'); + expect(state.context % 2).toBe(1); + } + } + }); + + expect(testedStateKeys).toContain('even'); + expect(testedStateKeys).toContain('odd'); + }); +}); diff --git a/packages/xstate-graph/test/testUtils.ts b/packages/xstate-graph/test/testUtils.ts new file mode 100644 index 0000000000..dd75ac694e --- /dev/null +++ b/packages/xstate-graph/test/testUtils.ts @@ -0,0 +1,30 @@ +import { EventObject, Snapshot } from 'xstate'; +import { TestModel } from '../src/TestModel'; +import { TestParam, TestPath } from '../src/types'; + +async function testModel< + TSnapshot extends Snapshot, + TEvent extends EventObject, + TInput +>( + model: TestModel, + params: TestParam +) { + for (const path of model.getShortestPaths()) { + await path.test(params); + } +} + +async function testPaths< + TSnapshot extends Snapshot, + TEvent extends EventObject +>(paths: TestPath[], params: TestParam) { + for (const path of paths) { + await path.test(params); + } +} + +export const testUtils = { + testPaths, + testModel +}; diff --git a/packages/xstate-graph/test/types.test.ts b/packages/xstate-graph/test/types.test.ts index b4251bc31b..a3e60c02b1 100644 --- a/packages/xstate-graph/test/types.test.ts +++ b/packages/xstate-graph/test/types.test.ts @@ -1,7 +1,7 @@ import { createMachine } from 'xstate'; -import { getShortestPaths } from '../src/index.ts'; +import { createTestModel, getShortestPaths } from '../src/index.ts'; -describe('types', () => { +describe('getShortestPath types', () => { it('`getEvents` should be allowed to return a mutable array', () => { const machine = createMachine({ types: {} as { @@ -139,3 +139,46 @@ describe('types', () => { }); }); }); + +describe('createTestModel types', () => { + it('`EventExecutor` should be passed event with type that corresponds to its key', () => { + const machine = createMachine({ + id: 'test', + types: { + events: {} as + | { type: 'a'; valueA: boolean } + | { type: 'b'; valueB: number } + }, + initial: 'a', + states: { + a: { + on: { + a: { target: '#test.b' } + } + }, + b: { + on: { + b: { target: '#test.a' } + } + } + } + }); + + for (const path of createTestModel(machine).getShortestPaths()) { + path.test({ + events: { + a: ({ event }) => { + ((_accept: 'a') => {})(event.type); + // @ts-expect-error + ((_accept: 'b') => {})(event.type); + }, + b: ({ event }) => { + // @ts-expect-error + ((_accept: 'a') => {})(event.type); + ((_accept: 'b') => {})(event.type); + } + } + }); + } + }); +}); diff --git a/packages/xstate-test/src/TestModel.ts b/packages/xstate-test/src/TestModel.ts index f3fae462cd..ab30a8e93f 100644 --- a/packages/xstate-test/src/TestModel.ts +++ b/packages/xstate-test/src/TestModel.ts @@ -2,21 +2,33 @@ import { getPathsFromEvents, getAdjacencyMap, joinPaths, - AdjacencyValue + serializeSnapshot } from '@xstate/graph'; import type { + AdjacencyMap, SerializedEvent, - SerializedState, + SerializedSnapshot, StatePath, Step, TraversalOptions } from '@xstate/graph'; import { EventObject, - AnyMachineSnapshot, ActorLogic, Snapshot, - isMachineSnapshot + isMachineSnapshot, + __unsafe_getAllOwnEventDescriptors, + AnyActorRef, + AnyEventObject, + AnyMachineSnapshot, + AnyStateMachine, + EventFromLogic, + MachineContext, + MachineSnapshot, + SnapshotFrom, + StateValue, + TODO, + InputFrom } from 'xstate'; import { deduplicatePaths } from './deduplicatePaths.ts'; import { @@ -37,6 +49,7 @@ import { getDescription, simpleStringify } from './utils.ts'; +import { validateMachine } from './validateMachine.ts'; /** * Creates a test model that represents an abstract model of a @@ -50,11 +63,11 @@ export class TestModel< TEvent extends EventObject, TInput > { - public options: TestModelOptions; - public defaultTraversalOptions?: TraversalOptions; - public getDefaultOptions(): TestModelOptions { + public options: TestModelOptions; + public defaultTraversalOptions?: TraversalOptions; + public getDefaultOptions(): TestModelOptions { return { - serializeState: (state) => simpleStringify(state) as SerializedState, + serializeState: (state) => simpleStringify(state) as SerializedSnapshot, serializeEvent: (event) => simpleStringify(event) as SerializedEvent, // For non-state-machine test models, we cannot identify // separate transitions, so just use event type @@ -70,8 +83,8 @@ export class TestModel< } constructor( - public logic: ActorLogic, - options?: Partial> + public testLogic: ActorLogic, + options?: Partial> ) { this.options = { ...this.getDefaultOptions(), @@ -81,21 +94,21 @@ export class TestModel< public getPaths( pathGenerator: PathGenerator, - options?: Partial> + options?: Partial> ): Array> { - const paths = pathGenerator(this.logic, this.resolveOptions(options)); + const paths = pathGenerator(this.testLogic, this.resolveOptions(options)); return deduplicatePaths(paths).map(this.toTestPath); } public getShortestPaths( - options?: Partial> + options?: Partial> ): Array> { return this.getPaths(createShortestPathsGen(), options); } public getShortestPathsFrom( paths: Array>, - options?: Partial> + options?: Partial> ): Array> { const resultPaths: TestPath[] = []; @@ -113,14 +126,14 @@ export class TestModel< } public getSimplePaths( - options?: Partial> + options?: Partial> ): Array> { return this.getPaths(createSimplePathsGen(), options); } public getSimplePathsFrom( paths: Array>, - options?: Partial> + options?: Partial> ): Array> { const resultPaths: TestPath[] = []; @@ -157,8 +170,6 @@ export class TestModel< ...statePath, test: (params: TestParam) => this.testPath(statePath, params), - testSync: (params: TestParam) => - this.testPathSync(statePath, params), description: isMachineSnapshot(statePath.state) ? `Reaches ${getDescription( statePath.state as any @@ -169,15 +180,15 @@ export class TestModel< public getPathsFromEvents( events: TEvent[], - options?: TraversalOptions + options?: TraversalOptions ): Array> { - const paths = getPathsFromEvents(this.logic, events, options); + const paths = getPathsFromEvents(this.testLogic, events, options); return paths.map(this.toTestPath); } public getAllStates(): TSnapshot[] { - const adj = getAdjacencyMap(this.logic, this.options); + const adj = getAdjacencyMap(this.testLogic, this.options); return Object.values(adj).map((x) => x.state); } @@ -185,84 +196,15 @@ export class TestModel< * An array of adjacencies, which are objects that represent each `state` with the `nextState` * given the `event`. */ - public getAdjacencyList(): Array<{ - state: TSnapshot; - event: TEvent; - nextState: TSnapshot; - }> { - const adjMap = getAdjacencyMap(this.logic, this.options); - const adjList: Array<{ - state: TSnapshot; - event: TEvent; - nextState: TSnapshot; - }> = []; - - for (const adjValue of Object.values(adjMap)) { - for (const transition of Object.values( - (adjValue as AdjacencyValue).transitions - )) { - adjList.push({ - state: (adjValue as AdjacencyValue).state, - event: transition.event, - nextState: transition.state - }); - } - } - - return adjList; - } - - public testPathSync( - path: StatePath, - params: TestParam, - options?: Partial> - ): TestPathResult { - const testPathResult: TestPathResult = { - steps: [], - state: { - error: null - } - }; - - try { - for (const step of path.steps) { - const testStepResult: TestStepResult = { - step, - state: { error: null }, - event: { error: null } - }; - - testPathResult.steps.push(testStepResult); - - try { - this.testTransitionSync(params, step); - } catch (err: any) { - testStepResult.event.error = err; - - throw err; - } - - try { - this.testStateSync(params, step.state, options); - } catch (err: any) { - testStepResult.state.error = err; - - throw err; - } - } - } catch (err: any) { - // TODO: make option - err.message += formatPathTestResult(path, testPathResult, this.options); - throw err; - } - - return testPathResult; + public getAdjacencyMap(): AdjacencyMap { + const adjMap = getAdjacencyMap(this.testLogic, this.options); + return adjMap; } public async testPath( path: StatePath, params: TestParam, - options?: Partial> + options?: Partial> ): Promise { const testPathResult: TestPathResult = { steps: [], @@ -309,7 +251,7 @@ export class TestModel< public async testState( params: TestParam, state: TSnapshot, - options?: Partial> + options?: Partial> ): Promise { const resolvedOptions = this.resolveOptions(options); @@ -323,7 +265,7 @@ export class TestModel< private getStateTestKeys( params: TestParam, state: TSnapshot, - resolvedOptions: TestModelOptions + resolvedOptions: TestModelOptions ) { const states = params.states || {}; const stateTestKeys = Object.keys(states).filter((stateKey) => { @@ -338,23 +280,6 @@ export class TestModel< return stateTestKeys; } - public testStateSync( - params: TestParam, - state: TSnapshot, - options?: Partial> - ): void { - const resolvedOptions = this.resolveOptions(options); - - const stateTestKeys = this.getStateTestKeys(params, state, resolvedOptions); - - for (const stateTestKey of stateTestKeys) { - errorIfPromise( - params.states?.[stateTestKey](state), - `The test for '${stateTestKey}' returned a promise - did you mean to use the sync method?` - ); - } - } - private getEventExec( params: TestParam, step: Step @@ -373,27 +298,169 @@ export class TestModel< await (eventExec as EventExecutor)?.(step); } - public testTransitionSync( - params: TestParam, - step: Step - ): void { - const eventExec = this.getEventExec(params, step); + public resolveOptions( + options?: Partial> + ): TestModelOptions { + return { ...this.defaultTraversalOptions, ...this.options, ...options }; + } +} - errorIfPromise( - (eventExec as EventExecutor)?.(step), - `The event '${step.event.type}' returned a promise - did you mean to use the sync method?` - ); +export async function testStateFromMeta(snapshot: AnyMachineSnapshot) { + const meta = snapshot.getMeta(); + for (const id of Object.keys(meta)) { + const stateNodeMeta = meta[id]; + if (typeof stateNodeMeta.test === 'function' && !stateNodeMeta.skip) { + await stateNodeMeta.test(snapshot); + } } +} - public resolveOptions( - options?: Partial> - ): TestModelOptions { - return { ...this.defaultTraversalOptions, ...this.options, ...options }; +function stateValuesEqual( + a: StateValue | undefined, + b: StateValue | undefined +): boolean { + if (a === b) { + return true; + } + + if (a === undefined || b === undefined) { + return false; } + + if (typeof a === 'string' || typeof b === 'string') { + return a === b; + } + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => stateValuesEqual(a[key], b[key])) + ); } -const errorIfPromise = (result: unknown, err: string) => { - if (typeof result === 'object' && result && 'then' in result) { - throw new Error(err); +function serializeMachineTransition( + snapshot: MachineSnapshot< + MachineContext, + EventObject, + Record, + StateValue, + string, + unknown, + TODO // TMeta + >, + event: AnyEventObject | undefined, + previousSnapshot: + | MachineSnapshot< + MachineContext, + EventObject, + Record, + StateValue, + string, + unknown, + TODO // TMeta + > + | undefined, + { serializeEvent }: { serializeEvent: (event: AnyEventObject) => string } +): string { + // TODO: the stateValuesEqual check here is very likely not exactly correct + // but I'm not sure what the correct check is and what this is trying to do + if ( + !event || + (previousSnapshot && + stateValuesEqual(previousSnapshot.value, snapshot.value)) + ) { + return ''; } -}; + + const prevStateString = previousSnapshot + ? ` from ${simpleStringify(previousSnapshot.value)}` + : ''; + + return ` via ${serializeEvent(event)}${prevStateString}`; +} + +/** + * Creates a test model that represents an abstract model of a + * system under test (SUT). + * + * The test model is used to generate test paths, which are used to + * verify that states in the `machine` are reachable in the SUT. + * + * @example + * + * ```js + * const toggleModel = createModel(toggleMachine).withEvents({ + * TOGGLE: { + * exec: async page => { + * await page.click('input'); + * } + * } + * }); + * ``` + * + * @param machine The state machine used to represent the abstract model. + * @param options Options for the created test model: + * - `events`: an object mapping string event types (e.g., `SUBMIT`) + * to an event test config (e.g., `{exec: () => {...}, cases: [...]}`) + */ +export function createTestModel( + machine: TMachine, + options?: Partial< + TestModelOptions< + SnapshotFrom, + EventFromLogic, + InputFrom + > + > +): TestModel, EventFromLogic, unknown> { + validateMachine(machine); + + const serializeEvent = (options?.serializeEvent ?? simpleStringify) as ( + event: AnyEventObject + ) => string; + const serializeTransition = + options?.serializeTransition ?? serializeMachineTransition; + const { events: getEvents, ...otherOptions } = options ?? {}; + + const testModel = new TestModel< + SnapshotFrom, + EventFromLogic, + unknown + >(machine as any, { + serializeState: (state, event, prevState) => { + // Only consider the `state` if `serializeTransition()` is opted out (empty string) + return `${serializeSnapshot(state)}${serializeTransition( + state, + event, + prevState, + { + serializeEvent + } + )}` as SerializedSnapshot; + }, + stateMatcher: (state, key) => { + return key.startsWith('#') + ? (state as any)._nodes.includes(machine.getStateNodeById(key)) + : (state as any).matches(key); + }, + events: (state) => { + const events = + typeof getEvents === 'function' ? getEvents(state) : getEvents ?? []; + + return __unsafe_getAllOwnEventDescriptors(state).flatMap( + (eventType: string) => { + if (events.some((e) => (e as EventObject).type === eventType)) { + return events.filter((e) => (e as EventObject).type === eventType); + } + + return [{ type: eventType } as any]; // TODO: fix types + } + ); + }, + ...otherOptions + }); + + return testModel; +} diff --git a/packages/xstate-test/src/index.ts b/packages/xstate-test/src/index.ts index d155aba864..43ec60140c 100644 --- a/packages/xstate-test/src/index.ts +++ b/packages/xstate-test/src/index.ts @@ -1,4 +1,3 @@ -export { createTestModel, createTestMachine } from './machine.ts'; -export { TestModel } from './TestModel.ts'; +export { TestModel, createTestModel } from './TestModel.ts'; export * from './types.ts'; export * from './pathGenerators.ts'; diff --git a/packages/xstate-test/src/machine.ts b/packages/xstate-test/src/machine.ts deleted file mode 100644 index aeb5d04409..0000000000 --- a/packages/xstate-test/src/machine.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { SerializedState, serializeState } from '@xstate/graph'; -import { - AnyEventObject, - AnyMachineSnapshot, - AnyStateMachine, - createMachine, - EventFrom, - EventObject, - TypegenConstraint, - TypegenDisabled, - MachineContext, - StateValue, - SnapshotFrom, - MachineSnapshot, - __unsafe_getAllOwnEventDescriptors, - AnyActorRef, - EventFromLogic, - TODO -} from 'xstate'; -import { TestModel } from './TestModel.ts'; -import { - TestMachineConfig, - TestMachineOptions, - TestModelOptions -} from './types.ts'; -import { simpleStringify } from './utils.ts'; -import { validateMachine } from './validateMachine.ts'; - -export async function testStateFromMeta(snapshot: AnyMachineSnapshot) { - const meta = snapshot.getMeta(); - for (const id of Object.keys(meta)) { - const stateNodeMeta = meta[id]; - if (typeof stateNodeMeta.test === 'function' && !stateNodeMeta.skip) { - await stateNodeMeta.test(snapshot); - } - } -} - -export function createTestMachine< - TContext extends MachineContext, - TEvent extends EventObject = AnyEventObject, - TTypesMeta extends TypegenConstraint = TypegenDisabled ->( - config: TestMachineConfig, - options?: TestMachineOptions -) { - return createMachine(config as any, options as any); -} - -function stateValuesEqual( - a: StateValue | undefined, - b: StateValue | undefined -): boolean { - if (a === b) { - return true; - } - - if (a === undefined || b === undefined) { - return false; - } - - if (typeof a === 'string' || typeof b === 'string') { - return a === b; - } - - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - - return ( - aKeys.length === bKeys.length && - aKeys.every((key) => stateValuesEqual(a[key], b[key])) - ); -} - -function serializeMachineTransition( - snapshot: MachineSnapshot< - MachineContext, - EventObject, - Record, - StateValue, - string, - unknown, - TODO // TMeta - >, - event: AnyEventObject | undefined, - previousSnapshot: - | MachineSnapshot< - MachineContext, - EventObject, - Record, - StateValue, - string, - unknown, - TODO // TMeta - > - | undefined, - { serializeEvent }: { serializeEvent: (event: AnyEventObject) => string } -): string { - // TODO: the stateValuesEqual check here is very likely not exactly correct - // but I'm not sure what the correct check is and what this is trying to do - if ( - !event || - (previousSnapshot && - stateValuesEqual(previousSnapshot.value, snapshot.value)) - ) { - return ''; - } - - const prevStateString = previousSnapshot - ? ` from ${simpleStringify(previousSnapshot.value)}` - : ''; - - return ` via ${serializeEvent(event)}${prevStateString}`; -} - -/** - * Creates a test model that represents an abstract model of a - * system under test (SUT). - * - * The test model is used to generate test paths, which are used to - * verify that states in the `machine` are reachable in the SUT. - * - * @example - * - * ```js - * const toggleModel = createModel(toggleMachine).withEvents({ - * TOGGLE: { - * exec: async page => { - * await page.click('input'); - * } - * } - * }); - * ``` - * - * @param machine The state machine used to represent the abstract model. - * @param options Options for the created test model: - * - `events`: an object mapping string event types (e.g., `SUBMIT`) - * to an event test config (e.g., `{exec: () => {...}, cases: [...]}`) - */ -export function createTestModel( - machine: TMachine, - options?: Partial< - TestModelOptions, EventFromLogic> - > -): TestModel, EventFromLogic, unknown> { - validateMachine(machine); - - const serializeEvent = (options?.serializeEvent ?? simpleStringify) as ( - event: AnyEventObject - ) => string; - const serializeTransition = - options?.serializeTransition ?? serializeMachineTransition; - const { events: getEvents, ...otherOptions } = options ?? {}; - - const testModel = new TestModel< - SnapshotFrom, - EventFromLogic, - unknown - >(machine as any, { - serializeState: (state, event, prevState) => { - // Only consider the `state` if `serializeTransition()` is opted out (empty string) - return `${serializeState(state)}${serializeTransition( - state, - event, - prevState, - { - serializeEvent - } - )}` as SerializedState; - }, - stateMatcher: (state, key) => { - return key.startsWith('#') - ? (state as any)._nodes.includes(machine.getStateNodeById(key)) - : (state as any).matches(key); - }, - events: (state) => { - const events = - typeof getEvents === 'function' ? getEvents(state) : getEvents ?? []; - - return __unsafe_getAllOwnEventDescriptors(state).flatMap( - (eventType: string) => { - if (events.some((e) => (e as EventObject).type === eventType)) { - return events.filter((e) => (e as EventObject).type === eventType); - } - - return [{ type: eventType } as any]; // TODO: fix types - } - ); - }, - ...otherOptions - }); - - return testModel; -} diff --git a/packages/xstate-test/src/types.ts b/packages/xstate-test/src/types.ts index fa09f70e6f..217df647af 100644 --- a/packages/xstate-test/src/types.ts +++ b/packages/xstate-test/src/types.ts @@ -1,14 +1,9 @@ import { StatePath, Step, TraversalOptions } from '@xstate/graph'; import { EventObject, - MachineConfig, - MachineTypes, StateNodeConfig, TransitionConfig, - TypegenConstraint, - TypegenDisabled, ExtractEvent, - MachineImplementations, MachineContext, ActorLogic, ParameterizedObject, @@ -23,33 +18,11 @@ export type GetPathsOptions< TEvent extends EventObject, TInput > = Partial< - TraversalOptions & { + TraversalOptions & { pathGenerator?: PathGenerator; } >; -export interface TestMachineConfig< - TContext extends MachineContext, - TEvent extends EventObject, - TTypesMeta extends TypegenConstraint = TypegenDisabled -> extends TestStateNodeConfig { - context?: MachineConfig['context']; - types?: MachineTypes< - TContext, - TEvent, - TODO, - TODO, - TODO, - TODO, // delays - TODO, // tags - TODO, // input - TODO, // output - TODO, // emitted - TODO, // meta - TTypesMeta - >; -} - export interface TestStateNodeConfig< TContext extends MachineContext, TEvent extends EventObject @@ -83,26 +56,6 @@ export interface TestStateNodeConfig< states?: Record>; } -export type TestMachineOptions< - TContext extends MachineContext, - TEvent extends EventObject, - TTypesMeta extends TypegenConstraint = TypegenDisabled -> = Partial< - Pick< - MachineImplementations< - TContext, - TEvent, - any, - ParameterizedObject, - ParameterizedObject, - string, - string, - TTypesMeta - >, - 'actions' | 'guards' - > ->; - export interface TestMeta { test?: ( testContext: T, @@ -167,7 +120,6 @@ export interface TestPath< * tests the postcondition that the `state` is reached. */ test: (params: TestParam) => Promise; - testSync: (params: TestParam) => TestPathResult; } export interface TestPathResult { steps: TestStepResult[]; @@ -186,8 +138,9 @@ export type EventExecutor< export interface TestModelOptions< TSnapshot extends Snapshot, - TEvent extends EventObject -> extends TraversalOptions { + TEvent extends EventObject, + TInput +> extends TraversalOptions { stateMatcher: (state: TSnapshot, stateKey: string) => boolean; logger: { log: (msg: string) => void; @@ -247,5 +200,5 @@ export type PathGenerator< TInput > = ( behavior: ActorLogic, - options: TraversalOptions + options: TraversalOptions ) => Array>; diff --git a/packages/xstate-test/test/adjacency.test.ts b/packages/xstate-test/test/adjacency.test.ts index 2948da25f7..365cb434cd 100644 --- a/packages/xstate-test/test/adjacency.test.ts +++ b/packages/xstate-test/test/adjacency.test.ts @@ -1,8 +1,9 @@ import { createMachine } from 'xstate'; import { createTestModel } from '../src'; +import { adjacencyMapToArray } from '@xstate/graph'; -describe('model.getAdjacencyList()', () => { - it('generates an adjacency list', () => { +describe('model.getAdjacencyMap()', () => { + it('generates an adjacency map', () => { const machine = createMachine({ initial: 'standing', states: { @@ -35,12 +36,10 @@ describe('model.getAdjacencyList()', () => { const model = createTestModel(machine); expect( - model - .getAdjacencyList() - .map( - ({ state, event, nextState }) => - `Given Mario is ${state.value}, when ${event.type}, then ${nextState.value}` - ) + adjacencyMapToArray(model.getAdjacencyMap()).map( + ({ state, event, nextState }) => + `Given Mario is ${state.value}, when ${event.type}, then ${nextState.value}` + ) ).toMatchInlineSnapshot(` [ "Given Mario is standing, when left, then walking", diff --git a/packages/xstate-test/test/dieHard.test.ts b/packages/xstate-test/test/dieHard.test.ts index 4f1d40fb8f..1a1ffbc1a3 100644 --- a/packages/xstate-test/test/dieHard.test.ts +++ b/packages/xstate-test/test/dieHard.test.ts @@ -1,6 +1,5 @@ -import { StateFrom, assign, createMachine } from 'xstate'; +import { assign, createMachine, setup } from 'xstate'; import { createTestModel } from '../src/index.ts'; -import { createTestMachine } from '../src/machine'; import { getDescription } from '../src/utils'; describe('die hard example', () => { @@ -39,118 +38,89 @@ describe('die hard example', () => { this.five = this.five - poured; } } - let jugs: Jugs; - - const createDieHardModel = () => { - const dieHardMachine = createMachine( - { - types: {} as { context: DieHardContext }, - id: 'dieHard', - initial: 'pending', - context: { three: 0, five: 0 }, - states: { - pending: { - always: { - target: 'success', - guard: 'weHave4Gallons' - }, - on: { - POUR_3_TO_5: { - actions: assign(({ context }) => { - const poured = Math.min(5 - context.five, context.three); - - return { - three: context.three - poured, - five: context.five + poured - }; - }) - }, - POUR_5_TO_3: { - actions: assign(({ context }) => { - const poured = Math.min(3 - context.three, context.five); + const dieHardMachine = setup({ + types: { + context: {} as DieHardContext, + events: {} as + | { + type: 'POUR_3_TO_5'; + } + | { + type: 'POUR_5_TO_3'; + } + | { + type: 'FILL_3'; + } + | { + type: 'FILL_5'; + } + | { + type: 'EMPTY_3'; + } + | { + type: 'EMPTY_5'; + } + }, + guards: { + weHave4Gallons: ({ context }) => context.five === 4 + } + }).createMachine({ + id: 'dieHard', + initial: 'pending', + context: { three: 0, five: 0 }, + states: { + pending: { + always: { + target: 'success', + guard: 'weHave4Gallons' + }, + on: { + POUR_3_TO_5: { + actions: assign(({ context }) => { + const poured = Math.min(5 - context.five, context.three); + + return { + three: context.three - poured, + five: context.five + poured + }; + }) + }, + POUR_5_TO_3: { + actions: assign(({ context }) => { + const poured = Math.min(3 - context.three, context.five); - const res = { - three: context.three + poured, - five: context.five - poured - }; + const res = { + three: context.three + poured, + five: context.five - poured + }; - return res; - }) - }, - FILL_3: { - actions: assign({ three: 3 }) - }, - FILL_5: { - actions: assign({ five: 5 }) - }, - EMPTY_3: { - actions: assign({ three: 0 }) - }, - EMPTY_5: { - actions: assign({ five: 0 }) - } - } + return res; + }) + }, + FILL_3: { + actions: assign({ three: 3 }) }, - success: { - type: 'final' + FILL_5: { + actions: assign({ five: 5 }) + }, + EMPTY_3: { + actions: assign({ three: 0 }) + }, + EMPTY_5: { + actions: assign({ five: 0 }) } } }, - { - guards: { - weHave4Gallons: ({ context }) => context.five === 4 - } - } - ); - - const options = { - states: { - pending: (state: ReturnType<(typeof dieHardMachine)['transition']>) => { - expect(jugs.five).not.toEqual(4); - expect(jugs.three).toEqual(state.context.three); - expect(jugs.five).toEqual(state.context.five); - }, - success: () => { - expect(jugs.five).toEqual(4); - } - }, - events: { - POUR_3_TO_5: async () => { - await jugs.transferThree(); - }, - POUR_5_TO_3: async () => { - await jugs.transferFive(); - }, - EMPTY_3: async () => { - await jugs.emptyThree(); - }, - EMPTY_5: async () => { - await jugs.emptyFive(); - }, - FILL_3: async () => { - await jugs.fillThree(); - }, - FILL_5: async () => { - await jugs.fillFive(); - } + success: { + type: 'final' } - }; - - return { - model: createTestModel(dieHardMachine), - options - }; - }; - - beforeEach(() => { - jugs = new Jugs(); - jugs.version = Math.random(); + } }); - describe('testing a model (shortestPathsTo)', () => { - const dieHardModel = createDieHardModel(); + const dieHardModel = createTestModel(dieHardMachine); - const paths = dieHardModel.model.getShortestPaths({ + describe('testing a model (shortestPathsTo)', () => { + const paths = dieHardModel.getShortestPaths({ toState: (state) => state.matches('success') }); @@ -161,15 +131,48 @@ describe('die hard example', () => { paths.forEach((path) => { describe(`path ${getDescription(path.state)}`, () => { it(`path ${getDescription(path.state)}`, async () => { - await dieHardModel.model.testPath(path, dieHardModel.options); + const jugs = new Jugs(); + await dieHardModel.testPath(path, { + states: { + pending: ( + state: ReturnType<(typeof dieHardMachine)['transition']> + ) => { + expect(jugs.five).not.toEqual(4); + expect(jugs.three).toEqual(state.context.three); + expect(jugs.five).toEqual(state.context.five); + }, + success: () => { + expect(jugs.five).toEqual(4); + } + }, + events: { + POUR_3_TO_5: async () => { + await jugs.transferThree(); + }, + POUR_5_TO_3: async () => { + await jugs.transferFive(); + }, + EMPTY_3: async () => { + await jugs.emptyThree(); + }, + EMPTY_5: async () => { + await jugs.emptyFive(); + }, + FILL_3: async () => { + await jugs.fillThree(); + }, + FILL_5: async () => { + await jugs.fillFive(); + } + } + }); }); }); }); }); describe('testing a model (simplePathsTo)', () => { - const dieHardModel = createDieHardModel(); - const paths = dieHardModel.model.getSimplePaths({ + const paths = dieHardModel.getSimplePaths({ toState: (state) => state.matches('success') }); @@ -182,16 +185,48 @@ describe('die hard example', () => { path.state.value )} (${JSON.stringify(path.state.context)})`, () => { it(`path ${getDescription(path.state)}`, async () => { - await dieHardModel.model.testPath(path, dieHardModel.options); + const jugs = new Jugs(); + await dieHardModel.testPath(path, { + states: { + pending: ( + state: ReturnType<(typeof dieHardMachine)['transition']> + ) => { + expect(jugs.five).not.toEqual(4); + expect(jugs.three).toEqual(state.context.three); + expect(jugs.five).toEqual(state.context.five); + }, + success: () => { + expect(jugs.five).toEqual(4); + } + }, + events: { + POUR_3_TO_5: async () => { + await jugs.transferThree(); + }, + POUR_5_TO_3: async () => { + await jugs.transferFive(); + }, + EMPTY_3: async () => { + await jugs.emptyThree(); + }, + EMPTY_5: async () => { + await jugs.emptyFive(); + }, + FILL_3: async () => { + await jugs.fillThree(); + }, + FILL_5: async () => { + await jugs.fillFive(); + } + } + }); }); }); }); }); describe('testing a model (getPathFromEvents)', () => { - const dieHardModel = createDieHardModel(); - - const path = dieHardModel.model.getPathsFromEvents( + const path = dieHardModel.getPathsFromEvents( [ { type: 'FILL_5' }, { type: 'POUR_5_TO_3' }, @@ -207,25 +242,55 @@ describe('die hard example', () => { path.state.value )} (${JSON.stringify(path.state.context)})`, () => { it(`path ${getDescription(path.state)}`, async () => { - await dieHardModel.model.testPath(path, dieHardModel.options); + const jugs = new Jugs(); + await dieHardModel.testPath(path, { + states: { + pending: ( + state: ReturnType<(typeof dieHardMachine)['transition']> + ) => { + expect(jugs.five).not.toEqual(4); + expect(jugs.three).toEqual(state.context.three); + expect(jugs.five).toEqual(state.context.five); + }, + success: () => { + expect(jugs.five).toEqual(4); + } + }, + events: { + POUR_3_TO_5: async () => { + await jugs.transferThree(); + }, + POUR_5_TO_3: async () => { + await jugs.transferFive(); + }, + EMPTY_3: async () => { + await jugs.emptyThree(); + }, + EMPTY_5: async () => { + await jugs.emptyFive(); + }, + FILL_3: async () => { + await jugs.fillThree(); + }, + FILL_5: async () => { + await jugs.fillFive(); + } + } + }); }); }); it('should return no paths if the target does not match the last entered state', () => { - const paths = dieHardModel.model.getPathsFromEvents( - [{ type: 'FILL_5' }], - { - toState: (state) => state.matches('success') - } - ); + const paths = dieHardModel.getPathsFromEvents([{ type: 'FILL_5' }], { + toState: (state) => state.matches('success') + }); expect(paths).toHaveLength(0); }); }); describe('.testPath(path)', () => { - const dieHardModel = createDieHardModel(); - const paths = dieHardModel.model.getSimplePaths({ + const paths = dieHardModel.getSimplePaths({ toState: (state) => { return state.matches('success') && state.context.three === 0; } @@ -241,7 +306,41 @@ describe('die hard example', () => { )} (${JSON.stringify(path.state.context)})`, () => { describe(`path ${getDescription(path.state)}`, () => { it(`reaches the target state`, async () => { - await dieHardModel.model.testPath(path, dieHardModel.options); + const jugs = new Jugs(); + await dieHardModel.testPath(path, { + states: { + pending: ( + state: ReturnType<(typeof dieHardMachine)['transition']> + ) => { + expect(jugs.five).not.toEqual(4); + expect(jugs.three).toEqual(state.context.three); + expect(jugs.five).toEqual(state.context.five); + }, + success: () => { + expect(jugs.five).toEqual(4); + } + }, + events: { + POUR_3_TO_5: async () => { + await jugs.transferThree(); + }, + POUR_5_TO_3: async () => { + await jugs.transferFive(); + }, + EMPTY_3: async () => { + await jugs.emptyThree(); + }, + EMPTY_5: async () => { + await jugs.emptyFive(); + }, + FILL_3: async () => { + await jugs.fillThree(); + }, + FILL_5: async () => { + await jugs.fillFive(); + } + } + }); }); }); }); @@ -250,7 +349,7 @@ describe('die hard example', () => { }); describe('error path trace', () => { describe('should return trace for failed state', () => { - const machine = createTestMachine({ + const machine = createMachine({ initial: 'first', states: { first: { diff --git a/packages/xstate-test/test/events.test.ts b/packages/xstate-test/test/events.test.ts index 4ef2daa2f1..4f58064f02 100644 --- a/packages/xstate-test/test/events.test.ts +++ b/packages/xstate-test/test/events.test.ts @@ -1,13 +1,13 @@ +import { createMachine } from 'xstate'; import { createTestModel } from '../src/index.ts'; -import { createTestMachine } from '../src/machine'; import { testUtils } from './testUtils'; describe('events', () => { - it('should execute events (`exec` property)', async () => { + it('should execute events', async () => { let executed = false; const testModel = createTestModel( - createTestMachine({ + createMachine({ initial: 'a', states: { a: { @@ -20,35 +20,7 @@ describe('events', () => { }) ); - await testUtils.testModel(testModel, { - events: { - EVENT: () => { - executed = true; - } - } - }); - - expect(executed).toBe(true); - }); - - it('should execute events (function)', async () => { - let executed = false; - - const testModel = createTestModel( - createTestMachine({ - initial: 'a', - states: { - a: { - on: { - EVENT: 'b' - } - }, - b: {} - } - }) - ); - - await testUtils.testModel(testModel, { + await testUtils.testShortestPaths(testModel, { events: { EVENT: () => { executed = true; diff --git a/packages/xstate-test/test/forbiddenAttributes.test.ts b/packages/xstate-test/test/forbiddenAttributes.test.ts index 35b5873902..8663278ba3 100644 --- a/packages/xstate-test/test/forbiddenAttributes.test.ts +++ b/packages/xstate-test/test/forbiddenAttributes.test.ts @@ -11,7 +11,7 @@ describe('Forbidden attributes', () => { expect(() => { createTestModel(machine); - }).toThrowError('Invocations on test machines are not supported'); + }).toThrow('Invocations on test machines are not supported'); }); it('Should not let you declare after on your test machine', () => { @@ -25,7 +25,7 @@ describe('Forbidden attributes', () => { expect(() => { createTestModel(machine); - }).toThrowError('After events on test machines are not supported'); + }).toThrow('After events on test machines are not supported'); }); it('Should not let you delayed actions on your machine', () => { @@ -44,6 +44,6 @@ describe('Forbidden attributes', () => { expect(() => { createTestModel(machine); - }).toThrowError('Delayed actions on test machines are not supported'); + }).toThrow('Delayed actions on test machines are not supported'); }); }); diff --git a/packages/xstate-test/test/index.test.ts b/packages/xstate-test/test/index.test.ts index 84f6036bf1..687d4e021d 100644 --- a/packages/xstate-test/test/index.test.ts +++ b/packages/xstate-test/test/index.test.ts @@ -1,21 +1,20 @@ -import { assign, createMachine } from 'xstate'; +import { assign, createMachine, setup } from 'xstate'; import { createTestModel } from '../src/index.ts'; -import { createTestMachine } from '../src/machine'; import { testUtils } from './testUtils'; describe('events', () => { it('should allow for representing many cases', async () => { - type Events = - | { type: 'CLICK_BAD' } - | { type: 'CLICK_GOOD' } - | { type: 'CLOSE' } - | { type: 'ESC' } - | { type: 'SUBMIT'; value: string }; - const feedbackMachine = createTestMachine({ - id: 'feedback', + const feedbackMachine = setup({ types: { - events: {} as Events - }, + events: {} as + | { type: 'CLICK_BAD' } + | { type: 'CLICK_GOOD' } + | { type: 'CLOSE' } + | { type: 'ESC' } + | { type: 'SUBMIT'; value: string } + } + }).createMachine({ + id: 'feedback', initial: 'question', states: { question: { @@ -65,11 +64,11 @@ describe('events', () => { ] }); - await testUtils.testModel(testModel, {}); + await testUtils.testShortestPaths(testModel, {}); }); it('should not throw an error for unimplemented events', () => { - const testMachine = createTestMachine({ + const testMachine = createMachine({ initial: 'idle', states: { idle: { @@ -82,7 +81,7 @@ describe('events', () => { const testModel = createTestModel(testMachine); expect(async () => { - await testUtils.testModel(testModel, {}); + await testUtils.testShortestPaths(testModel, {}); }).not.toThrow(); }); @@ -173,8 +172,8 @@ describe('state limiting', () => { const testModel = createTestModel(machine); const testPaths = testModel.getShortestPaths({ - filter: (state) => { - return state.context.count < 5; + stopWhen: (state) => { + return state.context.count >= 5; } }); @@ -200,7 +199,7 @@ it('prevents infinite recursion based on a provided limit', () => { const model = createTestModel(machine); expect(() => { - model.getShortestPaths({ traversalLimit: 100 }); + model.getShortestPaths({ limit: 100 }); }).toThrowErrorMatchingInlineSnapshot(`"Traversal limit exceeded"`); }); @@ -209,7 +208,7 @@ describe('test model options', () => { const testedStates: any[] = []; const model = createTestModel( - createTestMachine({ + createMachine({ initial: 'inactive', states: { inactive: { @@ -222,7 +221,7 @@ describe('test model options', () => { }) ); - await testUtils.testModel(model, { + await testUtils.testShortestPaths(model, { states: { '*': (state) => { testedStates.push(state.value); @@ -237,7 +236,7 @@ describe('test model options', () => { // https://github.com/statelyai/xstate/issues/1538 it('tests transitions', async () => { expect.assertions(2); - const machine = createTestMachine({ + const machine = createMachine({ initial: 'first', states: { first: { @@ -265,7 +264,7 @@ it('tests transitions', async () => { // https://github.com/statelyai/xstate/issues/982 it('Event in event executor should contain payload from case', async () => { - const machine = createTestMachine({ + const machine = createMachine({ initial: 'first', states: { first: { @@ -310,7 +309,7 @@ describe('state tests', () => { // a -> b (2) expect.assertions(2); - const machine = createTestMachine({ + const machine = createMachine({ initial: 'a', states: { a: { @@ -322,7 +321,7 @@ describe('state tests', () => { const model = createTestModel(machine); - await testUtils.testModel(model, { + await testUtils.testShortestPaths(model, { states: { a: (state) => { expect(state.value).toEqual('a'); @@ -340,7 +339,7 @@ describe('state tests', () => { // a -> c (2) expect.assertions(4); - const machine = createTestMachine({ + const machine = createMachine({ initial: 'a', states: { a: { @@ -353,7 +352,7 @@ describe('state tests', () => { const model = createTestModel(machine); - await testUtils.testModel(model, { + await testUtils.testShortestPaths(model, { states: { a: (state) => { expect(state.value).toEqual('a'); @@ -371,7 +370,7 @@ describe('state tests', () => { it('should test nested states', async () => { const testedStateValues: any[] = []; - const machine = createTestMachine({ + const machine = createMachine({ initial: 'a', states: { a: { @@ -388,7 +387,7 @@ describe('state tests', () => { const model = createTestModel(machine); - await testUtils.testModel(model, { + await testUtils.testShortestPaths(model, { states: { a: (state) => { testedStateValues.push('a'); diff --git a/packages/xstate-test/test/paths.test.ts b/packages/xstate-test/test/paths.test.ts index 7ce610f4cb..ad2238e918 100644 --- a/packages/xstate-test/test/paths.test.ts +++ b/packages/xstate-test/test/paths.test.ts @@ -1,9 +1,8 @@ -import { getInitialSnapshot, getNextSnapshot } from 'xstate'; +import { createMachine, getInitialSnapshot, getNextSnapshot } from 'xstate'; import { createTestModel } from '../src/index.ts'; -import { createTestMachine } from '../src/machine'; import { testUtils } from './testUtils'; -const multiPathMachine = createTestMachine({ +const multiPathMachine = createMachine({ initial: 'a', states: { a: { @@ -30,7 +29,7 @@ const multiPathMachine = createTestMachine({ describe('testModel.testPaths(...)', () => { it('custom path generators can be provided', async () => { const testModel = createTestModel( - createTestMachine({ + createMachine({ initial: 'a', states: { a: { @@ -70,7 +69,7 @@ describe('testModel.testPaths(...)', () => { describe('When the machine only has one path', () => { it('Should only follow that path', () => { - const machine = createTestMachine({ + const machine = createMachine({ initial: 'a', states: { a: { @@ -121,7 +120,7 @@ describe('path.description', () => { describe('transition coverage', () => { it('path generation should cover all transitions by default', () => { - const machine = createTestMachine({ + const machine = createMachine({ initial: 'a', states: { a: { @@ -153,7 +152,7 @@ describe('transition coverage', () => { }); it('transition coverage should consider guarded transitions', () => { - const machine = createTestMachine( + const machine = createMachine( { initial: 'a', states: { @@ -195,7 +194,7 @@ describe('transition coverage', () => { }); it('transition coverage should consider multiple transitions with the same target', () => { - const machine = createTestMachine({ + const machine = createMachine({ initial: 'a', states: { a: { @@ -229,7 +228,7 @@ describe('transition coverage', () => { }); describe('getShortestPathsTo', () => { - const machine = createTestMachine({ + const machine = createMachine({ initial: 'open', states: { open: { @@ -263,7 +262,7 @@ describe('getShortestPathsTo', () => { describe('getShortestPathsFrom', () => { it('should get shortest paths from array of paths', () => { - const machine = createTestMachine({ + const machine = createMachine({ initial: 'a', states: { a: { @@ -302,7 +301,7 @@ describe('getShortestPathsFrom', () => { describe('getSimplePathsFrom', () => { it('should get simple paths from array of paths', () => { - const machine = createTestMachine({ + const machine = createMachine({ initial: 'a', states: { a: { diff --git a/packages/xstate-test/test/states.test.ts b/packages/xstate-test/test/states.test.ts index 41438ab45d..eb6176cf92 100644 --- a/packages/xstate-test/test/states.test.ts +++ b/packages/xstate-test/test/states.test.ts @@ -1,13 +1,12 @@ -import { StateValue } from 'xstate'; +import { StateValue, createMachine } from 'xstate'; import { createTestModel } from '../src/index.ts'; -import { createTestMachine } from '../src/machine'; import { testUtils } from './testUtils'; describe('states', () => { it('should test states by key', async () => { const testedStateValues: StateValue[] = []; const testModel = createTestModel( - createTestMachine({ + createMachine({ initial: 'a', states: { a: { @@ -26,7 +25,7 @@ describe('states', () => { }) ); - await testUtils.testModel(testModel, { + await testUtils.testShortestPaths(testModel, { states: { a: (state) => { testedStateValues.push(state.value); @@ -64,7 +63,7 @@ describe('states', () => { it('should test states by ID', async () => { const testedStateValues: StateValue[] = []; const testModel = createTestModel( - createTestMachine({ + createMachine({ initial: 'a', states: { a: { @@ -90,7 +89,7 @@ describe('states', () => { }) ); - await testUtils.testModel(testModel, { + await testUtils.testShortestPaths(testModel, { states: { '#state_a': (state) => { testedStateValues.push(state.value); diff --git a/packages/xstate-test/test/sync.test.ts b/packages/xstate-test/test/sync.test.ts deleted file mode 100644 index 183ae59db3..0000000000 --- a/packages/xstate-test/test/sync.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createMachine } from 'xstate'; -import { createTestModel } from '../src/index.ts'; - -const machine = createMachine({ - initial: 'a', - states: { - a: { - on: { - EVENT: 'b' - } - }, - b: {} - } -}); - -const promiseStateModel = createTestModel(machine); - -const promiseEventModel = createTestModel(machine); - -const syncModel = createTestModel(machine); - -describe('.testPathSync', () => { - it('Should error if it encounters a promise in a state', () => { - expect(() => - promiseStateModel.getShortestPaths().forEach((path) => - promiseStateModel.testPathSync(path, { - states: { - a: async () => {}, - b: () => {} - }, - events: { - EVENT: () => {} - } - }) - ) - ).toThrowError( - `The test for 'a' returned a promise - did you mean to use the sync method?` - ); - }); - - it('Should error if it encounters a promise in an event', () => { - expect(() => - promiseEventModel.getShortestPaths().forEach((path) => - promiseEventModel.testPathSync(path, { - states: { - a: () => {}, - b: () => {} - }, - events: { - EVENT: async () => {} - } - }) - ) - ).toThrowError( - `The event 'EVENT' returned a promise - did you mean to use the sync method?` - ); - }); - - it('Should succeed if it encounters no promises', () => { - expect(() => - syncModel.getShortestPaths().forEach((path) => - syncModel.testPathSync(path, { - states: { - a: () => {}, - b: () => {} - }, - events: { - EVENT: () => {} - } - }) - ) - ).not.toThrow(); - }); -}); diff --git a/packages/xstate-test/test/testModel.test.ts b/packages/xstate-test/test/testModel.test.ts index ba09cf0e77..1c5c198525 100644 --- a/packages/xstate-test/test/testModel.test.ts +++ b/packages/xstate-test/test/testModel.test.ts @@ -77,4 +77,46 @@ describe('custom test models', () => { expect(testedStateKeys).toContain('even'); expect(testedStateKeys).toContain('odd'); }); + + it('works with input', async () => { + const testedStateKeys: string[] = []; + + const transition = fromTransition( + (value, event) => { + if (event.type === 'even') { + return value / 2; + } else { + return value * 3 + 1; + } + }, + ({ input }: { input: number }) => input + ); + + const model = new TestModel(transition, { + input: 5, + events: (state) => { + if (state.context % 2 === 0) { + return [{ type: 'even' }]; + } + return [{ type: 'odd' }]; + }, + stateMatcher: (state, key) => { + if (key === 'even') { + return state.context % 2 === 0; + } + if (key === 'odd') { + return state.context % 2 === 1; + } + return false; + } + }); + + const paths = model.getShortestPaths({ + toState: (state) => state.context === 1 + }); + + expect(paths[0]!.steps.map((s) => s.state.context)).toEqual([ + 5, 16, 8, 4, 2, 1 + ]); + }); }); diff --git a/packages/xstate-test/test/testUtils.ts b/packages/xstate-test/test/testUtils.ts index dd75ac694e..68a5a56ac6 100644 --- a/packages/xstate-test/test/testUtils.ts +++ b/packages/xstate-test/test/testUtils.ts @@ -2,7 +2,7 @@ import { EventObject, Snapshot } from 'xstate'; import { TestModel } from '../src/TestModel'; import { TestParam, TestPath } from '../src/types'; -async function testModel< +async function testShortestPaths< TSnapshot extends Snapshot, TEvent extends EventObject, TInput @@ -26,5 +26,5 @@ async function testPaths< export const testUtils = { testPaths, - testModel + testShortestPaths };