UNPKG

xstate

Version:

Finite State Machines and Statecharts for the Modern Web.

902 lines (876 loc) 28.6 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var actors_dist_xstateActors = require('../../actors/dist/xstate-actors.cjs.js'); var StateMachine = require('../../dist/StateMachine-1cda96d3.cjs.js'); var guards_dist_xstateGuards = require('../../dist/raise-5872b9e8.cjs.js'); require('../../dev/dist/xstate-dev.cjs.js'); require('../../dist/assign-e9c344ea.cjs.js'); 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) { // eslint-disable-next-line @typescript-eslint/no-for-in-array 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(logic, defaultOptions); return paths; }; const createSimplePathsGen = () => (logic, defaultOptions) => { const paths = 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: guards_dist_xstateGuards.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(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(path, shortestPath))); } } return resultPaths; } getPathsFromEvents(events, options) { const paths = getPathsFromEvents(this.testLogic, events, options); return paths.map(this._toTestPath); } /** * An array of adjacencies, which are objects that represent each `state` with * the `nextState` given the `event`. */ getAdjacencyMap() { const adjMap = 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 `${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 guards_dist_xstateGuards.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() { const emptyActor = actors_dist_xstateActors.createEmptyActor(); return { self: emptyActor, logger: console.log, id: '', sessionId: Math.random().toString(32).slice(2), defer: () => {}, system: emptyActor.system, // TODO: mock system? stopChild: () => {}, emit: () => {}, actionExecutor: () => {} }; } /** * Returns all state nodes of the given `node`. * * @param stateNode State node to recursively get child state nodes from */ function getStateNodes(stateNode) { const { states } = stateNode; const nodes = Object.keys(states).reduce((accNodes, stateKey) => { const childStateNode = states[stateKey]; const childStateNodes = getStateNodes(childStateNode); accNodes.push(childStateNode, ...childStateNodes); return accNodes; }, []); return nodes; } function getChildren(stateNode) { if (!stateNode.states) { return []; } const children = Object.keys(stateNode.states).map(key => { return stateNode.states[key]; }); return children; } function serializeSnapshot(snapshot) { const { value, context } = snapshot; return JSON.stringify({ value, context: Object.keys(context ?? {}).length ? context : undefined }); } function serializeEvent(event) { return JSON.stringify(event); } function createDefaultMachineOptions(machine, options) { const { events: getEvents, ...otherOptions } = options ?? {}; const traversalOptions = { serializeState: serializeSnapshot, serializeEvent, events: state => { const events = typeof getEvents === 'function' ? getEvents(state) : getEvents ?? []; return guards_dist_xstateGuards.getAllOwnEventDescriptors(state).flatMap(type => { const matchingEvents = events.filter(ev => ev.type === type); if (matchingEvents.length) { return matchingEvents; } return [{ type }]; }); }, fromState: machine.getInitialSnapshot(createMockActorScope(), options?.input), ...otherOptions }; return traversalOptions; } function createDefaultLogicOptions() { return { serializeState: state => JSON.stringify(state), serializeEvent }; } function toDirectedGraph(stateMachine) { const stateNode = stateMachine instanceof StateMachine.StateMachine ? stateMachine.root : stateMachine; // TODO: accept only machines const edges = [...stateNode.transitions.values()].flat().flatMap((t, transitionIndex) => { const targets = t.target ? t.target : [stateNode]; return targets.map((target, targetIndex) => { const edge = { id: `${stateNode.id}:${transitionIndex}:${targetIndex}`, source: stateNode, target: target, transition: t, label: { text: t.eventType, toJSON: () => ({ text: t.eventType }) }, toJSON: () => { const { label } = edge; return { source: stateNode.id, target: target.id, label }; } }; return edge; }); }); const graph = { id: stateNode.id, stateNode: stateNode, children: getChildren(stateNode).map(toDirectedGraph), edges, toJSON: () => { const { id, children, edges: graphEdges } = graph; return { id, children, edges: graphEdges }; } }; return graph; } function isMachineLogic(logic) { return 'getStateNodeById' in logic; } function resolveTraversalOptions(logic, traversalOptions, defaultOptions) { const resolvedDefaultOptions = defaultOptions ?? (isMachineLogic(logic) ? createDefaultMachineOptions(logic, traversalOptions) : undefined); const serializeState = traversalOptions?.serializeState ?? resolvedDefaultOptions?.serializeState ?? (state => JSON.stringify(state)); const traversalConfig = { serializeState, serializeEvent, events: [], limit: Infinity, fromState: undefined, toState: undefined, // Traversal should not continue past the `toState` predicate // since the target state has already been reached at that point stopWhen: traversalOptions?.toState, ...resolvedDefaultOptions, ...traversalOptions }; return traversalConfig; } function joinPaths(headPath, tailPath) { const secondPathSource = tailPath.steps[0].state; if (secondPathSource !== headPath.state) { throw new Error(`Paths cannot be joined`); } return { state: tailPath.state, // e.g. [A, B, C] + [C, D, E] = [A, B, C, D, E] steps: headPath.steps.concat(tailPath.steps.slice(1)), weight: headPath.weight + tailPath.weight }; } function getAdjacencyMap(logic, options) { const { transition } = logic; const { serializeEvent, serializeState, events: getEvents, limit, fromState: customFromState, stopWhen } = resolveTraversalOptions(logic, options); const actorScope = createMockActorScope(); const fromState = customFromState ?? logic.getInitialSnapshot(actorScope, // TODO: fix this options.input); const adj = {}; let iterations = 0; const queue = [{ nextState: fromState, event: undefined, prevState: undefined }]; const stateMap = new Map(); while (queue.length) { const { nextState: state, event, prevState } = queue.shift(); if (iterations++ > limit) { throw new Error('Traversal limit exceeded'); } const serializedState = serializeState(state, event, prevState); if (adj[serializedState]) { continue; } stateMap.set(serializedState, state); adj[serializedState] = { state, transitions: {} }; if (stopWhen && stopWhen(state)) { continue; } const events = typeof getEvents === 'function' ? getEvents(state) : getEvents; for (const nextEvent of events) { const nextSnapshot = transition(state, nextEvent, actorScope); adj[serializedState].transitions[serializeEvent(nextEvent)] = { event: nextEvent, state: nextSnapshot }; queue.push({ nextState: nextSnapshot, event: nextEvent, prevState: state }); } } return adj; } function adjacencyMapToArray(adjMap) { const adjList = []; for (const adjValue of Object.values(adjMap)) { for (const transition of Object.values(adjValue.transitions)) { adjList.push({ state: adjValue.state, event: transition.event, nextState: transition.state }); } } return adjList; } // TODO: rewrite parts of the algorithm leading to this to make this function obsolete function alterPath(path) { let steps = []; if (!path.steps.length) { steps = [{ state: path.state, event: { type: 'xstate.init' } }]; } else { for (let i = 0; i < path.steps.length; i++) { const step = path.steps[i]; steps.push({ state: step.state, event: i === 0 ? { type: 'xstate.init' } : path.steps[i - 1].event }); } steps.push({ state: path.state, event: path.steps[path.steps.length - 1].event }); } return { ...path, steps }; } 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, // TODO: fix this options?.input); const { serializeState, serializeEvent } = resolvedOptions; const adjacency = getAdjacencyMap(logic, resolvedOptions); const stateMap = new Map(); 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 getShortestPaths(logic, options) { const resolvedOptions = resolveTraversalOptions(logic, options); const serializeState = resolvedOptions.serializeState; const fromState = resolvedOptions.fromState ?? logic.getInitialSnapshot(createMockActorScope(), options?.input); const adjacency = getAdjacencyMap(logic, resolvedOptions); // weight, state, event const weightMap = new Map(); const stateMap = new Map(); const serializedFromState = serializeState(fromState, undefined, undefined); stateMap.set(serializedFromState, fromState); weightMap.set(serializedFromState, { weight: 0, state: undefined, event: undefined }); const unvisited = new Set(); const visited = new Set(); unvisited.add(serializedFromState); for (const serializedState of unvisited) { const prevState = stateMap.get(serializedState); const { weight } = weightMap.get(serializedState); for (const event of Object.keys(adjacency[serializedState].transitions)) { const { state: nextState, event: eventObject } = adjacency[serializedState].transitions[event]; const nextSerializedState = serializeState(nextState, eventObject, prevState); stateMap.set(nextSerializedState, nextState); if (!weightMap.has(nextSerializedState)) { weightMap.set(nextSerializedState, { weight: weight + 1, state: serializedState, event: eventObject }); } else { const { weight: nextWeight } = weightMap.get(nextSerializedState); if (nextWeight > weight + 1) { weightMap.set(nextSerializedState, { weight: weight + 1, state: serializedState, event: eventObject }); } } if (!visited.has(nextSerializedState)) { unvisited.add(nextSerializedState); } } visited.add(serializedState); unvisited.delete(serializedState); } const statePlanMap = {}; const paths = []; weightMap.forEach(({ weight, state: fromState, event: fromEvent }, stateSerial) => { const state = stateMap.get(stateSerial); const steps = !fromState ? [] : statePlanMap[fromState].paths[0].steps.concat({ state: stateMap.get(fromState), event: fromEvent }); paths.push({ state, steps, weight }); statePlanMap[stateSerial] = { state, paths: [{ state, steps, weight }] }; }); if (resolvedOptions.toState) { return paths.filter(path => resolvedOptions.toState(path.state)).map(alterPath); } return paths.map(alterPath); } function getSimplePaths(logic, options) { const resolvedOptions = resolveTraversalOptions(logic, options); const actorScope = createMockActorScope(); 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 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); } } } steps.pop(); visitCtx.vertices.delete(fromStateSerial); } const fromStateSerial = serializeState(fromState, undefined); stateMap.set(fromStateSerial, fromState); for (const nextStateSerial of Object.keys(adjacency)) { util(fromStateSerial, nextStateSerial); } 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); } exports.TestModel = TestModel; exports.adjacencyMapToArray = adjacencyMapToArray; exports.createShortestPathsGen = createShortestPathsGen; exports.createSimplePathsGen = createSimplePathsGen; exports.createTestModel = createTestModel; exports.getAdjacencyMap = getAdjacencyMap; exports.getPathsFromEvents = getPathsFromEvents; exports.getShortestPaths = getShortestPaths; exports.getSimplePaths = getSimplePaths; exports.getStateNodes = getStateNodes; exports.joinPaths = joinPaths; exports.serializeSnapshot = serializeSnapshot; exports.toDirectedGraph = toDirectedGraph;