@xstate/graph
Advanced tools
Comparing version 2.0.0 to 2.0.1
export * from "../src/index.js"; | ||
//# sourceMappingURL=xstate-graph.cjs.d.ts.map | ||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoieHN0YXRlLWdyYXBoLmNqcy5kLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vc3JjL2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBIn0= |
@@ -5,6 +5,6 @@ import { EventObject, AnyStateMachine, AnyActorLogic, EventFromLogic, Snapshot, InputFrom } from 'xstate'; | ||
* Returns all state nodes of the given `node`. | ||
* | ||
* @param stateNode State node to recursively get child state nodes from | ||
*/ | ||
export declare function getStateNodes(stateNode: AnyStateNode | AnyStateMachine): AnyStateNode[]; | ||
export declare function getChildren(stateNode: AnyStateNode): AnyStateNode[]; | ||
export declare function serializeSnapshot(snapshot: Snapshot<any>): SerializedSnapshot; | ||
@@ -11,0 +11,0 @@ export declare function serializeEvent<TEvent extends EventObject>(event: TEvent): SerializedEvent; |
@@ -0,9 +1,9 @@ | ||
export { TestModel, createTestModel } from "./TestModel.js"; | ||
export { adjacencyMapToArray, getAdjacencyMap } from "./adjacency.js"; | ||
export { getStateNodes, joinPaths, serializeEvent, serializeSnapshot, toDirectedGraph } from "./graph.js"; | ||
export type { AdjacencyMap, AdjacencyValue } from "./graph.js"; | ||
export { getStateNodes, serializeEvent, serializeSnapshot, toDirectedGraph, joinPaths } from "./graph.js"; | ||
export { getSimplePaths } from "./simplePaths.js"; | ||
export { getShortestPaths } from "./shortestPaths.js"; | ||
export { getPathsFromEvents } from "./pathFromEvents.js"; | ||
export { getAdjacencyMap, adjacencyMapToArray } from "./adjacency.js"; | ||
export { TestModel, createTestModel } from "./TestModel.js"; | ||
export * from "./pathGenerators.js"; | ||
export { getShortestPaths } from "./shortestPaths.js"; | ||
export { getSimplePaths } from "./simplePaths.js"; | ||
export * from "./types.js"; |
@@ -6,4 +6,4 @@ import type { AdjacencyMap, StatePath, Step, TraversalOptions } from "../dist/xstate-graph.cjs.js"; | ||
/** | ||
* Whether to allow deduplicate paths so that paths that are contained by longer paths | ||
* are included. | ||
* Whether to allow deduplicate paths so that paths that are contained by | ||
* longer paths are included. | ||
* | ||
@@ -15,7 +15,7 @@ * @default false | ||
/** | ||
* Creates a test model that represents an abstract model of a | ||
* system under test (SUT). | ||
* 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. | ||
* The test model is used to generate test paths, which are used to verify that | ||
* states in the model are reachable in the SUT. | ||
*/ | ||
@@ -36,4 +36,4 @@ export declare class TestModel<TSnapshot extends Snapshot<unknown>, TEvent extends EventObject, TInput> { | ||
/** | ||
* An array of adjacencies, which are objects that represent each `state` with the `nextState` | ||
* given the `event`. | ||
* An array of adjacencies, which are objects that represent each `state` with | ||
* the `nextState` given the `event`. | ||
*/ | ||
@@ -49,7 +49,7 @@ getAdjacencyMap(): AdjacencyMap<TSnapshot, TEvent>; | ||
/** | ||
* Creates a test model that represents an abstract model of a | ||
* system under test (SUT). | ||
* 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. | ||
* The test model is used to generate test paths, which are used to verify that | ||
* states in the `machine` are reachable in the SUT. | ||
* | ||
@@ -61,3 +61,3 @@ * @example | ||
* TOGGLE: { | ||
* exec: async page => { | ||
* exec: async (page) => { | ||
* await page.click('input'); | ||
@@ -71,6 +71,7 @@ * } | ||
* @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: [...]}`) | ||
* | ||
* - `events`: an object mapping string event types (e.g., `SUBMIT`) to an event | ||
* test config (e.g., `{exec: () => {...}, cases: [...]}`) | ||
*/ | ||
export declare function createTestModel<TMachine extends AnyStateMachine>(machine: TMachine, options?: Partial<TestModelOptions<SnapshotFrom<TMachine>, EventFromLogic<TMachine>, InputFrom<TMachine>>>): TestModel<SnapshotFrom<TMachine>, EventFromLogic<TMachine>, unknown>; | ||
export {}; |
@@ -29,5 +29,3 @@ import { EventObject, StateValue, StateNode, TransitionDefinition, Snapshot, MachineContext, ActorLogic, MachineSnapshot, ParameterizedObject, StateNodeConfig, TODO, TransitionConfig } from 'xstate'; | ||
children: DirectedGraphNode[]; | ||
/** | ||
* The edges representing all transitions from this `stateNode`. | ||
*/ | ||
/** The edges representing all transitions from this `stateNode`. */ | ||
edges: DirectedGraphEdge[]; | ||
@@ -45,23 +43,16 @@ }, { | ||
export interface StatePlan<TSnapshot extends Snapshot<unknown>, TEvent extends EventObject> { | ||
/** | ||
* The target state. | ||
*/ | ||
/** The target state. */ | ||
state: TSnapshot; | ||
/** | ||
* The paths that reach the target state. | ||
*/ | ||
/** The paths that reach the target state. */ | ||
paths: Array<StatePath<TSnapshot, TEvent>>; | ||
} | ||
export interface StatePath<TSnapshot extends Snapshot<unknown>, TEvent extends EventObject> { | ||
/** | ||
* The ending state of the path. | ||
*/ | ||
/** The ending state of the path. */ | ||
state: TSnapshot; | ||
/** | ||
* The ordered array of state-event pairs (steps) which reach the ending `state`. | ||
* The ordered array of state-event pairs (steps) which reach the ending | ||
* `state`. | ||
*/ | ||
steps: Steps<TSnapshot, TEvent>; | ||
/** | ||
* The combined weight of all steps in the path. | ||
*/ | ||
/** The combined weight of all steps in the path. */ | ||
weight: number; | ||
@@ -73,9 +64,5 @@ } | ||
export interface Step<TSnapshot extends Snapshot<unknown>, TEvent extends EventObject> { | ||
/** | ||
* The event that resulted in the current state | ||
*/ | ||
/** The event that resulted in the current state */ | ||
event: TEvent; | ||
/** | ||
* The current state after taking the event. | ||
*/ | ||
/** The current state after taking the event. */ | ||
state: TSnapshot; | ||
@@ -111,4 +98,4 @@ } | ||
/** | ||
* The maximum number of traversals to perform when calculating | ||
* the state transition adjacency map. | ||
* The maximum number of traversals to perform when calculating the state | ||
* transition adjacency map. | ||
* | ||
@@ -119,6 +106,3 @@ * @default `Infinity` | ||
fromState: TSnapshot | undefined; | ||
/** | ||
* When true, traversal of the adjacency map will stop | ||
* for that current state. | ||
*/ | ||
/** When true, traversal of the adjacency map will stop for that current state. */ | ||
stopWhen: ((state: TSnapshot) => boolean) | undefined; | ||
@@ -141,4 +125,6 @@ toState: ((state: TSnapshot) => boolean) | undefined; | ||
export interface TestMeta<T, TContext extends MachineContext> { | ||
test?: (testContext: T, state: MachineSnapshot<TContext, any, any, any, any, any, any>) => Promise<void> | void; | ||
description?: string | ((state: MachineSnapshot<TContext, any, any, any, any, any, any>) => string); | ||
test?: (testContext: T, state: MachineSnapshot<TContext, any, any, any, any, any, any, // TMeta | ||
any>) => Promise<void> | void; | ||
description?: string | ((state: MachineSnapshot<TContext, any, any, any, any, any, any, // TMeta | ||
any>) => string); | ||
skip?: boolean; | ||
@@ -169,4 +155,4 @@ } | ||
/** | ||
* Tests and executes each step in `steps` sequentially, and then | ||
* tests the postcondition that the `state` is reached. | ||
* Tests and executes each step in `steps` sequentially, and then tests the | ||
* postcondition that the `state` is reached. | ||
*/ | ||
@@ -181,4 +167,4 @@ test: (params: TestParam<TSnapshot, TEvent>) => Promise<TestPathResult>; | ||
/** | ||
* Executes an effect using the `testContext` and `event` | ||
* that triggers the represented `event`. | ||
* Executes an effect using the `testContext` and `event` that triggers the | ||
* represented `event`. | ||
*/ | ||
@@ -196,3 +182,4 @@ export type EventExecutor<TSnapshot extends Snapshot<unknown>, TEvent extends EventObject> = (step: Step<TSnapshot, TEvent>) => Promise<any> | void; | ||
TODO> { | ||
test?: (state: MachineSnapshot<TContext, TEvent, any, any, any, any, any>, testContext: TTestContext) => void; | ||
test?: (state: MachineSnapshot<TContext, TEvent, any, any, any, any, any, // TMeta | ||
any>, testContext: TTestContext) => void; | ||
} | ||
@@ -199,0 +186,0 @@ export type TestTransitionsConfig<TContext extends MachineContext, TEvent extends EventObject, TTestContext> = { |
export * from "./declarations/src/index"; | ||
//# sourceMappingURL=xstate-graph.cjs.d.ts.map | ||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoieHN0YXRlLWdyYXBoLmNqcy5kLnRzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi9kZWNsYXJhdGlvbnMvc3JjL2luZGV4LmQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEifQ== |
@@ -5,5 +5,395 @@ 'use strict'; | ||
var graph = require('@xstate/graph'); | ||
var xstate = require('xstate'); | ||
var graph = require('@xstate/graph'); | ||
function simpleStringify(value) { | ||
return JSON.stringify(value); | ||
} | ||
function formatPathTestResult(path, testPathResult, options) { | ||
const resolvedOptions = { | ||
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; | ||
} | ||
function getDescription(snapshot) { | ||
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]; | ||
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(); | ||
} | ||
/** | ||
* Deduplicates your paths so that A -> B is not executed separately to A -> B | ||
* -> C | ||
*/ | ||
const deduplicatePaths = (paths, serializeEvent = simpleStringify) => { | ||
/** Put all paths on the same level so we can dedup them */ | ||
const allPathsWithEventSequence = []; | ||
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 = []; | ||
/** 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); | ||
}; | ||
const createShortestPathsGen = () => (logic, defaultOptions) => { | ||
const paths = graph.getShortestPaths(logic, defaultOptions); | ||
return paths; | ||
}; | ||
const createSimplePathsGen = () => (logic, defaultOptions) => { | ||
const paths = graph.getSimplePaths(logic, defaultOptions); | ||
return paths; | ||
}; | ||
const validateState = state => { | ||
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.delay === 'number') { | ||
throw new Error('Delayed actions on test machines are not supported'); | ||
} | ||
}); | ||
for (const child of Object.values(state.states)) { | ||
validateState(child); | ||
} | ||
}; | ||
const validateMachine = machine => { | ||
validateState(machine.root); | ||
}; | ||
/** | ||
* 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. | ||
*/ | ||
class TestModel { | ||
getDefaultOptions() { | ||
return { | ||
serializeState: state => simpleStringify(state), | ||
serializeEvent: event => simpleStringify(event), | ||
// 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(testLogic, options) { | ||
this.testLogic = testLogic; | ||
this.options = void 0; | ||
this.defaultTraversalOptions = void 0; | ||
this._toTestPath = statePath => { | ||
function formatEvent(event) { | ||
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 => this.testPath(statePath, params), | ||
description: xstate.isMachineSnapshot(statePath.state) ? `Reaches ${getDescription(statePath.state).trim()}: ${eventsString}` : JSON.stringify(statePath.state) | ||
}; | ||
}; | ||
this.options = { | ||
...this.getDefaultOptions(), | ||
...options | ||
}; | ||
} | ||
getPaths(pathGenerator, options) { | ||
const allowDuplicatePaths = options?.allowDuplicatePaths ?? false; | ||
const paths = pathGenerator(this.testLogic, this._resolveOptions(options)); | ||
return (allowDuplicatePaths ? paths : deduplicatePaths(paths)).map(this._toTestPath); | ||
} | ||
getShortestPaths(options) { | ||
return this.getPaths(createShortestPathsGen(), options); | ||
} | ||
getShortestPathsFrom(paths, options) { | ||
const resultPaths = []; | ||
for (const path of paths) { | ||
const shortestPaths = this.getShortestPaths({ | ||
...options, | ||
fromState: path.state | ||
}); | ||
for (const shortestPath of shortestPaths) { | ||
resultPaths.push(this._toTestPath(graph.joinPaths(path, shortestPath))); | ||
} | ||
} | ||
return resultPaths; | ||
} | ||
getSimplePaths(options) { | ||
return this.getPaths(createSimplePathsGen(), options); | ||
} | ||
getSimplePathsFrom(paths, options) { | ||
const resultPaths = []; | ||
for (const path of paths) { | ||
const shortestPaths = this.getSimplePaths({ | ||
...options, | ||
fromState: path.state | ||
}); | ||
for (const shortestPath of shortestPaths) { | ||
resultPaths.push(this._toTestPath(graph.joinPaths(path, shortestPath))); | ||
} | ||
} | ||
return resultPaths; | ||
} | ||
getPathsFromEvents(events, options) { | ||
const paths = graph.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`. | ||
*/ | ||
getAdjacencyMap() { | ||
const adjMap = graph.getAdjacencyMap(this.testLogic, this.options); | ||
return adjMap; | ||
} | ||
async testPath(path, params, options) { | ||
const testPathResult = { | ||
steps: [], | ||
state: { | ||
error: null | ||
} | ||
}; | ||
try { | ||
for (const step of path.steps) { | ||
const testStepResult = { | ||
step, | ||
state: { | ||
error: null | ||
}, | ||
event: { | ||
error: null | ||
} | ||
}; | ||
testPathResult.steps.push(testStepResult); | ||
try { | ||
await this.testTransition(params, step); | ||
} catch (err) { | ||
testStepResult.event.error = err; | ||
throw err; | ||
} | ||
try { | ||
await this.testState(params, step.state, options); | ||
} catch (err) { | ||
testStepResult.state.error = err; | ||
throw err; | ||
} | ||
} | ||
} catch (err) { | ||
// TODO: make option | ||
err.message += formatPathTestResult(path, testPathResult, this.options); | ||
throw err; | ||
} | ||
return testPathResult; | ||
} | ||
async testState(params, state, options) { | ||
const resolvedOptions = this._resolveOptions(options); | ||
const stateTestKeys = this._getStateTestKeys(params, state, resolvedOptions); | ||
for (const stateTestKey of stateTestKeys) { | ||
await params.states?.[stateTestKey](state); | ||
} | ||
} | ||
_getStateTestKeys(params, state, resolvedOptions) { | ||
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; | ||
} | ||
_getEventExec(params, step) { | ||
const eventExec = params.events?.[step.event.type]; | ||
return eventExec; | ||
} | ||
async testTransition(params, step) { | ||
const eventExec = this._getEventExec(params, step); | ||
await eventExec?.(step); | ||
} | ||
_resolveOptions(options) { | ||
return { | ||
...this.defaultTraversalOptions, | ||
...this.options, | ||
...options | ||
}; | ||
} | ||
} | ||
function stateValuesEqual(a, b) { | ||
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, event, previousSnapshot, { | ||
serializeEvent | ||
}) { | ||
// 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: [...]}`) | ||
*/ | ||
function createTestModel(machine, options) { | ||
validateMachine(machine); | ||
const serializeEvent = options?.serializeEvent ?? simpleStringify; | ||
const serializeTransition = options?.serializeTransition ?? serializeMachineTransition; | ||
const { | ||
events: getEvents, | ||
...otherOptions | ||
} = options ?? {}; | ||
const testModel = new TestModel(machine, { | ||
serializeState: (state, event, prevState) => { | ||
// Only consider the `state` if `serializeTransition()` is opted out (empty string) | ||
return `${graph.serializeSnapshot(state)}${serializeTransition(state, event, prevState, { | ||
serializeEvent | ||
})}`; | ||
}, | ||
stateMatcher: (state, key) => { | ||
return key.startsWith('#') ? state._nodes.includes(machine.getStateNodeById(key)) : state.matches(key); | ||
}, | ||
events: state => { | ||
const events = typeof getEvents === 'function' ? getEvents(state) : getEvents ?? []; | ||
return xstate.__unsafe_getAllOwnEventDescriptors(state).flatMap(eventType => { | ||
if (events.some(e => e.type === eventType)) { | ||
return events.filter(e => e.type === eventType); | ||
} | ||
return [{ | ||
type: eventType | ||
}]; // TODO: fix types | ||
}); | ||
}, | ||
...otherOptions | ||
}); | ||
return testModel; | ||
} | ||
function createMockActorScope() { | ||
@@ -26,2 +416,3 @@ const emptyActor = xstate.createEmptyActor(); | ||
* Returns all state nodes of the given `node`. | ||
* | ||
* @param stateNode State node to recursively get child state nodes from | ||
@@ -285,67 +676,55 @@ */ | ||
function getSimplePaths(logic, options) { | ||
const resolvedOptions = resolveTraversalOptions(logic, options); | ||
function isMachine(value) { | ||
return !!value && '__xstatenode' in value; | ||
} | ||
function getPathsFromEvents(logic, events, options) { | ||
const resolvedOptions = resolveTraversalOptions(logic, { | ||
events, | ||
...options | ||
}, isMachine(logic) ? createDefaultMachineOptions(logic) : createDefaultLogicOptions()); | ||
const actorScope = createMockActorScope(); | ||
const fromState = resolvedOptions.fromState ?? logic.getInitialSnapshot(actorScope, options?.input); | ||
const serializeState = resolvedOptions.serializeState; | ||
const fromState = resolvedOptions.fromState ?? logic.getInitialSnapshot(actorScope, | ||
// TODO: fix this | ||
options?.input); | ||
const { | ||
serializeState, | ||
serializeEvent | ||
} = resolvedOptions; | ||
const adjacency = getAdjacencyMap(logic, resolvedOptions); | ||
const stateMap = new Map(); | ||
const visitCtx = { | ||
vertices: new Set(), | ||
edges: new Set() | ||
}; | ||
const steps = []; | ||
const pathMap = {}; | ||
function util(fromStateSerial, toStateSerial) { | ||
const fromState = stateMap.get(fromStateSerial); | ||
visitCtx.vertices.add(fromStateSerial); | ||
if (fromStateSerial === toStateSerial) { | ||
if (!pathMap[toStateSerial]) { | ||
pathMap[toStateSerial] = { | ||
state: stateMap.get(toStateSerial), | ||
paths: [] | ||
}; | ||
} | ||
const toStatePlan = pathMap[toStateSerial]; | ||
const path2 = { | ||
state: fromState, | ||
weight: steps.length, | ||
steps: [...steps] | ||
}; | ||
toStatePlan.paths.push(path2); | ||
} else { | ||
for (const serializedEvent of Object.keys(adjacency[fromStateSerial].transitions)) { | ||
const { | ||
state: nextState, | ||
event: subEvent | ||
} = adjacency[fromStateSerial].transitions[serializedEvent]; | ||
if (!(serializedEvent in adjacency[fromStateSerial].transitions)) { | ||
continue; | ||
} | ||
const prevState = stateMap.get(fromStateSerial); | ||
const nextStateSerial = serializeState(nextState, subEvent, prevState); | ||
stateMap.set(nextStateSerial, nextState); | ||
if (!visitCtx.vertices.has(nextStateSerial)) { | ||
visitCtx.edges.add(serializedEvent); | ||
steps.push({ | ||
state: stateMap.get(fromStateSerial), | ||
event: subEvent | ||
}); | ||
util(nextStateSerial, toStateSerial); | ||
} | ||
} | ||
const serializedFromState = serializeState(fromState, undefined, undefined); | ||
stateMap.set(serializedFromState, fromState); | ||
let stateSerial = serializedFromState; | ||
let state = fromState; | ||
for (const event of events) { | ||
steps.push({ | ||
state: stateMap.get(stateSerial), | ||
event | ||
}); | ||
const eventSerial = serializeEvent(event); | ||
const { | ||
state: nextState, | ||
event: _nextEvent | ||
} = adjacency[stateSerial].transitions[eventSerial]; | ||
if (!nextState) { | ||
throw new Error(`Invalid transition from ${stateSerial} with ${eventSerial}`); | ||
} | ||
steps.pop(); | ||
visitCtx.vertices.delete(fromStateSerial); | ||
const prevState = stateMap.get(stateSerial); | ||
const nextStateSerial = serializeState(nextState, event, prevState); | ||
stateMap.set(nextStateSerial, nextState); | ||
stateSerial = nextStateSerial; | ||
state = nextState; | ||
} | ||
const fromStateSerial = serializeState(fromState, undefined); | ||
stateMap.set(fromStateSerial, fromState); | ||
for (const nextStateSerial of Object.keys(adjacency)) { | ||
util(fromStateSerial, nextStateSerial); | ||
// If it is expected to reach a specific state (`toState`) and that state | ||
// isn't reached, there are no paths | ||
if (resolvedOptions.toState && !resolvedOptions.toState(state)) { | ||
return []; | ||
} | ||
const simplePaths = Object.values(pathMap).flatMap(p => p.paths); | ||
if (resolvedOptions.toState) { | ||
return simplePaths.filter(path => resolvedOptions.toState(path.state)).map(alterPath); | ||
} | ||
return simplePaths.map(alterPath); | ||
return [alterPath({ | ||
state, | ||
steps, | ||
weight: steps.length | ||
})]; | ||
} | ||
@@ -441,451 +820,69 @@ | ||
function isMachine(value) { | ||
return !!value && '__xstatenode' in value; | ||
} | ||
function getPathsFromEvents(logic, events, options) { | ||
const resolvedOptions = resolveTraversalOptions(logic, { | ||
events, | ||
...options | ||
}, isMachine(logic) ? createDefaultMachineOptions(logic) : createDefaultLogicOptions()); | ||
function getSimplePaths(logic, options) { | ||
const resolvedOptions = resolveTraversalOptions(logic, options); | ||
const actorScope = createMockActorScope(); | ||
const fromState = resolvedOptions.fromState ?? logic.getInitialSnapshot(actorScope, | ||
// TODO: fix this | ||
options?.input); | ||
const { | ||
serializeState, | ||
serializeEvent | ||
} = resolvedOptions; | ||
const fromState = resolvedOptions.fromState ?? logic.getInitialSnapshot(actorScope, options?.input); | ||
const serializeState = resolvedOptions.serializeState; | ||
const adjacency = getAdjacencyMap(logic, resolvedOptions); | ||
const stateMap = new Map(); | ||
const visitCtx = { | ||
vertices: new Set(), | ||
edges: new Set() | ||
}; | ||
const steps = []; | ||
const serializedFromState = serializeState(fromState, undefined, undefined); | ||
stateMap.set(serializedFromState, fromState); | ||
let stateSerial = serializedFromState; | ||
let state = fromState; | ||
for (const event of events) { | ||
steps.push({ | ||
state: stateMap.get(stateSerial), | ||
event | ||
}); | ||
const eventSerial = serializeEvent(event); | ||
const { | ||
state: nextState, | ||
event: _nextEvent | ||
} = adjacency[stateSerial].transitions[eventSerial]; | ||
if (!nextState) { | ||
throw new Error(`Invalid transition from ${stateSerial} with ${eventSerial}`); | ||
} | ||
const prevState = stateMap.get(stateSerial); | ||
const nextStateSerial = serializeState(nextState, event, prevState); | ||
stateMap.set(nextStateSerial, nextState); | ||
stateSerial = nextStateSerial; | ||
state = nextState; | ||
} | ||
// If it is expected to reach a specific state (`toState`) and that state | ||
// isn't reached, there are no paths | ||
if (resolvedOptions.toState && !resolvedOptions.toState(state)) { | ||
return []; | ||
} | ||
return [alterPath({ | ||
state, | ||
steps, | ||
weight: steps.length | ||
})]; | ||
} | ||
function simpleStringify(value) { | ||
return JSON.stringify(value); | ||
} | ||
function formatPathTestResult(path, testPathResult, options) { | ||
const resolvedOptions = { | ||
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; | ||
} | ||
function getDescription(snapshot) { | ||
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]; | ||
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(); | ||
} | ||
/** | ||
* Deduplicates your paths so that A -> B | ||
* is not executed separately to A -> B -> C | ||
*/ | ||
const deduplicatePaths = (paths, serializeEvent = simpleStringify) => { | ||
/** | ||
* Put all paths on the same level so we can dedup them | ||
*/ | ||
const allPathsWithEventSequence = []; | ||
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 = []; | ||
/** | ||
* 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; | ||
} | ||
const pathMap = {}; | ||
function util(fromStateSerial, toStateSerial) { | ||
const fromState = stateMap.get(fromStateSerial); | ||
visitCtx.vertices.add(fromStateSerial); | ||
if (fromStateSerial === toStateSerial) { | ||
if (!pathMap[toStateSerial]) { | ||
pathMap[toStateSerial] = { | ||
state: stateMap.get(toStateSerial), | ||
paths: [] | ||
}; | ||
} | ||
// 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); | ||
}; | ||
const createShortestPathsGen = () => (logic, defaultOptions) => { | ||
const paths = graph.getShortestPaths(logic, defaultOptions); | ||
return paths; | ||
}; | ||
const createSimplePathsGen = () => (logic, defaultOptions) => { | ||
const paths = graph.getSimplePaths(logic, defaultOptions); | ||
return paths; | ||
}; | ||
const validateState = state => { | ||
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.delay === 'number') { | ||
throw new Error('Delayed actions on test machines are not supported'); | ||
} | ||
}); | ||
for (const child of Object.values(state.states)) { | ||
validateState(child); | ||
} | ||
}; | ||
const validateMachine = machine => { | ||
validateState(machine.root); | ||
}; | ||
/** | ||
* 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. | ||
*/ | ||
class TestModel { | ||
getDefaultOptions() { | ||
return { | ||
serializeState: state => simpleStringify(state), | ||
serializeEvent: event => simpleStringify(event), | ||
// 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(testLogic, options) { | ||
this.testLogic = testLogic; | ||
this.options = void 0; | ||
this.defaultTraversalOptions = void 0; | ||
this._toTestPath = statePath => { | ||
function formatEvent(event) { | ||
const toStatePlan = pathMap[toStateSerial]; | ||
const path2 = { | ||
state: fromState, | ||
weight: steps.length, | ||
steps: [...steps] | ||
}; | ||
toStatePlan.paths.push(path2); | ||
} else { | ||
for (const serializedEvent of Object.keys(adjacency[fromStateSerial].transitions)) { | ||
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 => this.testPath(statePath, params), | ||
description: xstate.isMachineSnapshot(statePath.state) ? `Reaches ${getDescription(statePath.state).trim()}: ${eventsString}` : JSON.stringify(statePath.state) | ||
}; | ||
}; | ||
this.options = { | ||
...this.getDefaultOptions(), | ||
...options | ||
}; | ||
} | ||
getPaths(pathGenerator, options) { | ||
const allowDuplicatePaths = options?.allowDuplicatePaths ?? false; | ||
const paths = pathGenerator(this.testLogic, this._resolveOptions(options)); | ||
return (allowDuplicatePaths ? paths : deduplicatePaths(paths)).map(this._toTestPath); | ||
} | ||
getShortestPaths(options) { | ||
return this.getPaths(createShortestPathsGen(), options); | ||
} | ||
getShortestPathsFrom(paths, options) { | ||
const resultPaths = []; | ||
for (const path of paths) { | ||
const shortestPaths = this.getShortestPaths({ | ||
...options, | ||
fromState: path.state | ||
}); | ||
for (const shortestPath of shortestPaths) { | ||
resultPaths.push(this._toTestPath(graph.joinPaths(path, shortestPath))); | ||
} | ||
} | ||
return resultPaths; | ||
} | ||
getSimplePaths(options) { | ||
return this.getPaths(createSimplePathsGen(), options); | ||
} | ||
getSimplePathsFrom(paths, options) { | ||
const resultPaths = []; | ||
for (const path of paths) { | ||
const shortestPaths = this.getSimplePaths({ | ||
...options, | ||
fromState: path.state | ||
}); | ||
for (const shortestPath of shortestPaths) { | ||
resultPaths.push(this._toTestPath(graph.joinPaths(path, shortestPath))); | ||
} | ||
} | ||
return resultPaths; | ||
} | ||
getPathsFromEvents(events, options) { | ||
const paths = graph.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`. | ||
*/ | ||
getAdjacencyMap() { | ||
const adjMap = graph.getAdjacencyMap(this.testLogic, this.options); | ||
return adjMap; | ||
} | ||
async testPath(path, params, options) { | ||
const testPathResult = { | ||
steps: [], | ||
state: { | ||
error: null | ||
} | ||
}; | ||
try { | ||
for (const step of path.steps) { | ||
const testStepResult = { | ||
step, | ||
state: { | ||
error: null | ||
}, | ||
event: { | ||
error: null | ||
} | ||
}; | ||
testPathResult.steps.push(testStepResult); | ||
try { | ||
await this.testTransition(params, step); | ||
} catch (err) { | ||
testStepResult.event.error = err; | ||
throw err; | ||
state: nextState, | ||
event: subEvent | ||
} = adjacency[fromStateSerial].transitions[serializedEvent]; | ||
if (!(serializedEvent in adjacency[fromStateSerial].transitions)) { | ||
continue; | ||
} | ||
try { | ||
await this.testState(params, step.state, options); | ||
} catch (err) { | ||
testStepResult.state.error = err; | ||
throw err; | ||
const prevState = stateMap.get(fromStateSerial); | ||
const nextStateSerial = serializeState(nextState, subEvent, prevState); | ||
stateMap.set(nextStateSerial, nextState); | ||
if (!visitCtx.vertices.has(nextStateSerial)) { | ||
visitCtx.edges.add(serializedEvent); | ||
steps.push({ | ||
state: stateMap.get(fromStateSerial), | ||
event: subEvent | ||
}); | ||
util(nextStateSerial, toStateSerial); | ||
} | ||
} | ||
} catch (err) { | ||
// TODO: make option | ||
err.message += formatPathTestResult(path, testPathResult, this.options); | ||
throw err; | ||
} | ||
return testPathResult; | ||
steps.pop(); | ||
visitCtx.vertices.delete(fromStateSerial); | ||
} | ||
async testState(params, state, options) { | ||
const resolvedOptions = this._resolveOptions(options); | ||
const stateTestKeys = this._getStateTestKeys(params, state, resolvedOptions); | ||
for (const stateTestKey of stateTestKeys) { | ||
await params.states?.[stateTestKey](state); | ||
} | ||
const fromStateSerial = serializeState(fromState, undefined); | ||
stateMap.set(fromStateSerial, fromState); | ||
for (const nextStateSerial of Object.keys(adjacency)) { | ||
util(fromStateSerial, nextStateSerial); | ||
} | ||
_getStateTestKeys(params, state, resolvedOptions) { | ||
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; | ||
const simplePaths = Object.values(pathMap).flatMap(p => p.paths); | ||
if (resolvedOptions.toState) { | ||
return simplePaths.filter(path => resolvedOptions.toState(path.state)).map(alterPath); | ||
} | ||
_getEventExec(params, step) { | ||
const eventExec = params.events?.[step.event.type]; | ||
return eventExec; | ||
} | ||
async testTransition(params, step) { | ||
const eventExec = this._getEventExec(params, step); | ||
await eventExec?.(step); | ||
} | ||
_resolveOptions(options) { | ||
return { | ||
...this.defaultTraversalOptions, | ||
...this.options, | ||
...options | ||
}; | ||
} | ||
return simplePaths.map(alterPath); | ||
} | ||
function stateValuesEqual(a, b) { | ||
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, event, previousSnapshot, { | ||
serializeEvent | ||
}) { | ||
// 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: [...]}`) | ||
*/ | ||
function createTestModel(machine, options) { | ||
validateMachine(machine); | ||
const serializeEvent = options?.serializeEvent ?? simpleStringify; | ||
const serializeTransition = options?.serializeTransition ?? serializeMachineTransition; | ||
const { | ||
events: getEvents, | ||
...otherOptions | ||
} = options ?? {}; | ||
const testModel = new TestModel(machine, { | ||
serializeState: (state, event, prevState) => { | ||
// Only consider the `state` if `serializeTransition()` is opted out (empty string) | ||
return `${graph.serializeSnapshot(state)}${serializeTransition(state, event, prevState, { | ||
serializeEvent | ||
})}`; | ||
}, | ||
stateMatcher: (state, key) => { | ||
return key.startsWith('#') ? state._nodes.includes(machine.getStateNodeById(key)) : state.matches(key); | ||
}, | ||
events: state => { | ||
const events = typeof getEvents === 'function' ? getEvents(state) : getEvents ?? []; | ||
return xstate.__unsafe_getAllOwnEventDescriptors(state).flatMap(eventType => { | ||
if (events.some(e => e.type === eventType)) { | ||
return events.filter(e => e.type === eventType); | ||
} | ||
return [{ | ||
type: eventType | ||
}]; // TODO: fix types | ||
}); | ||
}, | ||
...otherOptions | ||
}); | ||
return testModel; | ||
} | ||
exports.TestModel = TestModel; | ||
@@ -892,0 +889,0 @@ exports.adjacencyMapToArray = adjacencyMapToArray; |
@@ -1,4 +0,394 @@ | ||
import { createEmptyActor, StateMachine, __unsafe_getAllOwnEventDescriptors, isMachineSnapshot } from 'xstate'; | ||
import { getShortestPaths as getShortestPaths$1, getSimplePaths as getSimplePaths$1, joinPaths as joinPaths$1, getPathsFromEvents as getPathsFromEvents$1, getAdjacencyMap as getAdjacencyMap$1, serializeSnapshot as serializeSnapshot$1 } from '@xstate/graph'; | ||
import { isMachineSnapshot, __unsafe_getAllOwnEventDescriptors, createEmptyActor, StateMachine } from 'xstate'; | ||
function simpleStringify(value) { | ||
return JSON.stringify(value); | ||
} | ||
function formatPathTestResult(path, testPathResult, options) { | ||
const resolvedOptions = { | ||
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; | ||
} | ||
function getDescription(snapshot) { | ||
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]; | ||
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(); | ||
} | ||
/** | ||
* Deduplicates your paths so that A -> B is not executed separately to A -> B | ||
* -> C | ||
*/ | ||
const deduplicatePaths = (paths, serializeEvent = simpleStringify) => { | ||
/** Put all paths on the same level so we can dedup them */ | ||
const allPathsWithEventSequence = []; | ||
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 = []; | ||
/** 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); | ||
}; | ||
const createShortestPathsGen = () => (logic, defaultOptions) => { | ||
const paths = getShortestPaths$1(logic, defaultOptions); | ||
return paths; | ||
}; | ||
const createSimplePathsGen = () => (logic, defaultOptions) => { | ||
const paths = getSimplePaths$1(logic, defaultOptions); | ||
return paths; | ||
}; | ||
const validateState = state => { | ||
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.delay === 'number') { | ||
throw new Error('Delayed actions on test machines are not supported'); | ||
} | ||
}); | ||
for (const child of Object.values(state.states)) { | ||
validateState(child); | ||
} | ||
}; | ||
const validateMachine = machine => { | ||
validateState(machine.root); | ||
}; | ||
/** | ||
* 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. | ||
*/ | ||
class TestModel { | ||
getDefaultOptions() { | ||
return { | ||
serializeState: state => simpleStringify(state), | ||
serializeEvent: event => simpleStringify(event), | ||
// 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(testLogic, options) { | ||
this.testLogic = testLogic; | ||
this.options = void 0; | ||
this.defaultTraversalOptions = void 0; | ||
this._toTestPath = statePath => { | ||
function formatEvent(event) { | ||
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 => this.testPath(statePath, params), | ||
description: isMachineSnapshot(statePath.state) ? `Reaches ${getDescription(statePath.state).trim()}: ${eventsString}` : JSON.stringify(statePath.state) | ||
}; | ||
}; | ||
this.options = { | ||
...this.getDefaultOptions(), | ||
...options | ||
}; | ||
} | ||
getPaths(pathGenerator, options) { | ||
const allowDuplicatePaths = options?.allowDuplicatePaths ?? false; | ||
const paths = pathGenerator(this.testLogic, this._resolveOptions(options)); | ||
return (allowDuplicatePaths ? paths : deduplicatePaths(paths)).map(this._toTestPath); | ||
} | ||
getShortestPaths(options) { | ||
return this.getPaths(createShortestPathsGen(), options); | ||
} | ||
getShortestPathsFrom(paths, options) { | ||
const resultPaths = []; | ||
for (const path of paths) { | ||
const shortestPaths = this.getShortestPaths({ | ||
...options, | ||
fromState: path.state | ||
}); | ||
for (const shortestPath of shortestPaths) { | ||
resultPaths.push(this._toTestPath(joinPaths$1(path, shortestPath))); | ||
} | ||
} | ||
return resultPaths; | ||
} | ||
getSimplePaths(options) { | ||
return this.getPaths(createSimplePathsGen(), options); | ||
} | ||
getSimplePathsFrom(paths, options) { | ||
const resultPaths = []; | ||
for (const path of paths) { | ||
const shortestPaths = this.getSimplePaths({ | ||
...options, | ||
fromState: path.state | ||
}); | ||
for (const shortestPath of shortestPaths) { | ||
resultPaths.push(this._toTestPath(joinPaths$1(path, shortestPath))); | ||
} | ||
} | ||
return resultPaths; | ||
} | ||
getPathsFromEvents(events, options) { | ||
const paths = getPathsFromEvents$1(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`. | ||
*/ | ||
getAdjacencyMap() { | ||
const adjMap = getAdjacencyMap$1(this.testLogic, this.options); | ||
return adjMap; | ||
} | ||
async testPath(path, params, options) { | ||
const testPathResult = { | ||
steps: [], | ||
state: { | ||
error: null | ||
} | ||
}; | ||
try { | ||
for (const step of path.steps) { | ||
const testStepResult = { | ||
step, | ||
state: { | ||
error: null | ||
}, | ||
event: { | ||
error: null | ||
} | ||
}; | ||
testPathResult.steps.push(testStepResult); | ||
try { | ||
await this.testTransition(params, step); | ||
} catch (err) { | ||
testStepResult.event.error = err; | ||
throw err; | ||
} | ||
try { | ||
await this.testState(params, step.state, options); | ||
} catch (err) { | ||
testStepResult.state.error = err; | ||
throw err; | ||
} | ||
} | ||
} catch (err) { | ||
// TODO: make option | ||
err.message += formatPathTestResult(path, testPathResult, this.options); | ||
throw err; | ||
} | ||
return testPathResult; | ||
} | ||
async testState(params, state, options) { | ||
const resolvedOptions = this._resolveOptions(options); | ||
const stateTestKeys = this._getStateTestKeys(params, state, resolvedOptions); | ||
for (const stateTestKey of stateTestKeys) { | ||
await params.states?.[stateTestKey](state); | ||
} | ||
} | ||
_getStateTestKeys(params, state, resolvedOptions) { | ||
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; | ||
} | ||
_getEventExec(params, step) { | ||
const eventExec = params.events?.[step.event.type]; | ||
return eventExec; | ||
} | ||
async testTransition(params, step) { | ||
const eventExec = this._getEventExec(params, step); | ||
await eventExec?.(step); | ||
} | ||
_resolveOptions(options) { | ||
return { | ||
...this.defaultTraversalOptions, | ||
...this.options, | ||
...options | ||
}; | ||
} | ||
} | ||
function stateValuesEqual(a, b) { | ||
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, event, previousSnapshot, { | ||
serializeEvent | ||
}) { | ||
// 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: [...]}`) | ||
*/ | ||
function createTestModel(machine, options) { | ||
validateMachine(machine); | ||
const serializeEvent = options?.serializeEvent ?? simpleStringify; | ||
const serializeTransition = options?.serializeTransition ?? serializeMachineTransition; | ||
const { | ||
events: getEvents, | ||
...otherOptions | ||
} = options ?? {}; | ||
const testModel = new TestModel(machine, { | ||
serializeState: (state, event, prevState) => { | ||
// Only consider the `state` if `serializeTransition()` is opted out (empty string) | ||
return `${serializeSnapshot$1(state)}${serializeTransition(state, event, prevState, { | ||
serializeEvent | ||
})}`; | ||
}, | ||
stateMatcher: (state, key) => { | ||
return key.startsWith('#') ? state._nodes.includes(machine.getStateNodeById(key)) : state.matches(key); | ||
}, | ||
events: state => { | ||
const events = typeof getEvents === 'function' ? getEvents(state) : getEvents ?? []; | ||
return __unsafe_getAllOwnEventDescriptors(state).flatMap(eventType => { | ||
if (events.some(e => e.type === eventType)) { | ||
return events.filter(e => e.type === eventType); | ||
} | ||
return [{ | ||
type: eventType | ||
}]; // TODO: fix types | ||
}); | ||
}, | ||
...otherOptions | ||
}); | ||
return testModel; | ||
} | ||
function createMockActorScope() { | ||
@@ -21,2 +411,3 @@ const emptyActor = createEmptyActor(); | ||
* Returns all state nodes of the given `node`. | ||
* | ||
* @param stateNode State node to recursively get child state nodes from | ||
@@ -280,67 +671,55 @@ */ | ||
function getSimplePaths(logic, options) { | ||
const resolvedOptions = resolveTraversalOptions(logic, options); | ||
function isMachine(value) { | ||
return !!value && '__xstatenode' in value; | ||
} | ||
function getPathsFromEvents(logic, events, options) { | ||
const resolvedOptions = resolveTraversalOptions(logic, { | ||
events, | ||
...options | ||
}, isMachine(logic) ? createDefaultMachineOptions(logic) : createDefaultLogicOptions()); | ||
const actorScope = createMockActorScope(); | ||
const fromState = resolvedOptions.fromState ?? logic.getInitialSnapshot(actorScope, options?.input); | ||
const serializeState = resolvedOptions.serializeState; | ||
const fromState = resolvedOptions.fromState ?? logic.getInitialSnapshot(actorScope, | ||
// TODO: fix this | ||
options?.input); | ||
const { | ||
serializeState, | ||
serializeEvent | ||
} = resolvedOptions; | ||
const adjacency = getAdjacencyMap(logic, resolvedOptions); | ||
const stateMap = new Map(); | ||
const visitCtx = { | ||
vertices: new Set(), | ||
edges: new Set() | ||
}; | ||
const steps = []; | ||
const pathMap = {}; | ||
function util(fromStateSerial, toStateSerial) { | ||
const fromState = stateMap.get(fromStateSerial); | ||
visitCtx.vertices.add(fromStateSerial); | ||
if (fromStateSerial === toStateSerial) { | ||
if (!pathMap[toStateSerial]) { | ||
pathMap[toStateSerial] = { | ||
state: stateMap.get(toStateSerial), | ||
paths: [] | ||
}; | ||
} | ||
const toStatePlan = pathMap[toStateSerial]; | ||
const path2 = { | ||
state: fromState, | ||
weight: steps.length, | ||
steps: [...steps] | ||
}; | ||
toStatePlan.paths.push(path2); | ||
} else { | ||
for (const serializedEvent of Object.keys(adjacency[fromStateSerial].transitions)) { | ||
const { | ||
state: nextState, | ||
event: subEvent | ||
} = adjacency[fromStateSerial].transitions[serializedEvent]; | ||
if (!(serializedEvent in adjacency[fromStateSerial].transitions)) { | ||
continue; | ||
} | ||
const prevState = stateMap.get(fromStateSerial); | ||
const nextStateSerial = serializeState(nextState, subEvent, prevState); | ||
stateMap.set(nextStateSerial, nextState); | ||
if (!visitCtx.vertices.has(nextStateSerial)) { | ||
visitCtx.edges.add(serializedEvent); | ||
steps.push({ | ||
state: stateMap.get(fromStateSerial), | ||
event: subEvent | ||
}); | ||
util(nextStateSerial, toStateSerial); | ||
} | ||
} | ||
const serializedFromState = serializeState(fromState, undefined, undefined); | ||
stateMap.set(serializedFromState, fromState); | ||
let stateSerial = serializedFromState; | ||
let state = fromState; | ||
for (const event of events) { | ||
steps.push({ | ||
state: stateMap.get(stateSerial), | ||
event | ||
}); | ||
const eventSerial = serializeEvent(event); | ||
const { | ||
state: nextState, | ||
event: _nextEvent | ||
} = adjacency[stateSerial].transitions[eventSerial]; | ||
if (!nextState) { | ||
throw new Error(`Invalid transition from ${stateSerial} with ${eventSerial}`); | ||
} | ||
steps.pop(); | ||
visitCtx.vertices.delete(fromStateSerial); | ||
const prevState = stateMap.get(stateSerial); | ||
const nextStateSerial = serializeState(nextState, event, prevState); | ||
stateMap.set(nextStateSerial, nextState); | ||
stateSerial = nextStateSerial; | ||
state = nextState; | ||
} | ||
const fromStateSerial = serializeState(fromState, undefined); | ||
stateMap.set(fromStateSerial, fromState); | ||
for (const nextStateSerial of Object.keys(adjacency)) { | ||
util(fromStateSerial, nextStateSerial); | ||
// If it is expected to reach a specific state (`toState`) and that state | ||
// isn't reached, there are no paths | ||
if (resolvedOptions.toState && !resolvedOptions.toState(state)) { | ||
return []; | ||
} | ||
const simplePaths = Object.values(pathMap).flatMap(p => p.paths); | ||
if (resolvedOptions.toState) { | ||
return simplePaths.filter(path => resolvedOptions.toState(path.state)).map(alterPath); | ||
} | ||
return simplePaths.map(alterPath); | ||
return [alterPath({ | ||
state, | ||
steps, | ||
weight: steps.length | ||
})]; | ||
} | ||
@@ -436,451 +815,69 @@ | ||
function isMachine(value) { | ||
return !!value && '__xstatenode' in value; | ||
} | ||
function getPathsFromEvents(logic, events, options) { | ||
const resolvedOptions = resolveTraversalOptions(logic, { | ||
events, | ||
...options | ||
}, isMachine(logic) ? createDefaultMachineOptions(logic) : createDefaultLogicOptions()); | ||
function getSimplePaths(logic, options) { | ||
const resolvedOptions = resolveTraversalOptions(logic, options); | ||
const actorScope = createMockActorScope(); | ||
const fromState = resolvedOptions.fromState ?? logic.getInitialSnapshot(actorScope, | ||
// TODO: fix this | ||
options?.input); | ||
const { | ||
serializeState, | ||
serializeEvent | ||
} = resolvedOptions; | ||
const fromState = resolvedOptions.fromState ?? logic.getInitialSnapshot(actorScope, options?.input); | ||
const serializeState = resolvedOptions.serializeState; | ||
const adjacency = getAdjacencyMap(logic, resolvedOptions); | ||
const stateMap = new Map(); | ||
const visitCtx = { | ||
vertices: new Set(), | ||
edges: new Set() | ||
}; | ||
const steps = []; | ||
const serializedFromState = serializeState(fromState, undefined, undefined); | ||
stateMap.set(serializedFromState, fromState); | ||
let stateSerial = serializedFromState; | ||
let state = fromState; | ||
for (const event of events) { | ||
steps.push({ | ||
state: stateMap.get(stateSerial), | ||
event | ||
}); | ||
const eventSerial = serializeEvent(event); | ||
const { | ||
state: nextState, | ||
event: _nextEvent | ||
} = adjacency[stateSerial].transitions[eventSerial]; | ||
if (!nextState) { | ||
throw new Error(`Invalid transition from ${stateSerial} with ${eventSerial}`); | ||
} | ||
const prevState = stateMap.get(stateSerial); | ||
const nextStateSerial = serializeState(nextState, event, prevState); | ||
stateMap.set(nextStateSerial, nextState); | ||
stateSerial = nextStateSerial; | ||
state = nextState; | ||
} | ||
// If it is expected to reach a specific state (`toState`) and that state | ||
// isn't reached, there are no paths | ||
if (resolvedOptions.toState && !resolvedOptions.toState(state)) { | ||
return []; | ||
} | ||
return [alterPath({ | ||
state, | ||
steps, | ||
weight: steps.length | ||
})]; | ||
} | ||
function simpleStringify(value) { | ||
return JSON.stringify(value); | ||
} | ||
function formatPathTestResult(path, testPathResult, options) { | ||
const resolvedOptions = { | ||
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; | ||
} | ||
function getDescription(snapshot) { | ||
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]; | ||
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(); | ||
} | ||
/** | ||
* Deduplicates your paths so that A -> B | ||
* is not executed separately to A -> B -> C | ||
*/ | ||
const deduplicatePaths = (paths, serializeEvent = simpleStringify) => { | ||
/** | ||
* Put all paths on the same level so we can dedup them | ||
*/ | ||
const allPathsWithEventSequence = []; | ||
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 = []; | ||
/** | ||
* 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; | ||
} | ||
const pathMap = {}; | ||
function util(fromStateSerial, toStateSerial) { | ||
const fromState = stateMap.get(fromStateSerial); | ||
visitCtx.vertices.add(fromStateSerial); | ||
if (fromStateSerial === toStateSerial) { | ||
if (!pathMap[toStateSerial]) { | ||
pathMap[toStateSerial] = { | ||
state: stateMap.get(toStateSerial), | ||
paths: [] | ||
}; | ||
} | ||
// 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); | ||
}; | ||
const createShortestPathsGen = () => (logic, defaultOptions) => { | ||
const paths = getShortestPaths$1(logic, defaultOptions); | ||
return paths; | ||
}; | ||
const createSimplePathsGen = () => (logic, defaultOptions) => { | ||
const paths = getSimplePaths$1(logic, defaultOptions); | ||
return paths; | ||
}; | ||
const validateState = state => { | ||
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.delay === 'number') { | ||
throw new Error('Delayed actions on test machines are not supported'); | ||
} | ||
}); | ||
for (const child of Object.values(state.states)) { | ||
validateState(child); | ||
} | ||
}; | ||
const validateMachine = machine => { | ||
validateState(machine.root); | ||
}; | ||
/** | ||
* 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. | ||
*/ | ||
class TestModel { | ||
getDefaultOptions() { | ||
return { | ||
serializeState: state => simpleStringify(state), | ||
serializeEvent: event => simpleStringify(event), | ||
// 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(testLogic, options) { | ||
this.testLogic = testLogic; | ||
this.options = void 0; | ||
this.defaultTraversalOptions = void 0; | ||
this._toTestPath = statePath => { | ||
function formatEvent(event) { | ||
const toStatePlan = pathMap[toStateSerial]; | ||
const path2 = { | ||
state: fromState, | ||
weight: steps.length, | ||
steps: [...steps] | ||
}; | ||
toStatePlan.paths.push(path2); | ||
} else { | ||
for (const serializedEvent of Object.keys(adjacency[fromStateSerial].transitions)) { | ||
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 => this.testPath(statePath, params), | ||
description: isMachineSnapshot(statePath.state) ? `Reaches ${getDescription(statePath.state).trim()}: ${eventsString}` : JSON.stringify(statePath.state) | ||
}; | ||
}; | ||
this.options = { | ||
...this.getDefaultOptions(), | ||
...options | ||
}; | ||
} | ||
getPaths(pathGenerator, options) { | ||
const allowDuplicatePaths = options?.allowDuplicatePaths ?? false; | ||
const paths = pathGenerator(this.testLogic, this._resolveOptions(options)); | ||
return (allowDuplicatePaths ? paths : deduplicatePaths(paths)).map(this._toTestPath); | ||
} | ||
getShortestPaths(options) { | ||
return this.getPaths(createShortestPathsGen(), options); | ||
} | ||
getShortestPathsFrom(paths, options) { | ||
const resultPaths = []; | ||
for (const path of paths) { | ||
const shortestPaths = this.getShortestPaths({ | ||
...options, | ||
fromState: path.state | ||
}); | ||
for (const shortestPath of shortestPaths) { | ||
resultPaths.push(this._toTestPath(joinPaths$1(path, shortestPath))); | ||
} | ||
} | ||
return resultPaths; | ||
} | ||
getSimplePaths(options) { | ||
return this.getPaths(createSimplePathsGen(), options); | ||
} | ||
getSimplePathsFrom(paths, options) { | ||
const resultPaths = []; | ||
for (const path of paths) { | ||
const shortestPaths = this.getSimplePaths({ | ||
...options, | ||
fromState: path.state | ||
}); | ||
for (const shortestPath of shortestPaths) { | ||
resultPaths.push(this._toTestPath(joinPaths$1(path, shortestPath))); | ||
} | ||
} | ||
return resultPaths; | ||
} | ||
getPathsFromEvents(events, options) { | ||
const paths = getPathsFromEvents$1(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`. | ||
*/ | ||
getAdjacencyMap() { | ||
const adjMap = getAdjacencyMap$1(this.testLogic, this.options); | ||
return adjMap; | ||
} | ||
async testPath(path, params, options) { | ||
const testPathResult = { | ||
steps: [], | ||
state: { | ||
error: null | ||
} | ||
}; | ||
try { | ||
for (const step of path.steps) { | ||
const testStepResult = { | ||
step, | ||
state: { | ||
error: null | ||
}, | ||
event: { | ||
error: null | ||
} | ||
}; | ||
testPathResult.steps.push(testStepResult); | ||
try { | ||
await this.testTransition(params, step); | ||
} catch (err) { | ||
testStepResult.event.error = err; | ||
throw err; | ||
state: nextState, | ||
event: subEvent | ||
} = adjacency[fromStateSerial].transitions[serializedEvent]; | ||
if (!(serializedEvent in adjacency[fromStateSerial].transitions)) { | ||
continue; | ||
} | ||
try { | ||
await this.testState(params, step.state, options); | ||
} catch (err) { | ||
testStepResult.state.error = err; | ||
throw err; | ||
const prevState = stateMap.get(fromStateSerial); | ||
const nextStateSerial = serializeState(nextState, subEvent, prevState); | ||
stateMap.set(nextStateSerial, nextState); | ||
if (!visitCtx.vertices.has(nextStateSerial)) { | ||
visitCtx.edges.add(serializedEvent); | ||
steps.push({ | ||
state: stateMap.get(fromStateSerial), | ||
event: subEvent | ||
}); | ||
util(nextStateSerial, toStateSerial); | ||
} | ||
} | ||
} catch (err) { | ||
// TODO: make option | ||
err.message += formatPathTestResult(path, testPathResult, this.options); | ||
throw err; | ||
} | ||
return testPathResult; | ||
steps.pop(); | ||
visitCtx.vertices.delete(fromStateSerial); | ||
} | ||
async testState(params, state, options) { | ||
const resolvedOptions = this._resolveOptions(options); | ||
const stateTestKeys = this._getStateTestKeys(params, state, resolvedOptions); | ||
for (const stateTestKey of stateTestKeys) { | ||
await params.states?.[stateTestKey](state); | ||
} | ||
const fromStateSerial = serializeState(fromState, undefined); | ||
stateMap.set(fromStateSerial, fromState); | ||
for (const nextStateSerial of Object.keys(adjacency)) { | ||
util(fromStateSerial, nextStateSerial); | ||
} | ||
_getStateTestKeys(params, state, resolvedOptions) { | ||
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; | ||
const simplePaths = Object.values(pathMap).flatMap(p => p.paths); | ||
if (resolvedOptions.toState) { | ||
return simplePaths.filter(path => resolvedOptions.toState(path.state)).map(alterPath); | ||
} | ||
_getEventExec(params, step) { | ||
const eventExec = params.events?.[step.event.type]; | ||
return eventExec; | ||
} | ||
async testTransition(params, step) { | ||
const eventExec = this._getEventExec(params, step); | ||
await eventExec?.(step); | ||
} | ||
_resolveOptions(options) { | ||
return { | ||
...this.defaultTraversalOptions, | ||
...this.options, | ||
...options | ||
}; | ||
} | ||
return simplePaths.map(alterPath); | ||
} | ||
function stateValuesEqual(a, b) { | ||
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, event, previousSnapshot, { | ||
serializeEvent | ||
}) { | ||
// 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: [...]}`) | ||
*/ | ||
function createTestModel(machine, options) { | ||
validateMachine(machine); | ||
const serializeEvent = options?.serializeEvent ?? simpleStringify; | ||
const serializeTransition = options?.serializeTransition ?? serializeMachineTransition; | ||
const { | ||
events: getEvents, | ||
...otherOptions | ||
} = options ?? {}; | ||
const testModel = new TestModel(machine, { | ||
serializeState: (state, event, prevState) => { | ||
// Only consider the `state` if `serializeTransition()` is opted out (empty string) | ||
return `${serializeSnapshot$1(state)}${serializeTransition(state, event, prevState, { | ||
serializeEvent | ||
})}`; | ||
}, | ||
stateMatcher: (state, key) => { | ||
return key.startsWith('#') ? state._nodes.includes(machine.getStateNodeById(key)) : state.matches(key); | ||
}, | ||
events: state => { | ||
const events = typeof getEvents === 'function' ? getEvents(state) : getEvents ?? []; | ||
return __unsafe_getAllOwnEventDescriptors(state).flatMap(eventType => { | ||
if (events.some(e => e.type === eventType)) { | ||
return events.filter(e => e.type === eventType); | ||
} | ||
return [{ | ||
type: eventType | ||
}]; // TODO: fix types | ||
}); | ||
}, | ||
...otherOptions | ||
}); | ||
return testModel; | ||
} | ||
export { TestModel, adjacencyMapToArray, createShortestPathsGen, createSimplePathsGen, createTestModel, getAdjacencyMap, getPathsFromEvents, getShortestPaths, getSimplePaths, getStateNodes, joinPaths, serializeEvent, serializeSnapshot, toDirectedGraph }; |
{ | ||
"name": "@xstate/graph", | ||
"version": "2.0.0", | ||
"version": "2.0.1", | ||
"description": "XState graph utilities", | ||
@@ -38,3 +38,2 @@ "keywords": [ | ||
}, | ||
"scripts": {}, | ||
"bugs": { | ||
@@ -44,8 +43,9 @@ "url": "https://github.com/statelyai/xstate/issues" | ||
"peerDependencies": { | ||
"xstate": "^5.13.0" | ||
"xstate": "^5.18.2" | ||
}, | ||
"devDependencies": { | ||
"xstate": "5.13.0" | ||
"xstate": "^5.18.2" | ||
}, | ||
"dependencies": {} | ||
} | ||
"dependencies": {}, | ||
"scripts": {} | ||
} |
Sorry, the diff of this file is not supported yet
77611
18
2064