|
| 1 | +import {matchesState} from 'xstate'; |
| 2 | +import type {SnapshotFrom} from 'xstate'; |
| 3 | +import {getPathsFromEvents, getSimplePaths} from 'xstate/graph'; |
| 4 | +import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; |
| 5 | +import type {MfaEvent} from '@components/MultifactorAuthentication/machine/types'; |
| 6 | +import {toStateValue} from './machineStates'; |
| 7 | +import createInitEvent from './mfaTestFixtures'; |
| 8 | + |
| 9 | +/** |
| 10 | + * A small `createTestModel`-style harness over `xstate/graph`'s plain path generators: each path |
| 11 | + * exposes `path.test({events, states})`, which replays the path through the real UI (drive the |
| 12 | + * matching event executor at each step, then run every matching state assertion). |
| 13 | + * |
| 14 | + * Why not the real `createTestModel`? Its constructor calls `validateMachine`, which throws "After |
| 15 | + * events on test machines are not supported", and `mfaMachine.closing` has an `after` (closeFallback) |
| 16 | + * transition. The plain generators have no such restriction. |
| 17 | + * |
| 18 | + * Paths are generated WITHOUT a pinned event list, so the graph synthesizes an event for every |
| 19 | + * transition the machine declares and auto-discovers new (sub)states as the chart grows. Each step's |
| 20 | + * `state` is the deepest settled configuration (e.g. `open.outcome.success`), so the `states` map |
| 21 | + * routes by `matchesState` and can assert at any depth - transient routers (`always`/`initial` such |
| 22 | + * as `preparing`/`outcome`) are passed through and never become their own node. |
| 23 | + */ |
| 24 | + |
| 25 | +type MfaSnapshot = SnapshotFrom<typeof mfaMachine>; |
| 26 | + |
| 27 | +/** UI action that produces a machine event - an `events` map entry passed to `path.test`. */ |
| 28 | +type MfaEventExecutor = () => void | Promise<void>; |
| 29 | + |
| 30 | +/** Assertion run when the walk reaches a matching state - a `states` map entry passed to `path.test`. */ |
| 31 | +type MfaStateAssertion = (state: MfaSnapshot) => void | Promise<void>; |
| 32 | + |
| 33 | +type MfaPathTestConfig = { |
| 34 | + events: Partial<Record<MfaEvent['type'], MfaEventExecutor>>; |
| 35 | + /** Keyed by dot-path state value (e.g. `open.outcome.success`), matched against each step with `matchesState`. */ |
| 36 | + states: Record<string, MfaStateAssertion>; |
| 37 | +}; |
| 38 | + |
| 39 | +type RawPath = { |
| 40 | + state: MfaSnapshot; |
| 41 | + steps: ReadonlyArray<{state: MfaSnapshot; event: {type: string}}>; |
| 42 | +}; |
| 43 | + |
| 44 | +type MfaTestPath = { |
| 45 | + /** Final-state snapshot, e.g. for `JSON.stringify(path.state.value)` test names. */ |
| 46 | + state: MfaSnapshot; |
| 47 | + /** The driven event sequence, e.g. `INIT -> CLOSE_MODAL`. */ |
| 48 | + description: string; |
| 49 | + /** Walks the path: for each step, runs the matching event executor then asserts every matching state. */ |
| 50 | + test: (config: MfaPathTestConfig) => Promise<void>; |
| 51 | +}; |
| 52 | + |
| 53 | +// INIT carries the real scenario payload; CLOSE_MODAL/MODAL_CLOSED are bare. Used only for the explicit |
| 54 | +// teardown lap (getLifecyclePaths); simple paths auto-discover events from the chart. |
| 55 | +const DRIVING_EVENTS: MfaEvent[] = [createInitEvent(), {type: 'CLOSE_MODAL'}, {type: 'MODAL_CLOSED'}]; |
| 56 | +const INIT_STEP_EVENT_TYPE = 'xstate.init'; |
| 57 | +const DELAYED_EVENT_PREFIX = 'xstate.after'; |
| 58 | + |
| 59 | +// Delayed (`after`) transitions are timers, not gestures - drop any path that would need to fire one. |
| 60 | +// The closing -> closed teardown they cover is driven explicitly via MODAL_CLOSED in getLifecyclePaths. |
| 61 | +function isGestureDrivablePath(path: RawPath): boolean { |
| 62 | + return path.steps.every((step) => !step.event.type.startsWith(DELAYED_EVENT_PREFIX)); |
| 63 | +} |
| 64 | + |
| 65 | +async function assertMatchingStates(snapshot: MfaSnapshot, states: MfaPathTestConfig['states']): Promise<void> { |
| 66 | + for (const [stateValue, assertState] of Object.entries(states)) { |
| 67 | + if (matchesState(toStateValue(stateValue.split('.')), snapshot.value)) { |
| 68 | + await assertState(snapshot); |
| 69 | + } |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +function wrapPath(graphPath: RawPath): MfaTestPath { |
| 74 | + const drivenEventTypes = graphPath.steps.map((step) => step.event.type).filter((type) => type !== INIT_STEP_EVENT_TYPE); |
| 75 | + |
| 76 | + return { |
| 77 | + state: graphPath.state, |
| 78 | + description: drivenEventTypes.length > 0 ? drivenEventTypes.join(' -> ') : '(initial state)', |
| 79 | + test: async ({events, states}) => { |
| 80 | + const executorByEventType: Partial<Record<string, MfaEventExecutor>> = events; |
| 81 | + for (const step of graphPath.steps) { |
| 82 | + if (step.event.type !== INIT_STEP_EVENT_TYPE) { |
| 83 | + const executeEvent = executorByEventType[step.event.type]; |
| 84 | + if (!executeEvent) { |
| 85 | + throw new Error(`No event executor provided for "${step.event.type}"`); |
| 86 | + } |
| 87 | + await executeEvent(); |
| 88 | + } |
| 89 | + await assertMatchingStates(step.state, states); |
| 90 | + } |
| 91 | + }, |
| 92 | + }; |
| 93 | +} |
| 94 | + |
| 95 | +function toTestPaths(rawPaths: RawPath[]): MfaTestPath[] { |
| 96 | + return rawPaths.filter(isGestureDrivablePath).map(wrapPath); |
| 97 | +} |
| 98 | + |
| 99 | +function createMfaTestModel() { |
| 100 | + return { |
| 101 | + getSimplePaths: (): MfaTestPath[] => toTestPaths(getSimplePaths(mfaMachine)), |
| 102 | + // The full teardown lap (... -> MODAL_CLOSED -> closed) that simple paths skip because it revisits `closed`. |
| 103 | + getLifecyclePaths: (): MfaTestPath[] => toTestPaths(getPathsFromEvents(mfaMachine, DRIVING_EVENTS)), |
| 104 | + }; |
| 105 | +} |
| 106 | + |
| 107 | +export default createMfaTestModel; |
| 108 | +export type {MfaPathTestConfig, MfaTestPath}; |
0 commit comments