├── .eslintrc.yaml ├── .github └── workflows │ ├── ci.yaml │ └── docs.yaml ├── .gitignore ├── LICENSE ├── README.md ├── f-of-xstate.png ├── jest.config.js ├── package.json ├── simply-stated.png ├── src ├── arbitrary-machine.ts ├── categorize-transitions.ts ├── get-all-actions.ts ├── get-all-conditions.ts ├── get-all-invocations.ts ├── get-all-states.ts ├── get-all-transitions.ts ├── index.ts ├── map-states.ts └── state-mappers.ts ├── tests ├── arbitrary-machine.test.ts ├── categorize-transitions.test.ts ├── get-all-actions.test.ts ├── get-all-conditions.test.ts ├── get-all-invocations.test.ts ├── get-all-states.test.ts ├── get-all-transitions.test.ts ├── map-states.test.ts └── state-mappers.test.ts ├── tsconfig.json ├── typedoc.json └── yarn.lock /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: 5 | - eslint:recommended 6 | - plugin:@typescript-eslint/recommended 7 | parser: '@typescript-eslint/parser' 8 | parserOptions: 9 | ecmaVersion: latest 10 | sourceType: module 11 | plugins: 12 | - '@typescript-eslint' 13 | rules: 14 | "@typescript-eslint/no-explicit-any": "off" 15 | "prefer-spread": "off" 16 | "@typescript-eslint/no-unused-vars": 17 | - error 18 | - varsIgnorePattern: "^_" 19 | argsIgnorePattern: "^_" 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: 16.x 15 | cache: 'yarn' 16 | - run: yarn --frozen-lockfile 17 | - run: yarn test 18 | doc: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-node@v2 23 | with: 24 | node-version: 16.x 25 | cache: 'yarn' 26 | - run: yarn --frozen-lockfile 27 | - run: yarn doc 28 | lint: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v2 32 | - uses: actions/setup-node@v2 33 | with: 34 | node-version: 16.x 35 | cache: 'yarn' 36 | - run: yarn --frozen-lockfile 37 | - run: yarn lint 38 | build: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v2 42 | - uses: actions/setup-node@v2 43 | with: 44 | node-version: 16.x 45 | cache: 'yarn' 46 | - run: yarn --frozen-lockfile 47 | - run: yarn build -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy docs site 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | permissions: 8 | contents: read 9 | pages: write 10 | id-token: write 11 | 12 | concurrency: 13 | group: "pages" 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | deploy: 18 | environment: 19 | name: github-pages 20 | url: ${{ steps.deployment.outputs.page_url }} 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-node@v2 25 | with: 26 | node-version: 16.x 27 | cache: 'yarn' 28 | - run: yarn --frozen-lockfile 29 | - run: yarn doc 30 | - name: Setup Pages 31 | uses: actions/configure-pages@v2 32 | - name: Upload artifact 33 | uses: actions/upload-pages-artifact@v1 34 | with: 35 | path: "doc" 36 | - name: Deploy to GitHub Pages 37 | id: deployment 38 | uses: actions/deploy-pages@v1 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # vi backups 107 | .*.swp 108 | 109 | # docs 110 | doc/ 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 simplystated 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @simplystated/f-of-xstate · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/simplystated/f-of-xstate/blob/main/LICENSE) [![npm version](https://img.shields.io/npm/v/@simplystated/f-of-xstate.svg?style=flat)](https://www.npmjs.com/package/@simplystated/f-of-xstate) [![CI](https://github.com/simplystated/f-of-xstate/actions/workflows/ci.yaml/badge.svg)](https://github.com/simplystated/f-of-xstate/actions/workflows/ci.yaml) 2 | 3 | Tools for operating on [XState](https://github.com/statelyai/xstate) state machines as data. 4 | 5 | Query and update your statechart structure. 6 | 7 | ![Logo](https://github.com/simplystated/f-of-xstate/raw/main/f-of-xstate.png) 8 | 9 | # Pronounciation 10 | 11 | Eff of ex state. 12 | As in: `f(x-state)`, because it's a set of utilities to operate on XState state charts as data. 13 | 14 | # Motivation 15 | 16 | Statecharts are awesome. 17 | A lot of that reputation comes from the fact that they make it far easier to reason about your logic, making hard problems tractable. 18 | However, one of the too-often overlooked benefits of representing your logic as data is that once you do that... well... your logic is data! 19 | Once your logic is data, you can live out every lisp programmer's dream and write programs to inspect, modify, and even generate your programs. 20 | That's where f-of-xstate comes in. 21 | We aim to provide a set of utilities to make that easy. 22 | 23 | # Quickstart 24 | 25 | You can play around with f-of-xstate in your browser in [this codesandbox](https://codesandbox.io/s/f-of-xstate-example-zkkoj2?file=/src/App.js). 26 | 27 | # Installation 28 | 29 | ```bash 30 | yarn add @simplystated/f-of-xstate 31 | ``` 32 | 33 | or 34 | 35 | ```bash 36 | npm install --save @simplystated/f-of-xstate 37 | ``` 38 | 39 | # API Documentation 40 | 41 | Please find our API Documentation [here](https://simplystated.github.io/f-of-xstate/). 42 | 43 | # Testing 44 | 45 | f-of-xstate ships with a [fast-check](https://github.com/dubzzz/fast-check) [Arbitrary](https://github.com/dubzzz/fast-check/blob/main/packages/fast-check/documentation/Arbitraries.md) to generate random XState state machine configurations (e.g. the things you can pass to `createMachine`). 46 | 47 | The intention is that this should make it easier to use property-based testing to gain confidence in the correctness of your state machine transformations. 48 | All of the functions exposed in this package make use of this arbitrary for testing. 49 | You can find examples [here](https://github.com/simplystated/f-of-xstate/tree/main/tests). 50 | 51 | You can find the documentation for the machine arbitrary [here](https://simplystated.github.io/f-of-xstate/variables/arbitrary_machine.arbitraryMachine.html). 52 | 53 | Note: `arbitraryMachine` is not exported from index of f-of-xstate because it is intended to be used for testing and we didn't want to mix it with production code. 54 | You can import it as: 55 | ```typescript 56 | import { arbitraryMachine } from "@simplystated/f-of-xstate/dist/arbitrary-machine" 57 | ``` 58 | 59 | One *highly* useful thing to do is to open a terminal and look at a few of these: 60 | ```typescript 61 | import { arbitraryMachine } from "@simplystated/f-of-xstate/dist/arbitrary-machine" 62 | import * as fc from "fast-check"; 63 | console.log(JSON.stringify(fc.sample(arbitraryMachine, 5), null, 2)); 64 | ``` 65 | 66 | # Querying 67 | 68 | Given a `StateMachine` (e.g. something returned from XState's `createMachine`), you can query for the following, each of which walks the tree of state nodes and returns an array of all items encountered: 69 | - [`getAllStates`](https://simplystated.github.io/f-of-xstate/functions/index.getAllStates.html) 70 | - [`getAllProperStates`](https://simplystated.github.io/f-of-xstate/functions/index.getAllProperStates.html) 71 | - [`getAllActions`](https://simplystated.github.io/f-of-xstate/functions/index.getAllActions.html) 72 | - [`getAllConditions`](https://simplystated.github.io/f-of-xstate/functions/index.getAllConditions.html) 73 | - [`getAllInvocations`](https://simplystated.github.io/f-of-xstate/functions/index.getAllInvocations.html) 74 | - [`getAllTransitions`](https://simplystated.github.io/f-of-xstate/functions/index.getAllTransitions.html) 75 | 76 | # Transforming 77 | 78 | Given a `StateMachine`, f-of-xstate provides utilities to map over its states, supplying a function to transform each state to produce a new machine config (suitable to pass to `createMachine`). 79 | 80 | See: 81 | - [`mapStates`](https://simplystated.github.io/f-of-xstate/functions/index.mapStates.html) 82 | 83 | f-of-xstate also provides some utility mappers for common transformations that can be used with `mapStates`: 84 | - [`filterMapStates`](https://simplystated.github.io/f-of-xstate/functions/index.filterMapStates.html) 85 | - [`mapTransitions`](https://simplystated.github.io/f-of-xstate/functions/index.mapTransitions.html) 86 | - [`filterTransitions`](https://simplystated.github.io/f-of-xstate/functions/index.filterTransitions.html) 87 | - [`appendTransitions`](https://simplystated.github.io/f-of-xstate/functions/index.appendTransitions.html) 88 | - [`appendActionsToAllTransitions`](https://simplystated.github.io/f-of-xstate/functions/index.appendActionsToAllTransitions.html) 89 | 90 | Example: 91 | 92 | ```typescript 93 | import { createMachine, actions } from "xstate"; 94 | import { mapStates, filterMapStates, appendActionsToAllTransitions } from "@simplystated/f-of-xstate"; 95 | const machine = createMachine(...); 96 | const config = mapStates( 97 | machine, 98 | filterMapStates( 99 | (state) => state.id === "stateToLog", 100 | appendActionsToAllTransitions([ 101 | actions.log((_, evt) => `Hello ${evt.type}`) 102 | ]) 103 | ) 104 | ); 105 | // The updated machine will now log `Hello ` for every event on the "stateToLog" state. 106 | const updatedMachine = createMachine(config); 107 | ``` 108 | 109 | # Simply Stated 110 | 111 | f-of-xstate is a utility library built by [Simply Stated](https://www.simplystated.dev). 112 | At Simply Stated, our goal is to help to unlock the power of statecharts for everyone. 113 | 114 | ![Logo](https://github.com/simplystated/f-of-xstate/raw/main/simply-stated.png) 115 | 116 | # License 117 | 118 | f-of-xstate is [MIT licensed](https://github.com/simplystated/f-of-xstate/blob/main/LICENSE). 119 | -------------------------------------------------------------------------------- /f-of-xstate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplystated/f-of-xstate/0cf6e144ca1884fdb091ef70ddd1631685e808d0/f-of-xstate.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testPathIgnorePatterns: ["/node_mdoules/", "/dist/"], 6 | testMatch: ["**/tests/**/*.[jt]s?(x)", "tests/**/*.[jt]s?(x)", "tests/*.[jt]s?(x)"] 7 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@simplystated/f-of-xstate", 3 | "version": "0.4.0", 4 | "description": "Tools for operating on xstate state machines as data, by Simply Stated", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "build": "tsc", 10 | "watch": "tsc -w", 11 | "prettier": "prettier --write '{src,tests}/**/*.{ts,js,yaml,yml,json}'", 12 | "lint": "eslint src && eslint tests", 13 | "doc": "typedoc" 14 | }, 15 | "files": [ 16 | "README", 17 | "LICENSE", 18 | "dist", 19 | "package.json" 20 | ], 21 | "keywords": ["xstate", "statechart", "state machine", "finite state machine"], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/simplystated/f-of-xstate.git" 25 | }, 26 | "author": "Adam Berger ", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/simplystated/f-of-xstate/issues" 30 | }, 31 | "homepage": "https://github.com/simplystated/f-of-xstate#readme", 32 | "devDependencies": { 33 | "@types/jest": "^29.1.1", 34 | "@typescript-eslint/eslint-plugin": "^5.38.1", 35 | "@typescript-eslint/parser": "^5.42.1", 36 | "eslint": "^8.24.0", 37 | "fast-check": "^3.3.0", 38 | "jest": "^29.1.2", 39 | "prettier": "^2.7.1", 40 | "ts-jest": "^29.0.3", 41 | "ts-node": "^10.9.1", 42 | "typedoc": "^0.23.20", 43 | "typescript": "^4.8.4", 44 | "xstate": "^4.34.0" 45 | }, 46 | "peerDependencies": { 47 | "fast-check": "^3.3.0", 48 | "xstate": "^4.34.0" 49 | }, 50 | "peerDependenciesMeta": { 51 | "fast-check": { 52 | "optional": true 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /simply-stated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplystated/f-of-xstate/0cf6e144ca1884fdb091ef70ddd1631685e808d0/simply-stated.png -------------------------------------------------------------------------------- /src/arbitrary-machine.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import type { StateNodeConfig } from "xstate"; 3 | 4 | const depthIdentifier = fc.createDepthIdentifier(); 5 | 6 | /** 7 | * Any StateNodeConfig. 8 | */ 9 | export type AnyStateNodeConfig = StateNodeConfig; 10 | 11 | const isValidXStateStateName = (stateName: string) => 12 | stateName !== "__proto__" && 13 | stateName.indexOf("#") !== 0 && 14 | stateName.indexOf(".") !== 0; 15 | 16 | const stateNameArbitrary = fc 17 | .string({ minLength: 1 }) 18 | .filter(isValidXStateStateName); 19 | 20 | const machineDescriptorArbitrary: fc.Arbitrary = fc.letrec( 21 | (tie) => ({ 22 | machine: fc 23 | .oneof( 24 | { maxDepth: 3, withCrossShrink: true, depthIdentifier }, 25 | tie("atomicState"), 26 | tie("compoundState"), 27 | tie("parallelState") 28 | ) 29 | .map( 30 | (([_type, name, children]: StateDescriptor): StateDescriptor => [ 31 | "machine", 32 | name, 33 | children, 34 | ]) as any 35 | ), 36 | state: fc.oneof( 37 | { maxDepth: 3, withCrossShrink: true, depthIdentifier }, 38 | tie("atomicState"), 39 | tie("finalState"), 40 | tie("historyState"), 41 | tie("compoundState"), 42 | tie("parallelState") 43 | ), 44 | atomicState: fc.tuple( 45 | fc.constant("atomic"), 46 | stateNameArbitrary, 47 | fc.constant([]) 48 | ), 49 | compoundState: fc.tuple( 50 | fc.constant("compound"), 51 | stateNameArbitrary, 52 | fc.array(tie("state"), { maxLength: 5, depthIdentifier }) 53 | ), 54 | parallelState: fc.tuple( 55 | fc.constant("parallel"), 56 | stateNameArbitrary, 57 | fc.array(tie("compoundState"), { maxLength: 5, depthIdentifier }) 58 | ), 59 | finalState: fc.tuple( 60 | fc.constant("final"), 61 | stateNameArbitrary, 62 | fc.constant([]) 63 | ), 64 | historyState: fc.tuple( 65 | fc.constant("history"), 66 | stateNameArbitrary, 67 | fc.constant([]) 68 | ), 69 | }) 70 | ).machine as fc.Arbitrary; 71 | 72 | type StateType = 73 | | "machine" 74 | | "atomic" 75 | | "compound" 76 | | "parallel" 77 | | "history" 78 | | "final"; 79 | type StateDescriptor = [StateType, string, Array]; 80 | interface BaseStateConfig { 81 | type: StateType; 82 | id: string; 83 | states: Record; 84 | } 85 | 86 | // this is a weird hybrid of StateNodeDefinition and StateNodeConfig 87 | interface StateConfig { 88 | type?: StateType; 89 | id?: string; 90 | states?: Record; 91 | on?: Record>; 92 | always?: Array; 93 | onDone?: Array; 94 | data?: Function; // eslint-disable-line @typescript-eslint/ban-types 95 | invoke?: Array; 96 | initial?: string; 97 | } 98 | 99 | interface InvokeConfig { 100 | id?: string; 101 | src: string; 102 | } 103 | 104 | interface EventConfig { 105 | eventType?: string; 106 | target?: string; 107 | actions: Array; 108 | cond?: string; 109 | delay?: string; 110 | } 111 | 112 | const createStates = ( 113 | path: Array, 114 | pathsByStateNames: Map; type: StateType }>, 115 | stateDescriptors: Array 116 | ): Record => 117 | stateDescriptors.reduce((states, [type, name, children]) => { 118 | const id = pathsByStateNames.has(name) 119 | ? `${name}${pathsByStateNames.size}` 120 | : name; 121 | const newPath = path.concat([id]); 122 | pathsByStateNames.set(id, { path: newPath, type }); 123 | const state = { 124 | type, 125 | id, 126 | states: createStates(newPath, pathsByStateNames, children), 127 | }; 128 | return { 129 | ...states, 130 | [id]: state, 131 | }; 132 | }, {}); 133 | 134 | // there are some event names that xstate just doesn't like... 135 | const isValidXStateEvent = (eventType: string) => 136 | ["__proto__", "constructor", "valueOf", "toString"].indexOf(eventType) < 0; 137 | 138 | const eventArb = (stateIds: Array) => 139 | fc 140 | .oneof( 141 | { depthIdentifier }, 142 | fc.constant(void 0), 143 | stateIds.length ? fc.constantFrom(...stateIds) : fc.constant(void 0) 144 | ) 145 | .chain((target) => 146 | fc.record({ 147 | eventType: fc.string({ minLength: 1 }).filter(isValidXStateEvent), 148 | target: 149 | typeof target === "undefined" 150 | ? fc.constant(void 0) 151 | : fc.constant(`#${target}`), 152 | actions: fc.array(fc.string(), { maxLength: 3, depthIdentifier }), 153 | cond: fc.oneof(fc.constant(void 0), fc.string()), 154 | delay: fc.oneof(fc.constant(void 0), fc.string()), 155 | }) 156 | ); 157 | 158 | const eventMapStateUpdate = (stateIds: Array) => 159 | eventArb(stateIds).map((event) => (state: StateConfig) => ({ 160 | state: { 161 | ...state, 162 | on: { 163 | ...state?.on, 164 | [event.eventType]: (state?.on && 165 | Object.prototype.hasOwnProperty.call(state.on, event.eventType) 166 | ? state.on[event.eventType] 167 | : [] 168 | ).concat([event]), 169 | }, 170 | }, 171 | events: [event.eventType], 172 | conditions: event.cond ? [event.cond] : [], 173 | actions: event.actions, 174 | services: [], 175 | })); 176 | 177 | const initialStateStateUpdate = () => 178 | fc.integer({ min: 0 }).map((idx) => (state: StateConfig) => ({ 179 | state: { 180 | ...state, 181 | initial: elementOf(idx, Object.keys(state.states ?? {})), 182 | }, 183 | events: [], 184 | conditions: [], 185 | actions: [], 186 | services: [], 187 | })); 188 | 189 | const elementOf = (rand: number, items: Array): T => { 190 | const idx = ((rand % items.length) + items.length) % items.length; 191 | return items[idx]; 192 | }; 193 | 194 | const alwaysStateUpdate = (stateIds: Array) => 195 | eventArb(stateIds).map((event) => (state: StateConfig) => { 196 | const { eventType: _, ...evt } = event; 197 | return { 198 | state: { 199 | ...state, 200 | always: (state?.always ?? []).concat([evt]), 201 | }, 202 | events: [""], 203 | conditions: evt.cond ? [evt.cond] : [], 204 | actions: evt.actions, 205 | services: [], 206 | }; 207 | }); 208 | 209 | const onDoneStateUpdate = (stateIds: Array) => 210 | eventArb(stateIds).map((event) => (state: StateConfig) => { 211 | const { eventType: _, ...evt } = event; 212 | return { 213 | state: { 214 | ...state, 215 | onDone: (state?.onDone ?? []).concat([evt]), 216 | }, 217 | events: [`done.state.${state.id}`], 218 | conditions: evt.cond ? [evt.cond] : [], 219 | actions: evt.actions, 220 | services: [], 221 | }; 222 | }); 223 | 224 | const doneDataStateUpdate = () => 225 | fc 226 | .func(fc.dictionary(fc.string(), fc.jsonValue(), { maxKeys: 3 })) 227 | .map((data) => (state: StateConfig) => ({ 228 | state: { 229 | ...state, 230 | data, 231 | }, 232 | events: [], 233 | conditions: [], 234 | actions: [], 235 | services: [], 236 | })); 237 | 238 | const invokeStateUpdate = () => 239 | fc 240 | .option( 241 | fc.record({ 242 | src: fc.string(), 243 | id: fc.string(), 244 | }) 245 | ) 246 | .map((invoke) => (state: StateConfig) => ({ 247 | state: { 248 | ...state, 249 | invoke: (state.invoke ?? []).concat(invoke ? [invoke] : []), 250 | }, 251 | events: [], 252 | conditions: [], 253 | actions: [], 254 | services: invoke ? [invoke.src] : [], 255 | })); 256 | 257 | const standardStateUpdate = (stateIds: Array) => 258 | fc.array( 259 | fc.oneof( 260 | eventMapStateUpdate(stateIds), 261 | alwaysStateUpdate(stateIds), 262 | invokeStateUpdate() 263 | ), 264 | { maxLength: 3, depthIdentifier } 265 | ); 266 | 267 | // this is necessary because we don't allow onDone at the top level of a machine, even if it's a parallel state. 268 | const machineStateUpdate = (stateIds: Array) => 269 | fc 270 | .array( 271 | fc.oneof( 272 | eventMapStateUpdate(stateIds), 273 | alwaysStateUpdate(stateIds), 274 | invokeStateUpdate() 275 | ), 276 | { maxLength: 3, depthIdentifier } 277 | ) 278 | .chain((updates) => 279 | initialStateStateUpdate().map((intiialUpdate) => 280 | updates.concat(intiialUpdate as any) 281 | ) 282 | ); 283 | 284 | const atomicStateUpdate = (stateIds: Array) => 285 | standardStateUpdate(stateIds); 286 | 287 | const compoundStateUpdate = (stateIds: Array) => 288 | standardStateUpdate(stateIds) 289 | .chain((updates) => 290 | onDoneStateUpdate(stateIds).map((onDone) => 291 | updates.concat([onDone as any]) 292 | ) 293 | ) 294 | .chain((updates) => 295 | initialStateStateUpdate().map((initialUpdate) => 296 | updates.concat([initialUpdate as any]) 297 | ) 298 | ); 299 | 300 | const parallelStateUpdate = (stateIds: Array) => 301 | fc.array( 302 | fc.oneof( 303 | eventMapStateUpdate(stateIds), 304 | alwaysStateUpdate(stateIds), 305 | invokeStateUpdate(), 306 | onDoneStateUpdate(stateIds) 307 | ), 308 | { maxLength: 3, depthIdentifier } 309 | ); 310 | 311 | const historyStateUpdate = (_stateIds: Array) => fc.constant([]); 312 | 313 | const finalStateUpdate = (_stateIds: Array) => 314 | fc 315 | .tuple( 316 | fc.option(doneDataStateUpdate()), 317 | fc.array(invokeStateUpdate(), { maxLength: 3, depthIdentifier }) 318 | ) 319 | .map(([done, invokes]) => 320 | invokes.concat(done ? ([done] as any) : []) 321 | ) as fc.Arbitrary>; 322 | 323 | const stateUpdatesForType = (stateIds: Array, type: StateType) => 324 | ({ 325 | atomic: atomicStateUpdate, 326 | compound: compoundStateUpdate, 327 | parallel: parallelStateUpdate, 328 | history: historyStateUpdate, 329 | final: finalStateUpdate, 330 | machine: machineStateUpdate, 331 | }[type](stateIds)); 332 | 333 | type Update = (state: StateConfig) => UpdateState; 334 | interface UpdateState { 335 | state: StateConfig; 336 | events: Array; 337 | conditions: Array; 338 | actions: Array; 339 | services: Array; 340 | } 341 | 342 | const applyInPath = ( 343 | updateState: UpdateState, 344 | path: Array, 345 | updates: Array 346 | ): UpdateState => { 347 | if (path.length > 0) { 348 | const nextState = path[0]; 349 | const { 350 | state: nextStateValue, 351 | events, 352 | conditions, 353 | actions, 354 | services, 355 | } = applyInPath( 356 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 357 | { ...updateState, state: updateState.state.states![nextState] }, 358 | path.slice(1), 359 | updates 360 | ); 361 | return { 362 | events, 363 | conditions, 364 | actions, 365 | services, 366 | state: { 367 | ...updateState.state, 368 | states: { 369 | ...updateState.state.states, 370 | [nextState]: nextStateValue, 371 | }, 372 | }, 373 | }; 374 | } 375 | 376 | return updates.reduce((updateState, update) => { 377 | const next = update(updateState.state); 378 | return { 379 | state: next.state, 380 | events: updateState.events.concat(next.events), 381 | conditions: updateState.conditions.concat(next.conditions), 382 | actions: updateState.actions.concat(next.actions), 383 | services: updateState.services.concat(next.services), 384 | }; 385 | }, updateState); 386 | }; 387 | 388 | /** 389 | * [Fast-check](https://github.com/dubzzz/fast-check) Arbitrary 390 | * to generate a config for an XState state machine. 391 | * The `machine` property of `arbitraryMachine` output is intended to be 392 | * passed to `createMachine`. 393 | * 394 | * The arbitrary returns the machine config in the `machine` property, 395 | * the list of all events in the machine in the `events` property, 396 | * the list of all conditions in the machine in the `conditions` property, 397 | * the list of all actions in the machine in the `actions` property, 398 | * the list of all services in the machine in the `services` property, 399 | * and the list of all states in the machine in the `states` property. 400 | */ 401 | export const arbitraryMachine: fc.Arbitrary<{ 402 | machine: AnyStateNodeConfig; 403 | events: Array; 404 | conditions: Array; 405 | actions: Array; 406 | services: Array; 407 | states: Array; 408 | }> = machineDescriptorArbitrary.chain((stateDescriptor) => { 409 | const pathsByStateNames = new Map< 410 | string, 411 | { path: Array; type: StateType } 412 | >(); 413 | const rootId = stateDescriptor[1]; 414 | const rootType = stateDescriptor[0]; 415 | pathsByStateNames.set(rootId, { path: [rootId], type: rootType }); 416 | const states = createStates([rootId], pathsByStateNames, stateDescriptor[2]); 417 | const rootState = { 418 | type: rootType, 419 | id: rootId, 420 | states, 421 | }; 422 | const stateIds = Array.from(pathsByStateNames.keys()).filter( 423 | (id) => id !== rootId 424 | ); 425 | 426 | return fc 427 | .tuple( 428 | ...Array.from(pathsByStateNames.values()).map(({ path, type }) => { 429 | return fc.tuple(fc.constant(path), stateUpdatesForType(stateIds, type)); 430 | }) 431 | ) 432 | .map((pathsAndUpdates) => { 433 | return pathsAndUpdates.reduce( 434 | (updateState: UpdateState, [path, updates]) => 435 | applyInPath(updateState, path, updates), 436 | { 437 | state: { states: { [rootId]: rootState } }, 438 | events: [], 439 | conditions: [], 440 | actions: [], 441 | services: [], 442 | } 443 | ); 444 | }) 445 | .map(({ events, conditions, actions, services, state }) => { 446 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 447 | const { type: _, ...machine } = state.states![rootId]; 448 | return { 449 | machine, 450 | events: dedup(events), 451 | conditions: dedup(conditions), 452 | actions: dedup(actions), 453 | services: dedup(services), 454 | states: stateIds, 455 | }; 456 | }); 457 | }) as fc.Arbitrary; 458 | 459 | const dedup = (items: Array): Array => Array.from(new Set(items)); 460 | -------------------------------------------------------------------------------- /src/categorize-transitions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionTypes, 3 | DelayedTransitionDefinition, 4 | EventObject, 5 | TransitionDefinition, 6 | } from "xstate"; 7 | 8 | /** 9 | * Return type for {@link categorizeTransitions}. 10 | * 11 | * Each property represents a transition category and contains 12 | * an array of the transitions in that category 13 | * (with additional information for some categories). 14 | * 15 | * Note: each transition will appear in only one category even though 16 | * `delayDone` and `always` may overlap. 17 | * A delayed always transition will appear in `delayDone`. 18 | */ 19 | export interface TransitionsByCategory { 20 | eventOccurred: Array>; 21 | stateDone: Array<{ 22 | transition: TransitionDefinition; 23 | stateId: string; 24 | }>; 25 | invocationDone: Array<{ 26 | transition: TransitionDefinition; 27 | invocationId: string; 28 | }>; 29 | invocationError: Array<{ 30 | transition: TransitionDefinition; 31 | invocationId: string; 32 | }>; 33 | delayDone: Array>; 34 | always: Array>; 35 | wildcard: Array>; 36 | } 37 | 38 | /** 39 | * Is `transition` a delayed transition? 40 | * 41 | * @param transition A potentially delayed `TransitionDefinition` 42 | * @returns Whether the transition is a delayed transition. 43 | */ 44 | export function isDelayedTransition( 45 | transition: 46 | | TransitionDefinition 47 | | DelayedTransitionDefinition 48 | ): transition is DelayedTransitionDefinition { 49 | return ( 50 | "delay" in transition && 51 | typeof transition.delay !== "undefined" && 52 | transition.delay !== null 53 | ); 54 | } 55 | 56 | /** 57 | * Is `transition` an always transition? 58 | * 59 | * @param transition A potentially always `TransitionDefinition` 60 | * @returns Whether the transition is an always transition. 61 | */ 62 | export const isAlwaysTransition = ( 63 | transition: TransitionDefinition 64 | ): boolean => transition.eventType === ""; 65 | 66 | /** 67 | * Is `transition` a wildcard transition? 68 | * 69 | * @param transition A potentially wildcard `TransitionDefinition` 70 | * @returns Whether the transition is a wildcard transition. 71 | */ 72 | export const isWildcardTransition = ( 73 | transition: TransitionDefinition 74 | ): boolean => transition.eventType === "*"; 75 | 76 | /** 77 | * Is `transition` a state done transition? 78 | * (e.g. from a state's `onDone` property)? 79 | * 80 | * @param transition A potentially state done `TransitionDefinition` 81 | * @returns Whether the transition is a state done transition. 82 | */ 83 | export const isStateDoneTransition = ( 84 | transition: TransitionDefinition 85 | ): boolean => transition.eventType.startsWith(`${ActionTypes.DoneState}.`); 86 | 87 | /** 88 | * Is `transition` an invocation done transition? 89 | * (e.g. from an `invoke`'s `onDone` property)? 90 | * 91 | * @param transition A potentially invocation done `TransitionDefinition` 92 | * @returns Whether the transition is an invocation done transition. 93 | */ 94 | export const isInvocationDoneTransition = < 95 | TContext, 96 | TEvent extends EventObject 97 | >( 98 | transition: TransitionDefinition 99 | ): boolean => transition.eventType.startsWith(`${ActionTypes.DoneInvoke}.`); 100 | 101 | /** 102 | * Is `transition` an invocation error transition? 103 | * (e.g. from an `invoke`'s `onError` property)? 104 | * 105 | * @param transition A potentially invocation error `TransitionDefinition` 106 | * @returns Whether the transition is an invocation error transition. 107 | */ 108 | export const isInvocationErrorTransition = < 109 | TContext, 110 | TEvent extends EventObject 111 | >( 112 | transition: TransitionDefinition 113 | ): boolean => transition.eventType.startsWith(`${ActionTypes.ErrorPlatform}.`); 114 | 115 | /** 116 | * Is `transition` a regular event transition? 117 | * 118 | * @param transition A potentially regular event `TransitionDefinition` 119 | * @returns Whether the transition is regular event transition. 120 | */ 121 | export const isEventTransition = ( 122 | transition: TransitionDefinition 123 | ): boolean => 124 | !( 125 | isAlwaysTransition(transition) || 126 | isWildcardTransition(transition) || 127 | isStateDoneTransition(transition) || 128 | isInvocationDoneTransition(transition) || 129 | isInvocationErrorTransition(transition) 130 | ); 131 | 132 | const stateDoneRegex = new RegExp( 133 | `^${ActionTypes.DoneState.replace(".", "[.]")}[.]` 134 | ); 135 | const invocationDoneRegex = new RegExp( 136 | `^${ActionTypes.DoneInvoke.replace(".", "[.]")}[.]` 137 | ); 138 | const invocationErrorRegex = new RegExp( 139 | `^${ActionTypes.ErrorPlatform.replace(".", "[.]")}[.]` 140 | ); 141 | 142 | /** 143 | * Given an array of `TransitionDefinition`s such as those returned by 144 | * `xstate.createMachine(...).definition.transitions` or by 145 | * {@link getAllTransitions}, categorize the transitions and return 146 | * a structure of arrays of each category. 147 | 148 | * Note: each transition will appear in only one category even though 149 | * `delayDone` and `always` may overlap. 150 | * A delayed always transition will appear in `delayDone`. 151 | * 152 | * @param transitions An array of `TransitionDefinition`s (or `DelayedTransitionDefinition`s) 153 | * @returns Categorized transitions. 154 | */ 155 | export const categorizeTransitions = ( 156 | transitions: Array< 157 | | TransitionDefinition 158 | | DelayedTransitionDefinition 159 | > 160 | ): TransitionsByCategory => 161 | transitions.reduce( 162 | (transitionsByCategory, transition) => { 163 | if (isDelayedTransition(transition)) { 164 | transitionsByCategory.delayDone.push(transition); 165 | return transitionsByCategory; 166 | } 167 | 168 | if (isAlwaysTransition(transition)) { 169 | transitionsByCategory.always.push(transition); 170 | return transitionsByCategory; 171 | } 172 | 173 | if (isWildcardTransition(transition)) { 174 | transitionsByCategory.wildcard.push(transition); 175 | return transitionsByCategory; 176 | } 177 | 178 | if (isStateDoneTransition(transition)) { 179 | transitionsByCategory.stateDone.push({ 180 | transition, 181 | stateId: transition.eventType.replace(stateDoneRegex, ""), 182 | }); 183 | return transitionsByCategory; 184 | } 185 | 186 | if (isInvocationDoneTransition(transition)) { 187 | transitionsByCategory.invocationDone.push({ 188 | transition, 189 | invocationId: transition.eventType.replace(invocationDoneRegex, ""), 190 | }); 191 | return transitionsByCategory; 192 | } 193 | 194 | if (isInvocationErrorTransition(transition)) { 195 | transitionsByCategory.invocationError.push({ 196 | transition, 197 | invocationId: transition.eventType.replace(invocationErrorRegex, ""), 198 | }); 199 | return transitionsByCategory; 200 | } 201 | 202 | transitionsByCategory.eventOccurred.push(transition); 203 | 204 | return transitionsByCategory; 205 | }, 206 | { 207 | eventOccurred: [], 208 | stateDone: [], 209 | invocationDone: [], 210 | invocationError: [], 211 | delayDone: [], 212 | always: [], 213 | wildcard: [], 214 | } as TransitionsByCategory 215 | ); 216 | -------------------------------------------------------------------------------- /src/get-all-actions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionObject, 3 | EventObject, 4 | StateMachine, 5 | StateNode, 6 | StateNodeDefinition, 7 | StateSchema, 8 | } from "xstate"; 9 | import { getAllStatesFromDefinition } from "./get-all-states"; 10 | 11 | /** 12 | * Returns all actions (transition actions, entry actions, exit actions, etc.) 13 | * for the provided root and all descendants. 14 | * 15 | * @param root Machine or StateNode to get all actions for. 16 | * @returns All actions for the provided Machine or StateNode and all descendants. 17 | */ 18 | export const getAllActions = < 19 | TContext, 20 | TStateSchema extends StateSchema, 21 | TEvent extends EventObject, 22 | TStateMachine extends StateMachine< 23 | TContext, 24 | TStateSchema, 25 | TEvent, 26 | any, 27 | any, 28 | any 29 | >, 30 | TStateNode extends StateNode 31 | >( 32 | root: TStateMachine | TStateNode 33 | ): Array> => 34 | getAllActionsFromDefinition(root.definition); 35 | 36 | /** 37 | * Returns all actions (transition actions, entry actions, exit actions, etc.) 38 | * for the provided root and all descendants. 39 | * 40 | * @param root StateNodeDefinition to get all actions for. 41 | * @returns All actions for the provided StateNodeDefinition and all descendants. 42 | */ 43 | export const getAllActionsFromDefinition = < 44 | TContext, 45 | TStateSchema extends StateSchema, 46 | TEvent extends EventObject 47 | >( 48 | definition: StateNodeDefinition 49 | ): Array> => 50 | getAllStatesFromDefinition(definition).flatMap((state) => 51 | state.entry 52 | .concat(state.exit) 53 | .concat(state.transitions.flatMap((t) => t.actions)) 54 | ); 55 | -------------------------------------------------------------------------------- /src/get-all-conditions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventObject, 3 | Guard, 4 | StateMachine, 5 | StateNode, 6 | StateNodeDefinition, 7 | StateSchema, 8 | } from "xstate"; 9 | import { getAllTransitionsFromDefinition } from "./get-all-transitions"; 10 | 11 | /** 12 | * Returns all conditions across all transitions for the provided root and all descendants. 13 | * 14 | * @param root Machine or StateNode to get all conditions for. 15 | * @returns All conditions for the provided Machine or StateNode and all descendants. 16 | */ 17 | export const getAllConditions = < 18 | TContext, 19 | TStateSchema extends StateSchema, 20 | TEvent extends EventObject, 21 | TStateMachine extends StateMachine< 22 | TContext, 23 | TStateSchema, 24 | TEvent, 25 | any, 26 | any, 27 | any 28 | >, 29 | TStateNode extends StateNode 30 | >( 31 | root: TStateMachine | TStateNode 32 | ): Array> => 33 | getAllConditionsFromDefinition(root.definition); 34 | 35 | /** 36 | * Returns all conditions across all transitions for the provided root and all descendants. 37 | * 38 | * @param root StateNodeDefinition to get all conditions for. 39 | * @returns All conditions for the provided StateNodeDefinition and all descendants. 40 | */ 41 | export const getAllConditionsFromDefinition = < 42 | TContext, 43 | TStateSchema extends StateSchema, 44 | TEvent extends EventObject 45 | >( 46 | definition: StateNodeDefinition 47 | ): Array> => 48 | getAllTransitionsFromDefinition(definition) 49 | .map((t) => t.cond) 50 | .filter(isCond); 51 | 52 | function isCond>( 53 | cond: TGuard | undefined 54 | ): cond is TGuard { 55 | return !!cond; 56 | } 57 | -------------------------------------------------------------------------------- /src/get-all-invocations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventObject, 3 | InvokeDefinition, 4 | StateMachine, 5 | StateNode, 6 | StateNodeDefinition, 7 | StateSchema, 8 | } from "xstate"; 9 | import { getAllStatesFromDefinition } from "./get-all-states"; 10 | 11 | /** 12 | * Returns all invocations across all states for the provided root and all descendants. 13 | * 14 | * @param root Machine or StateNode to get all invocations for. 15 | * @returns All invocations for the provided Machine or StateNode and all descendants. 16 | */ 17 | export const getAllInvocations = < 18 | TContext, 19 | TStateSchema extends StateSchema, 20 | TEvent extends EventObject, 21 | TStateMachine extends StateMachine< 22 | TContext, 23 | TStateSchema, 24 | TEvent, 25 | any, 26 | any, 27 | any 28 | >, 29 | TStateNode extends StateNode 30 | >( 31 | root: TStateMachine | TStateNode 32 | ): Array> => 33 | getAllInvocationsFromDefinition(root.definition); 34 | 35 | /** 36 | * Returns all invocations across all states for the provided root and all descendants. 37 | * 38 | * @param root StateNodeDefinition to get all invocations for. 39 | * @returns All invocations for the provided StateNodeDefinition and all descendants. 40 | */ 41 | export const getAllInvocationsFromDefinition = < 42 | TContext, 43 | TStateSchema extends StateSchema, 44 | TEvent extends EventObject 45 | >( 46 | definition: StateNodeDefinition 47 | ): Array> => 48 | getAllStatesFromDefinition(definition).flatMap((state) => state.invoke); 49 | -------------------------------------------------------------------------------- /src/get-all-states.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventObject, 3 | StateMachine, 4 | StateNode, 5 | StateNodeDefinition, 6 | StateSchema, 7 | } from "xstate"; 8 | 9 | /** 10 | * Returns all states including root and all descendants. 11 | * 12 | * Note: this includes the "state" representing the machine itself if 13 | * passed a machine. 14 | * 15 | * To retrieve only "proper" states (excluding the root/machine itself), 16 | * use {@link getAllProperStates}. 17 | * 18 | * @param root The StateMachine or StateNode for which to get all states. 19 | * @returns A list of all states. 20 | */ 21 | export const getAllStates = < 22 | TContext, 23 | TStateSchema extends StateSchema, 24 | TEvent extends EventObject, 25 | TStateMachine extends StateMachine< 26 | TContext, 27 | TStateSchema, 28 | TEvent, 29 | any, 30 | any, 31 | any 32 | >, 33 | TStateNode extends StateNode 34 | >( 35 | root: TStateMachine | TStateNode 36 | ): Array> => 37 | getAllStatesFromDefinition(root.definition); 38 | 39 | /** 40 | * Returns all proper states (excluding root). 41 | * Includes all descendants of root. 42 | * 43 | * To retrieve all states (including the root/machine itself), 44 | * use {@link getAllStates}. 45 | * 46 | * @param root The StateMachine or StateNode for which to get all proper states. 47 | * @returns A list of all proper states. 48 | */ 49 | export const getAllProperStates = < 50 | TContext, 51 | TStateSchema extends StateSchema, 52 | TEvent extends EventObject, 53 | TStateMachine extends StateMachine< 54 | TContext, 55 | TStateSchema, 56 | TEvent, 57 | any, 58 | any, 59 | any 60 | >, 61 | TStateNode extends StateNode 62 | >( 63 | root: TStateMachine | TStateNode 64 | ): Array> => 65 | Object.values(root.definition.states).flatMap( 66 | getAllStatesFromDefinition as any 67 | ); 68 | 69 | /** 70 | * Returns all states including root and all descendants. 71 | * 72 | * Note: this includes the "state" representing the machine itself if 73 | * passed a machine. 74 | * 75 | * @param root The StateNodeDefinition for which to get all states. 76 | * @returns A list of all states. 77 | */ 78 | export const getAllStatesFromDefinition = < 79 | TContext, 80 | TStateSchema extends StateSchema, 81 | TEvent extends EventObject 82 | >( 83 | definition: StateNodeDefinition 84 | ): Array> => { 85 | return [definition].concat( 86 | definition.states 87 | ? Object.keys(definition.states).flatMap((key: string) => 88 | getAllStatesFromDefinition((definition.states as any)[key]) 89 | ) 90 | : [] 91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /src/get-all-transitions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DelayedTransitionDefinition, 3 | EventObject, 4 | StateMachine, 5 | StateNode, 6 | StateNodeDefinition, 7 | StateSchema, 8 | TransitionDefinition, 9 | } from "xstate"; 10 | import { getAllStatesFromDefinition } from "./get-all-states"; 11 | 12 | /** 13 | * Returns all transitions of any type across all states 14 | * for the provided root and all descendants. 15 | * 16 | * @param root Machine or StateNode to get all transitions for. 17 | * @returns All transitions for the provided Machine or StateNode and all descendants. 18 | */ 19 | export const getAllTransitions = < 20 | TContext, 21 | TStateSchema extends StateSchema, 22 | TEvent extends EventObject, 23 | TStateMachine extends StateMachine< 24 | TContext, 25 | TStateSchema, 26 | TEvent, 27 | any, 28 | any, 29 | any 30 | >, 31 | TStateNode extends StateNode 32 | >( 33 | root: TStateMachine | TStateNode 34 | ): Array< 35 | | TransitionDefinition 36 | | DelayedTransitionDefinition 37 | > => getAllTransitionsFromDefinition(root.definition); 38 | 39 | /** 40 | * Returns all transitions of any type across all states 41 | * for the provided root and all descendants. 42 | * 43 | * @param root StateNodeDefinition to get all transitions for. 44 | * @returns All transitions for the provided StateNodeDefinition and all descendants. 45 | */ 46 | export const getAllTransitionsFromDefinition = < 47 | TContext, 48 | TStateSchema extends StateSchema, 49 | TEvent extends EventObject 50 | >( 51 | definition: StateNodeDefinition 52 | ): Array< 53 | | TransitionDefinition 54 | | DelayedTransitionDefinition 55 | > => 56 | getAllStatesFromDefinition(definition).flatMap( 57 | (stateDefinition) => stateDefinition.transitions 58 | ); 59 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./categorize-transitions"; 2 | export * from "./get-all-actions"; 3 | export * from "./get-all-conditions"; 4 | export * from "./get-all-invocations"; 5 | export * from "./get-all-states"; 6 | export * from "./get-all-transitions"; 7 | export * from "./map-states"; 8 | export * from "./state-mappers"; 9 | -------------------------------------------------------------------------------- /src/map-states.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionObject, 3 | EventObject, 4 | Guard, 5 | InvokeDefinition, 6 | MachineConfig, 7 | MachineOptions, 8 | StateMachine, 9 | StateNodeConfig, 10 | StateNodeDefinition, 11 | StateSchema, 12 | StateValue, 13 | TransitionConfig, 14 | TransitionDefinition, 15 | } from "xstate"; 16 | 17 | const transitionDefinitionToStructuredSourceConfig = < 18 | TContext, 19 | TEvent extends EventObject 20 | >( 21 | transition: TransitionDefinition 22 | ): StructuredTransitionConfig => ({ 23 | event: transition.eventType as any, 24 | actions: transition.actions, 25 | cond: transition.cond, 26 | description: transition.description, 27 | in: transition.in, 28 | internal: transition.internal, 29 | meta: transition.meta, 30 | // we are mapping over our state nodes so we cannot leave old 31 | // state nodes in our target. just use their ids. 32 | target: transition.target?.map((target) => `#${target.id}`) ?? [], 33 | // but make it easy to access the actual id of the target 34 | targetIds: transition.target?.map((target) => target.id) ?? [], 35 | }); 36 | 37 | /** 38 | * The input type provided to a state mapper. 39 | * This type is similar to an XState StateNodeDefinition but removes redundant information 40 | * and provides data better suited to modification. 41 | * For example, instead of `on` and `always`, we only provide a `transitions` property, which is 42 | * always an array of {@link StructuredTransitionConfig}s. 43 | */ 44 | export type StructuredSourceStateNodeConfig< 45 | TContext, 46 | TStateSchema extends StateSchema, 47 | TEvent extends EventObject 48 | > = Pick< 49 | StateNodeDefinition, 50 | | "data" 51 | | "description" 52 | | "entry" 53 | | "exit" 54 | | "history" 55 | | "id" 56 | | "initial" 57 | | "key" 58 | | "meta" 59 | | "order" 60 | | "tags" 61 | | "type" 62 | | "invoke" 63 | > & { 64 | transitions: Array>; 65 | readonly stateDefinitions: Readonly< 66 | Record> 67 | >; 68 | }; 69 | 70 | /** 71 | * The output type expected of a state mapper. 72 | * 73 | * All `StructuredSourceStateNodeConfig`s are acceptable as `StructuredTransformedStateNodeConfig`s 74 | * but the intention is that mappers will make some modifications to the returned value. 75 | * 76 | * NOTE: mappers may only *add* states via the `states` property. 77 | */ 78 | export type StructuredTransformedStateNodeConfig< 79 | TContext, 80 | TStateSchema extends StateSchema, 81 | TEvent extends EventObject 82 | > = Omit< 83 | StructuredSourceStateNodeConfig, 84 | "stateDefinitions" | "transitions" 85 | > & { 86 | states?: Record< 87 | string, 88 | Partial< 89 | StructuredTransformedStateNodeConfig 90 | > 91 | >; 92 | transitions: Array>; 93 | }; 94 | 95 | /** 96 | * A hybrid between an XState `TransitionConfig` and a `TransitionDefinition`. 97 | * `TransitionDefinition`s are unsuitable for mapping operations because they 98 | * represent targets (and sources) as StateNodes, which are likely about to be 99 | * changed. Instead, we ensure targets are represented as state id references 100 | * and we remove sources, which are obvious from context. 101 | */ 102 | export interface StructuredTransitionConfig< 103 | TContext, 104 | TEvent extends EventObject 105 | > extends TransitionConfig { 106 | /** The type of the event that will trigger this transition. */ 107 | event: TEvent["type"]; 108 | /** The condtion, if any guarding this transition. */ 109 | cond?: Guard; 110 | /** The actions to execute while taking this transition. */ 111 | actions: Array>; 112 | /** The in-state guard guarding this transition. */ 113 | in?: StateValue; 114 | /** Is this an internal transition. */ 115 | internal?: boolean; 116 | /** The array of target state references (e.g. "#(machine).myState"). */ 117 | target: Array; 118 | /** The array of target state ids (e.g. "(machine).myState"). */ 119 | targetIds: Array; 120 | /** The metadata associated with this transition. */ 121 | meta?: Record; 122 | /** The description associated with this transition. */ 123 | description?: string; 124 | } 125 | 126 | export type StructuredTransformedTransitionConfig< 127 | TContext, 128 | TEvent extends EventObject 129 | > = Omit, "targetIds">; 130 | 131 | const toStructuredSourceStateNodeConfig = < 132 | TContext, 133 | TStateSchema extends StateSchema, 134 | TEvent extends EventObject 135 | >( 136 | state: StateNodeDefinition 137 | ): StructuredSourceStateNodeConfig => ({ 138 | data: state.data, 139 | description: state.description, 140 | entry: state.entry, 141 | exit: state.exit, 142 | history: state.history, 143 | id: state.id, 144 | initial: state.initial, 145 | invoke: state.invoke, 146 | key: state.key, 147 | meta: state.meta, 148 | transitions: state.transitions.map( 149 | transitionDefinitionToStructuredSourceConfig 150 | ), 151 | order: state.order, 152 | stateDefinitions: state.states as any, 153 | tags: state.tags, 154 | type: state.type, 155 | }); 156 | 157 | /** 158 | * Mapper function used by {@link mapStates} to map a 159 | * {@link StructuredSourceStateNodeConfig} and a {@link StatePath} 160 | * to an updated {@link StructuredTransformedStateNodeConfig}. 161 | * 162 | * Implementations should likely have lots of `...`s and `concat`s to 163 | * ensure that you are preserving the parts of the source state 164 | * that you aren't direclty modifying. 165 | 166 | * `node`: An {@link StructuredSourceStateNodeConfig} representing the 167 | * current state . 168 | * 169 | * `statePath`: 170 | * 1 171 | * 2 3 172 | * 4 6 173 | * 174 | * Imagining the numbers above as states, 175 | * when processing node "4" in the tree above, paths will be [mapper(node 1), mapper(node 2)]. 176 | */ 177 | export type MapStatesMapper< 178 | TContext, 179 | TStateSchema extends StateSchema, 180 | TEvent extends EventObject 181 | > = ( 182 | node: StructuredSourceStateNodeConfig, 183 | statePath: StatePath< 184 | StructuredTransformedStateNodeConfig 185 | > 186 | ) => StructuredTransformedStateNodeConfig; 187 | 188 | /** 189 | * Construct a new `StateNodeConfig` (suitable for passing to `createMachine`) 190 | * by walking an existing `StateMachine` (e.g. the output of `createMachine`) 191 | * and replacing each state with the output of the supplied `mapper` applied 192 | * to that state. 193 | * 194 | * @example 195 | * Update state metadata: 196 | * ``` 197 | * import { createMachine } from "xstate"; 198 | * import { mapStates } from "@simplystated/f-of-xstate"; 199 | * const machine = createMachine(...); 200 | * const config = mapStatesFromDefinition( 201 | * machine, 202 | * (state) => ({ 203 | * ...state, 204 | * meta: { 205 | * ...state.meta, 206 | * stateId: state.id 207 | * } 208 | * }) 209 | * ); 210 | * const updatedMachine = createMachine(config); 211 | * ``` 212 | * 213 | * @param root StateMachine or StateNode to map over. 214 | * @param mapper Function that maps the existing node and 215 | * a state path to a possibly-updated new state. 216 | * 217 | * Generally, `mapper` should return a modified copy of the provided state. 218 | * E.g. with `(state) => ({ ...state, modifications: "here" })` 219 | * You should likely have lots of `...`s and `concat`s to 220 | * ensure that you are preserving the parts of the source state 221 | * that you aren't direclty modifying. 222 | * 223 | * @see {@link MapStatesMapper}. 224 | * 225 | * @returns The new `MachineConfig` resulting from applying `mapper` 226 | * to each state. 227 | */ 228 | export const mapStates = < 229 | TContext, 230 | TStateSchema extends StateSchema, 231 | TEvent extends EventObject 232 | >( 233 | root: StateMachine, 234 | mapper: MapStatesMapper 235 | ): MachineConfig => { 236 | const machineDefinition = mapStatesFromDefinition( 237 | root.options, 238 | root.definition, 239 | mapper 240 | ); 241 | return { 242 | ...machineDefinition, 243 | context: root.context as any, 244 | predictableActionArguments: root.config.predictableActionArguments, 245 | preserveActionOrder: root.config.preserveActionOrder, 246 | }; 247 | }; 248 | 249 | /** 250 | * Most users will prefer to use {@link mapStates}. 251 | * Only use `mapStatesFromDefinition` if you already have a StateNodeDefinition. 252 | * 253 | * @param machineOptions `MachineOptions` for the definition. 254 | * @param definition StateNodeDefinition to map over. 255 | * @param mapper Function that maps the existing node and 256 | * a state path to a possibly-updated new state. 257 | 258 | * Generally, `mapper` should return a modified copy of the provided state. 259 | * E.g. with `(state) => ({ ...state, modifications: "here" })` 260 | * You should likely have lots of `...`s and `concat`s to 261 | * ensure that you are preserving the parts of the source state 262 | * that you aren't direclty modifying. 263 | * 264 | * @see {@link MapStatesMapper}. 265 | * 266 | * @returns The new `StateNodeConfig` resulting from applying `mapper` 267 | * to each state. 268 | */ 269 | export const mapStatesFromDefinition = < 270 | TContext, 271 | TStateSchema extends StateSchema, 272 | TEvent extends EventObject 273 | >( 274 | machineOptions: MachineOptions, 275 | definition: StateNodeDefinition, 276 | mapper: MapStatesMapper 277 | ): StateNodeConfig => 278 | mapStatesWithPathFromDefinition( 279 | machineOptions, 280 | definition, 281 | [], 282 | mapper 283 | ) as any; 284 | 285 | /** 286 | * The path of states from the root to the current node. 287 | */ 288 | export type StatePath> = Array; 289 | 290 | const invokeDefinitionToConfig = ( 291 | machineOptions: MachineOptions, 292 | invokeDefinition: InvokeDefinition 293 | ) => { 294 | const src = 295 | typeof invokeDefinition.src === "string" 296 | ? invokeDefinition.src 297 | : invokeDefinition.src.type; 298 | return { 299 | ...invokeDefinition, 300 | src: (machineOptions.services?.[src] as any) ?? src, 301 | }; 302 | }; 303 | 304 | const adaptStructuredStateNodeConfig = < 305 | TContext, 306 | TStateSchema extends StateSchema, 307 | TEvent extends EventObject 308 | >( 309 | machineOptions: MachineOptions, 310 | state: StructuredTransformedStateNodeConfig 311 | ): StateNodeConfig => { 312 | const [on, always]: [ 313 | Array>, 314 | Array> 315 | ] = state.transitions.reduce( 316 | ([on, always], transition) => { 317 | return transition.event === "" 318 | ? [on, (always as any).concat([transition])] 319 | : [(on as any).concat([transition]), always]; 320 | }, 321 | [[], []] 322 | ); 323 | 324 | return { 325 | ...state, 326 | invoke: state.invoke.map((invoke) => 327 | invokeDefinitionToConfig(machineOptions, invoke) 328 | ), 329 | on, 330 | always, 331 | } as any; 332 | }; 333 | 334 | const mapStatesWithPathFromDefinition = < 335 | TContext, 336 | TStateSchema extends StateSchema, 337 | TEvent extends EventObject 338 | >( 339 | machineOptions: MachineOptions, 340 | definition: StateNodeDefinition, 341 | path: Array< 342 | StructuredTransformedStateNodeConfig 343 | >, 344 | mapper: MapStatesMapper 345 | ): StateNodeConfig => { 346 | const mapped = mapper(toStructuredSourceStateNodeConfig(definition), path); 347 | 348 | const config = adaptStructuredStateNodeConfig(machineOptions, mapped); 349 | const newPath = path.concat(mapped); 350 | 351 | const newState = config; 352 | 353 | newState.states = Object.keys(definition.states).reduce( 354 | (states, key) => ({ 355 | ...states, 356 | [key]: mapStatesWithPathFromDefinition( 357 | machineOptions, 358 | (definition.states as any)[key], 359 | newPath, 360 | mapper 361 | ), 362 | }), 363 | newState.states ?? ({} as any) 364 | ); 365 | 366 | return newState; 367 | }; 368 | -------------------------------------------------------------------------------- /src/state-mappers.ts: -------------------------------------------------------------------------------- 1 | import { ActionObject, EventObject, StateSchema } from "xstate"; 2 | import { 3 | MapStatesMapper, 4 | StatePath, 5 | StructuredSourceStateNodeConfig, 6 | StructuredTransformedStateNodeConfig, 7 | StructuredTransformedTransitionConfig, 8 | StructuredTransitionConfig, 9 | } from "./map-states"; 10 | 11 | /** 12 | * Produces a mapper to be used with {@link mapStates} to map over 13 | * all state transitions. 14 | * 15 | * One common gotcha: you either need to do a bunch of work to figure out 16 | * relative target ids or you can rely on the fact that `StructuredTransitionConfig`s 17 | * always have unique ids and use `#${absoluteTargets}` for transitions. 18 | * 19 | * @returns A mapper to be passed to {@link mapStates} 20 | */ 21 | export const mapTransitions = 22 | , TEvent extends EventObject>( 23 | mapper: ( 24 | transition: StructuredTransitionConfig 25 | ) => StructuredTransitionConfig 26 | ) => 27 | ( 28 | state: StructuredSourceStateNodeConfig 29 | ): StructuredTransformedStateNodeConfig => ({ 30 | ...state, 31 | transitions: state.transitions.map(mapper), 32 | }); 33 | 34 | /** 35 | * Produces a mapper to be used with {@link mapStates} to filter 36 | * state transitions. Only transitions for which the predicate 37 | * returns true will be kept. 38 | * 39 | * @returns A mapper to be passed to {@link mapStates} 40 | */ 41 | export const filterTransitions = 42 | , TEvent extends EventObject>( 43 | predicate: ( 44 | transition: StructuredTransitionConfig, 45 | state: StructuredSourceStateNodeConfig, 46 | path: StatePath< 47 | StructuredTransformedStateNodeConfig 48 | > 49 | ) => boolean 50 | ) => 51 | ( 52 | state: StructuredSourceStateNodeConfig, 53 | path: StatePath< 54 | StructuredTransformedStateNodeConfig 55 | > 56 | ): StructuredTransformedStateNodeConfig => ({ 57 | ...state, 58 | transitions: state.transitions.filter((transition) => 59 | predicate(transition, state, path) 60 | ), 61 | }); 62 | 63 | /** 64 | * Produces a mapper to be used with {@link mapStates} to append 65 | * state transitions. 66 | * 67 | * One common gotcha: you either need to do a bunch of work to figure out 68 | * relative target ids or you can rely on the fact that `StructuredTransitionConfig`s 69 | * always have unique ids and use `#${absoluteTargets}` for transitions. 70 | * 71 | * @returns A mapper to be passed to {@link mapStates} 72 | */ 73 | export const appendTransitions = 74 | , TEvent extends EventObject>( 75 | transitionsGetter: ( 76 | state: StructuredSourceStateNodeConfig 77 | ) => Array> 78 | ) => 79 | ( 80 | state: StructuredSourceStateNodeConfig 81 | ): StructuredTransformedStateNodeConfig => ({ 82 | ...state, 83 | transitions: ( 84 | state.transitions as StructuredTransformedTransitionConfig< 85 | TContext, 86 | TEvent 87 | >[] 88 | ).concat(transitionsGetter(state)), 89 | }); 90 | 91 | /** 92 | * Produces a mapper to be used with {@link mapStates} to invoke another 93 | * mapper only if the state matches some predicate. 94 | * 95 | * @returns A mapper to be passed to {@link mapStates} 96 | */ 97 | export const filterMapStates = 98 | , TEvent extends EventObject>( 99 | predicate: ( 100 | state: StructuredSourceStateNodeConfig, 101 | path: StatePath< 102 | StructuredTransformedStateNodeConfig 103 | > 104 | ) => boolean, 105 | mapper: MapStatesMapper 106 | ) => 107 | ( 108 | state: StructuredSourceStateNodeConfig, 109 | path: StatePath< 110 | StructuredTransformedStateNodeConfig 111 | > 112 | ): StructuredTransformedStateNodeConfig => 113 | predicate(state, path) ? mapper(state, path) : state; 114 | 115 | /** 116 | * Produces a mapper to be used with {@link mapStates} to append 117 | * the provided actions onto every transition in the mapped machine. 118 | * 119 | * @example 120 | * Appending a logging action to every transition. 121 | * ``` 122 | * import { createMachine, actions } from "xstate"; 123 | * import { mapStates, appendActionsToAllTransitions } from "@simplystated/f-of-xstate"; 124 | * const machine = createMachine(...); 125 | * const config = mapStates( 126 | * machine, 127 | * appendActionsToAllTransitions([ 128 | * actions.log((_, evt) => `Hello ${evt.type}`) 129 | * ]) 130 | * ); 131 | * const updatedMachine = createMachine(config); 132 | * ``` 133 | * 134 | * @param actions Array of `ActionObject`s to append to every transition's actions. 135 | * @returns A mapper to be passed to {@link mapStates} 136 | */ 137 | export const appendActionsToAllTransitions = < 138 | TContext, 139 | TEvent extends EventObject, 140 | Action extends ActionObject 141 | >( 142 | actions: Array 143 | ) => 144 | mapTransitions((transition) => ({ 145 | ...transition, 146 | actions: transition.actions.concat(actions), 147 | })); 148 | -------------------------------------------------------------------------------- /tests/arbitrary-machine.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { createMachine, StateNode } from "xstate"; 3 | import { arbitraryMachine } from "../src/arbitrary-machine"; 4 | 5 | type AnyStateNode = StateNode; 6 | 7 | // console.log(JSON.stringify(fc.sample(arbitraryMachine, 5), null, 2)); 8 | 9 | describe("arbitraryMachine", () => { 10 | afterEach(() => { 11 | jest.restoreAllMocks(); 12 | }); 13 | 14 | it("should generate valid machines", () => { 15 | const warn = jest.spyOn(global.console, "warn"); 16 | const error = jest.spyOn(global.console, "error"); 17 | 18 | fc.assert( 19 | fc.property(arbitraryMachine, ({ machine }) => { 20 | const m = createMachine({ 21 | ...machine, 22 | predictableActionArguments: true, 23 | }) as unknown as AnyStateNode; 24 | // a bit weird but we're using `definition` and `transitions` for their side effects. 25 | // they either throw or log when issues are found with the machine configuration (not all possible issues). 26 | m.definition; 27 | walkStateNodes(m, (node) => node.transitions); 28 | 29 | try { 30 | expect(warn).not.toHaveBeenCalled(); 31 | expect(error).not.toHaveBeenCalled(); 32 | } finally { 33 | jest.clearAllMocks(); 34 | } 35 | 36 | return true; 37 | }), 38 | { numRuns: 1000 } 39 | ); 40 | }); 41 | }); 42 | 43 | const walkStateNodes = ( 44 | stateNode: AnyStateNode, 45 | process: (stateNode: AnyStateNode) => void 46 | ) => { 47 | process(stateNode); 48 | Object.keys(stateNode.states ?? {}).forEach((state) => 49 | walkStateNodes(stateNode.states[state], process) 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /tests/categorize-transitions.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { createMachine, StateNodeConfig, TransitionDefinition } from "xstate"; 3 | import { arbitraryMachine } from "../src/arbitrary-machine"; 4 | import { 5 | categorizeTransitions, 6 | TransitionsByCategory, 7 | } from "../src/categorize-transitions"; 8 | import { getAllTransitions } from "../src/get-all-transitions"; 9 | 10 | describe("categorizeTransitions", () => { 11 | it("should return all transitions", () => { 12 | fc.assert( 13 | fc.property(arbitraryMachine, ({ machine }) => { 14 | const m = createMachine({ 15 | ...machine, 16 | predictableActionArguments: true, 17 | }); 18 | const transitions = getAllTransitions(m); 19 | const categories = categorizeTransitions(transitions); 20 | expect( 21 | new Set( 22 | categories.always 23 | .concat(categories.delayDone) 24 | .concat(categories.eventOccurred) 25 | .concat(categories.invocationDone.map((i) => i.transition)) 26 | .concat(categories.invocationError.map((i) => i.transition)) 27 | .concat(categories.stateDone.map((s) => s.transition)) 28 | .concat(categories.wildcard) 29 | ) 30 | ).toEqual(new Set(transitions)); 31 | return true; 32 | }) 33 | ); 34 | }); 35 | 36 | it("should recognize mutations to always transitions", () => { 37 | fc.assert( 38 | fc.property(arbitraryMachine, ({ machine, states }) => { 39 | return testMutationProperty( 40 | machine, 41 | states, 42 | ({ always: _always, on: _on, ...machine }) => machine, 43 | ({ always, ...machine }, target) => ({ 44 | ...machine, 45 | always: ((always as Array) ?? []).concat([ 46 | { target: `#${target}` }, 47 | ]), 48 | }), 49 | "always" 50 | ); 51 | }) 52 | ); 53 | }); 54 | 55 | it("should recognize mutations to wildcard transitions", () => { 56 | fc.assert( 57 | fc.property(arbitraryMachine, ({ machine, states }) => { 58 | return testMutationProperty( 59 | machine, 60 | states, 61 | ({ on: _, ...machine }) => { 62 | return { 63 | ...machine, 64 | }; 65 | }, 66 | ({ on, ...machine }, target) => ({ 67 | ...machine, 68 | on: { 69 | ...on, 70 | "*": ((on as any)?.["*"] ?? []).concat([ 71 | { target: `#${target}` }, 72 | ]), 73 | }, 74 | }), 75 | "wildcard" 76 | ); 77 | }) 78 | ); 79 | }); 80 | 81 | it("should recognize mutations to eventOccurred transitions", () => { 82 | fc.assert( 83 | fc.property(arbitraryMachine, ({ machine, states }) => { 84 | return testMutationProperty( 85 | machine, 86 | states, 87 | ({ on: _, ...machine }) => { 88 | return { 89 | ...machine, 90 | }; 91 | }, 92 | ({ on, ...machine }, target) => ({ 93 | ...machine, 94 | on: { 95 | ...on, 96 | anEvent: ((on as any)?.["*"] ?? []).concat([ 97 | { target: `#${target}` }, 98 | ]), 99 | }, 100 | }), 101 | "eventOccurred" 102 | ); 103 | }) 104 | ); 105 | }); 106 | 107 | it("should recognize mutations to delayDone transitions", () => { 108 | fc.assert( 109 | fc.property(arbitraryMachine, ({ machine, states }) => { 110 | return testMutationProperty( 111 | machine, 112 | states, 113 | ({ after: _after, on: _on, always: _always, ...machine }) => { 114 | return { 115 | ...machine, 116 | }; 117 | }, 118 | ({ after, ...machine }, target) => ({ 119 | ...machine, 120 | after: { 121 | ...after, 122 | timeout: ((after as any)?.["timeout"] ?? []).concat([ 123 | { target: `#${target}` }, 124 | ]), 125 | }, 126 | }), 127 | "delayDone" 128 | ); 129 | }) 130 | ); 131 | }); 132 | 133 | it("should recognize mutations to invocationDone transitions", () => { 134 | fc.assert( 135 | fc.property(arbitraryMachine, ({ machine, states }) => { 136 | return testMutationProperty( 137 | machine, 138 | states, 139 | ({ invoke: _, ...machine }) => { 140 | return { 141 | ...machine, 142 | }; 143 | }, 144 | ({ invoke, ...machine }, target) => ({ 145 | ...machine, 146 | invoke: ((invoke as any) ?? []).concat({ 147 | src: "service", 148 | id: "id", 149 | onDone: `#${target}`, 150 | }), 151 | }), 152 | "invocationDone" 153 | ); 154 | }) 155 | ); 156 | }); 157 | 158 | it("should recognize mutations to invocationError transitions", () => { 159 | fc.assert( 160 | fc.property(arbitraryMachine, ({ machine, states }) => { 161 | return testMutationProperty( 162 | machine, 163 | states, 164 | ({ invoke: _, ...machine }) => { 165 | return { 166 | ...machine, 167 | }; 168 | }, 169 | ({ invoke, ...machine }, target) => ({ 170 | ...machine, 171 | invoke: ((invoke as any) ?? []).concat({ 172 | src: "service", 173 | id: "id", 174 | onError: `#${target}`, 175 | }), 176 | }), 177 | "invocationError" 178 | ); 179 | }) 180 | ); 181 | }); 182 | 183 | it("should recognize mutations to stateDone transitions", () => { 184 | fc.assert( 185 | fc.property(arbitraryMachine, ({ machine, states }) => { 186 | return testMutationProperty( 187 | machine, 188 | states, 189 | ({ states: _, ...machine }) => { 190 | // TODO: this is a bit cheap 191 | return { 192 | ...machine, 193 | }; 194 | }, 195 | ({ states, ...machine }, target) => ({ 196 | ...machine, 197 | states: { 198 | ...states, 199 | extraState: { 200 | ...states?.["extraState"], 201 | onDone: `#${target}`, 202 | }, 203 | }, 204 | }), 205 | "stateDone" 206 | ); 207 | }) 208 | ); 209 | }); 210 | }); 211 | 212 | type AnyStateNodeConfig = StateNodeConfig; 213 | 214 | const testMutationProperty = ( 215 | machine: AnyStateNodeConfig, 216 | states: Array, 217 | removeSomeTransitions: (machine: AnyStateNodeConfig) => AnyStateNodeConfig, 218 | appendNewTransition: ( 219 | machine: AnyStateNodeConfig, 220 | target: string 221 | ) => AnyStateNodeConfig, 222 | category: keyof TransitionsByCategory 223 | ): boolean => { 224 | const m = createMachine({ 225 | ...machine, 226 | predictableActionArguments: true, 227 | }); 228 | const initialCategories = categorizeTransitions(getAllTransitions(m)); 229 | 230 | const categoryTransitions = categorizeTransitions(m.transitions)[category]; 231 | const existingTargets = categoryTransitions 232 | .map((t) => (t as any).transition ?? t) 233 | .flatMap((t: TransitionDefinition) => 234 | t.target?.map((target) => target.id) 235 | ) 236 | .filter((x) => !!x) as Array; 237 | 238 | const unusedTargets = setDifference(states, existingTargets); 239 | 240 | if (unusedTargets.length === 0) { 241 | if (existingTargets.length === 0) { 242 | // we have nothing to remove and nothing to add... 243 | return true; 244 | } 245 | 246 | // try removing an event 247 | const m2 = createMachine({ 248 | ...removeSomeTransitions(machine), 249 | predictableActionArguments: true, 250 | }); 251 | 252 | const newCategories = categorizeTransitions(getAllTransitions(m2)); 253 | expect(newCategories[category].length).toBeLessThan( 254 | initialCategories[category].length 255 | ); 256 | } else { 257 | // try adding an event 258 | const m2 = createMachine({ 259 | ...appendNewTransition(machine, unusedTargets[0]), 260 | predictableActionArguments: true, 261 | }); 262 | 263 | const newCategories = categorizeTransitions(getAllTransitions(m2)); 264 | expect(newCategories[category].length).toBeGreaterThan( 265 | initialCategories[category].length 266 | ); 267 | } 268 | 269 | return true; 270 | }; 271 | 272 | const setDifference = (universe: Array, toRemove: Array) => { 273 | const remSet = new Set(toRemove); 274 | return universe.filter((i) => !remSet.has(i)); 275 | }; 276 | -------------------------------------------------------------------------------- /tests/get-all-actions.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { createMachine } from "xstate"; 3 | import { arbitraryMachine } from "../src/arbitrary-machine"; 4 | import { getAllActions } from "../src/get-all-actions"; 5 | 6 | describe("getAllActions", () => { 7 | it("should return all actions", () => { 8 | fc.assert( 9 | fc.property(arbitraryMachine, ({ machine, actions }) => { 10 | const m = createMachine({ 11 | ...machine, 12 | predictableActionArguments: true, 13 | }); 14 | expect(new Set(getAllActions(m).map((a) => a.type))).toEqual( 15 | new Set(actions) 16 | ); 17 | return true; 18 | }), 19 | { numRuns: 1000 } 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/get-all-conditions.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { createMachine } from "xstate"; 3 | import { arbitraryMachine } from "../src/arbitrary-machine"; 4 | import { getAllConditions } from "../src/get-all-conditions"; 5 | 6 | describe("getAllConditions", () => { 7 | it("should return all conditions", () => { 8 | fc.assert( 9 | fc.property(arbitraryMachine, ({ machine, conditions }) => { 10 | const m = createMachine({ 11 | ...machine, 12 | predictableActionArguments: true, 13 | }); 14 | expect(new Set(getAllConditions(m).map((c) => c.name))).toEqual( 15 | new Set(conditions) 16 | ); 17 | return true; 18 | }), 19 | { numRuns: 1000 } 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/get-all-invocations.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { createMachine } from "xstate"; 3 | import { arbitraryMachine } from "../src/arbitrary-machine"; 4 | import { getAllInvocations } from "../src/get-all-invocations"; 5 | 6 | describe("getAllInvocations", () => { 7 | it("should return all invocations", () => { 8 | fc.assert( 9 | fc.property(arbitraryMachine, ({ machine, services }) => { 10 | const m = createMachine({ 11 | ...machine, 12 | predictableActionArguments: true, 13 | }); 14 | expect(new Set(getAllInvocations(m).map((c) => c.src))).toEqual( 15 | new Set(services) 16 | ); 17 | return true; 18 | }), 19 | { numRuns: 1000 } 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/get-all-states.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { createMachine } from "xstate"; 3 | import { arbitraryMachine } from "../src/arbitrary-machine"; 4 | import { getAllProperStates, getAllStates } from "../src/get-all-states"; 5 | 6 | describe("getAllStates", () => { 7 | it("should return all state ids", () => { 8 | fc.assert( 9 | fc.property(arbitraryMachine, ({ machine, states }) => { 10 | const m = createMachine({ 11 | ...machine, 12 | predictableActionArguments: true, 13 | }); 14 | expect(new Set(getAllStates(m).map((s) => s.id))).toEqual( 15 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 16 | new Set(states.concat([machine.id!])) 17 | ); 18 | return true; 19 | }), 20 | { numRuns: 1000 } 21 | ); 22 | }); 23 | }); 24 | 25 | describe("getAllProperStates", () => { 26 | it("should return all state ids", () => { 27 | fc.assert( 28 | fc.property(arbitraryMachine, ({ machine, states }) => { 29 | const m = createMachine({ 30 | ...machine, 31 | predictableActionArguments: true, 32 | }); 33 | expect(new Set(getAllProperStates(m).map((s) => s.id))).toEqual( 34 | new Set(states) 35 | ); 36 | return true; 37 | }), 38 | { numRuns: 1000 } 39 | ); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/get-all-transitions.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { createMachine } from "xstate"; 3 | import { arbitraryMachine } from "../src/arbitrary-machine"; 4 | import { getAllTransitions } from "../src/get-all-transitions"; 5 | 6 | describe("getAllTransitions", () => { 7 | it("should return all transitions", () => { 8 | fc.assert( 9 | fc.property(arbitraryMachine, ({ machine, events }) => { 10 | const m = createMachine({ 11 | ...machine, 12 | predictableActionArguments: true, 13 | }); 14 | expect(new Set(getAllTransitions(m).map((e) => e.eventType))).toEqual( 15 | new Set(events) 16 | ); 17 | return true; 18 | }), 19 | { numRuns: 1000 } 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/map-states.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { 3 | assign, 4 | createMachine, 5 | interpret, 6 | MachineConfig, 7 | StateNodeConfig, 8 | } from "xstate"; 9 | import { arbitraryMachine } from "../src/arbitrary-machine"; 10 | import { getAllActions } from "../src/get-all-actions"; 11 | import { getAllConditions } from "../src/get-all-conditions"; 12 | import { getAllInvocations } from "../src/get-all-invocations"; 13 | import { getAllStates } from "../src/get-all-states"; 14 | import { getAllTransitions } from "../src/get-all-transitions"; 15 | import { mapStates } from "../src/map-states"; 16 | 17 | describe("mapStates", () => { 18 | afterEach(() => { 19 | jest.restoreAllMocks(); 20 | }); 21 | 22 | it("should map all state ids", () => { 23 | const warn = jest.spyOn(global.console, "warn"); 24 | const error = jest.spyOn(global.console, "error"); 25 | 26 | fc.assert( 27 | fc.property( 28 | arbitraryMachine, 29 | ({ machine, states, events, actions, conditions }) => { 30 | const m = createMachine({ 31 | ...machine, 32 | predictableActionArguments: true, 33 | }); 34 | const mapped = mapStates(m, (node) => ({ ...node, invoke: [] })); 35 | const mappedMachine = createMachine({ 36 | ...mapped, 37 | predictableActionArguments: true, 38 | }); 39 | 40 | expect(getAllInvocations(mappedMachine)).toHaveLength(0); 41 | expect(new Set(getAllStates(mappedMachine).map((s) => s.id))).toEqual( 42 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 43 | new Set(states.concat([machine.id!])) 44 | ); 45 | expect( 46 | new Set(getAllActions(mappedMachine).map((a) => a.type)) 47 | ).toEqual(new Set(actions)); 48 | expect( 49 | new Set(getAllTransitions(mappedMachine).map((e) => e.eventType)) 50 | ).toEqual(new Set(events)); 51 | expect( 52 | new Set(getAllConditions(mappedMachine).map((e) => e.name)) 53 | ).toEqual(new Set(conditions)); 54 | 55 | // we don't want to create machine configs that has warnings from a machine config that didn't have warnings. 56 | try { 57 | expect(warn).not.toHaveBeenCalled(); 58 | expect(error).not.toHaveBeenCalled(); 59 | } finally { 60 | jest.clearAllMocks(); 61 | } 62 | 63 | return true; 64 | } 65 | ), 66 | { numRuns: 1000 } 67 | ); 68 | }); 69 | 70 | it("should provide correct paths", () => { 71 | fc.assert( 72 | fc.property(arbitraryMachine, ({ machine, states }) => { 73 | const m = createMachine({ 74 | ...machine, 75 | predictableActionArguments: true, 76 | }); 77 | const parentByChild = new Map(); 78 | const _ = mapStates(m, (node, path) => { 79 | parentByChild.set( 80 | node.id, 81 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 82 | path.length > 0 ? path[path.length - 1].id! : null 83 | ); 84 | return node; 85 | }); 86 | 87 | expect(parentByChild.size).toEqual(states.length + 1); // +1 b/c of the root machine state 88 | 89 | const allStates = getAllStates(m); 90 | 91 | for (const state of allStates) { 92 | const parentId = state.id; 93 | 94 | for (const child of Object.values(state.states)) { 95 | const nodeChild = child as StateNodeConfig; 96 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 97 | expect(parentByChild.get(nodeChild.id!)).toEqual(parentId); 98 | } 99 | } 100 | 101 | return true; 102 | }), 103 | { numRuns: 500 } 104 | ); 105 | }); 106 | 107 | it("should provide correct targets and target ids", () => { 108 | const warn = jest.spyOn(global.console, "warn"); 109 | const error = jest.spyOn(global.console, "error"); 110 | 111 | fc.assert( 112 | fc.property(arbitraryMachine, ({ machine }) => { 113 | const m = createMachine({ 114 | ...machine, 115 | predictableActionArguments: true, 116 | }); 117 | const mapped = mapStates(m, (node) => { 118 | node.transitions.forEach((transition) => 119 | expect(transition.target.join(",")).toEqual( 120 | transition.targetIds.map((i) => `#${i}`).join(",") 121 | ) 122 | ); 123 | return node; 124 | }); 125 | createMachine({ 126 | ...mapped, 127 | predictableActionArguments: true, 128 | }); 129 | 130 | // we don't want to create machine configs that has warnings from a machine config that didn't have warnings. 131 | try { 132 | expect(warn).not.toHaveBeenCalled(); 133 | expect(error).not.toHaveBeenCalled(); 134 | } finally { 135 | jest.clearAllMocks(); 136 | } 137 | 138 | return true; 139 | }), 140 | { numRuns: 500 } 141 | ); 142 | }); 143 | }); 144 | 145 | describe("comprehensive example", () => { 146 | afterEach(() => { 147 | jest.restoreAllMocks(); 148 | }); 149 | 150 | it("should work with identity mapper", async () => { 151 | const warn = jest.spyOn(global.console, "warn"); 152 | const error = jest.spyOn(global.console, "error"); 153 | 154 | const machineConfig = { 155 | predictableActionArguments: true, 156 | initial: "a", 157 | states: { 158 | a: { 159 | on: { 160 | next: { 161 | target: "a2", 162 | actions: "doSomething", 163 | cond: () => true, 164 | }, 165 | }, 166 | }, 167 | a2: { 168 | entry: "doSomething", 169 | always: [ 170 | { target: "b", cond: "isTrue" }, 171 | { target: "c", cond: () => false }, 172 | ], 173 | }, 174 | b: { 175 | type: "parallel", 176 | onDone: "c", 177 | exit: "doSomethingElse", 178 | states: { 179 | b_1: { 180 | initial: "b_1_a", 181 | states: { 182 | b_1_a: { 183 | on: { 184 | "*": { 185 | target: "b_1_b", 186 | actions: assign({ 187 | hello: "world", 188 | }), 189 | }, 190 | }, 191 | }, 192 | b_1_b: { 193 | type: "final", 194 | }, 195 | }, 196 | }, 197 | b_2: { 198 | initial: "b_2_a", 199 | states: { 200 | b_2_a: { 201 | on: { 202 | next: "b_2_b", 203 | }, 204 | }, 205 | b_2_b: { 206 | on: { 207 | next: "b_2_c", 208 | }, 209 | }, 210 | b_2_c: { 211 | type: "final", 212 | }, 213 | }, 214 | }, 215 | }, 216 | }, 217 | c: { 218 | entry: { 219 | type: "validateMeta", 220 | meta: { 221 | hello: "there", 222 | }, 223 | }, 224 | after: { 225 | 2: { 226 | target: "d", 227 | actions: assign({ 228 | hi: "world", 229 | }), 230 | }, 231 | }, 232 | }, 233 | d: { 234 | invoke: { 235 | src: () => new Promise((resolve) => resolve("hi")), 236 | onDone: "e", 237 | }, 238 | }, 239 | e: { 240 | invoke: { 241 | src: "promise", 242 | onError: "f", 243 | }, 244 | }, 245 | f: {}, 246 | }, 247 | } as MachineConfig; 248 | 249 | const machine = createMachine( 250 | mapStates(createMachine(machineConfig).withContext({}), (node) => node) 251 | ); 252 | 253 | const doSomethingAction = jest.fn(); 254 | const doSomethingElseAction = jest.fn(); 255 | const failedPromiseService = jest.fn( 256 | () => new Promise((_, reject) => reject("bad")) 257 | ); 258 | const validateMetaAction = jest.fn( 259 | (_ctx: any, _evt: any, meta: any) => meta.action.meta.hello === "there" 260 | ); 261 | 262 | const service = interpret( 263 | machine.withConfig({ 264 | services: { 265 | promise: failedPromiseService, 266 | }, 267 | actions: { 268 | doSomething: doSomethingAction, 269 | doSomethingElse: doSomethingElseAction, 270 | validateMeta: validateMetaAction, 271 | }, 272 | guards: { 273 | isTrue: () => true, 274 | }, 275 | }) 276 | ).start(); 277 | 278 | for (let i = 0; i < 10; ++i) { 279 | service.send("next"); 280 | } 281 | 282 | await new Promise((resolve) => setTimeout(resolve, 500)); 283 | 284 | expect(service.getSnapshot().matches("f")).toBe(true); 285 | 286 | expect(doSomethingAction).toHaveBeenCalledTimes(2); 287 | expect(doSomethingElseAction).toHaveBeenCalledTimes(1); 288 | expect(failedPromiseService).toHaveBeenCalledTimes(1); 289 | expect(validateMetaAction).toHaveBeenCalledTimes(1); 290 | expect(validateMetaAction).toHaveLastReturnedWith(true); 291 | 292 | try { 293 | expect(warn).not.toHaveBeenCalled(); 294 | expect(error).not.toHaveBeenCalled(); 295 | } finally { 296 | jest.clearAllMocks(); 297 | } 298 | }); 299 | 300 | it("should work with child states returned by the mapper", async () => { 301 | const warn = jest.spyOn(global.console, "warn"); 302 | const error = jest.spyOn(global.console, "error"); 303 | 304 | const machineConfig = { 305 | predictableActionArguments: true, 306 | id: "m", 307 | initial: "a", 308 | states: { 309 | a: { 310 | on: { 311 | next: { 312 | target: ".a2", 313 | }, 314 | }, 315 | states: { 316 | a2: {}, 317 | }, 318 | }, 319 | }, 320 | } as MachineConfig; 321 | 322 | const machine = createMachine( 323 | mapStates(createMachine(machineConfig).withContext({}), (node) => ({ 324 | ...node, 325 | states: 326 | node.id === "m" 327 | ? void 0 328 | : { 329 | subState: {}, 330 | }, 331 | })) 332 | ); 333 | 334 | expect(new Set(getAllStates(machine).map((s) => s.id))).toEqual( 335 | new Set(["m", "m.a", "m.a.a2", "m.a.subState", "m.a.a2.subState"]) 336 | ); 337 | 338 | try { 339 | expect(warn).not.toHaveBeenCalled(); 340 | expect(error).not.toHaveBeenCalled(); 341 | } finally { 342 | jest.clearAllMocks(); 343 | } 344 | }); 345 | }); 346 | -------------------------------------------------------------------------------- /tests/state-mappers.test.ts: -------------------------------------------------------------------------------- 1 | import * as fc from "fast-check"; 2 | import { createMachine } from "xstate"; 3 | import { arbitraryMachine } from "../src/arbitrary-machine"; 4 | import { getAllTransitions } from "../src/get-all-transitions"; 5 | import { mapStates } from "../src/map-states"; 6 | import { 7 | appendActionsToAllTransitions, 8 | appendTransitions, 9 | filterTransitions, 10 | } from "../src/state-mappers"; 11 | 12 | describe("stateMappers", () => { 13 | afterEach(() => { 14 | jest.restoreAllMocks(); 15 | }); 16 | 17 | it("should add actions", () => { 18 | fc.assert( 19 | fc.property(arbitraryMachine, ({ machine, actions }) => { 20 | const m = createMachine({ 21 | ...machine, 22 | predictableActionArguments: true, 23 | }); 24 | const initialTransitions = getAllTransitions(m); 25 | 26 | const newAction = `newAction-${actions.join(";")}`; 27 | const mapped = mapStates( 28 | m, 29 | appendActionsToAllTransitions([{ type: newAction }]) 30 | ); 31 | const mappedMachine = createMachine({ 32 | ...mapped, 33 | predictableActionArguments: true, 34 | }); 35 | 36 | const transitions = getAllTransitions(mappedMachine); 37 | expect(new Set(transitions.map((t) => t.eventType))).toEqual( 38 | new Set(initialTransitions.map((t) => t.eventType)) 39 | ); 40 | 41 | transitions.forEach((transition) => 42 | expect( 43 | transition.actions[transition.actions.length - 1].type 44 | ).toEqual(newAction) 45 | ); 46 | 47 | return true; 48 | }), 49 | { numRuns: 1000 } 50 | ); 51 | }); 52 | 53 | it("should filter transitions", () => { 54 | fc.assert( 55 | fc.property(arbitraryMachine, ({ machine, events }) => { 56 | const m = createMachine({ 57 | ...machine, 58 | predictableActionArguments: true, 59 | }); 60 | const eventToKeep = events?.[0]; 61 | 62 | const mapped = mapStates( 63 | m, 64 | filterTransitions((transition) => transition.event === eventToKeep) 65 | ); 66 | const mappedMachine = createMachine({ 67 | ...mapped, 68 | predictableActionArguments: true, 69 | }); 70 | 71 | const transitions = getAllTransitions(mappedMachine); 72 | expect(new Set(transitions.map((t) => t.eventType))).toEqual( 73 | new Set(typeof eventToKeep === "undefined" ? [] : [eventToKeep]) 74 | ); 75 | 76 | return true; 77 | }), 78 | { numRuns: 500 } 79 | ); 80 | }); 81 | 82 | it("should append transitions", () => { 83 | fc.assert( 84 | fc.property( 85 | arbitraryMachine.filter(({ states }) => states.length > 0), 86 | ({ machine, states, events }) => { 87 | const m = createMachine({ 88 | ...machine, 89 | predictableActionArguments: true, 90 | }); 91 | 92 | const newEvent = `new-event-${events.join("-")}`; 93 | const newTarget = `#${states[0]}`; 94 | const newTransition = { 95 | event: newEvent, 96 | target: [newTarget], 97 | actions: [], 98 | }; 99 | 100 | const mapped = mapStates( 101 | m, 102 | appendTransitions(() => [newTransition]) 103 | ); 104 | const mappedMachine = createMachine({ 105 | ...mapped, 106 | predictableActionArguments: true, 107 | }); 108 | 109 | const transitions = getAllTransitions(mappedMachine); 110 | expect( 111 | transitions.map((t) => ({ 112 | event: t.eventType, 113 | target: t.target?.map((t) => `#${t.id}`), 114 | actions: [], 115 | })) 116 | ).toContainEqual(newTransition); 117 | 118 | return true; 119 | } 120 | ), 121 | { numRuns: 500 } 122 | ); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2018" 7 | ], 8 | "outDir": "dist", 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "dist", 29 | "tests", 30 | "node_modules", 31 | "cdk.out" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/index.ts", "./src/arbitrary-machine.ts"], 3 | "out": "doc", 4 | "sidebarLinks": { 5 | "Simply Stated": "https://www.simplystated.dev", 6 | "Github": "https://github.com/simplystated/f-of-xstate" 7 | }, 8 | "navigationLinks": { 9 | "Simply Stated": "https://www.simplystated.dev", 10 | }, 11 | "treatWarningsAsErrors": true 12 | } 13 | --------------------------------------------------------------------------------