New Case Study:See how Anthropic automated 95% of dependency reviews with Socket.Learn More
Socket
Sign inDemoInstall
Socket

@xstate/graph

Package Overview
Dependencies
Maintainers
0
Versions
26
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@xstate/graph - npm Package Compare versions

Comparing version 2.0.0 to 2.0.1

2

dist/declarations/dist/xstate-graph.cjs.d.ts
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

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc