├── .babelrc ├── .eslintrc.json ├── .gitignore ├── README.md ├── examples ├── jest.config.js ├── rollup.config.js ├── src │ ├── index.html │ ├── index.js │ ├── simple │ │ ├── index.spec.js │ │ ├── index.state.js │ │ ├── index.store.js │ │ └── index.view.js │ └── traffic_light │ │ ├── index.spec.js │ │ ├── index.state.js │ │ ├── index.store.js │ │ └── index.view.js └── test │ ├── setup.js │ ├── statechart.example.main.pdf │ ├── statechart.example.main.png │ ├── statechart.example.simple.pdf │ ├── statechart.example.simple.png │ ├── statechart.example.traffic_light.pdf │ └── statechart.example.traffic_light.png ├── jest.config.js ├── lib ├── __snapshots__ │ └── index.spec.js.snap ├── index.browser.js ├── index.node.js ├── index.spec.js ├── machination.js ├── machine.js ├── machinist.js ├── state_chart.js └── utils.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── scripts ├── build.sh ├── start.sh ├── test.examples.sh └── test.sh └── test ├── setup.js ├── statechart.lib.pdf └── statechart.lib.png /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | "transform-runtime" 9 | ] 10 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["airbnb", "prettier"], 4 | "env": { 5 | "browser": true, 6 | "jest": true 7 | }, 8 | "rules": { 9 | "no-param-reassign": 0, 10 | "react/jsx-filename-extension": [ 11 | 1, 12 | { 13 | "extensions": [".js", ".jsx"] 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .DS_Store 3 | node_modules 4 | dist 5 | dev 6 | test/coverage 7 | examples/test/coverage 8 | TODO.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Note this is experimental 2 | 3 | # MST-FSM 4 | 5 | Finite State Machine for Mobx State Tree and React. 6 | 7 | ### Why? 8 | Finite State Machines (FSM's) are popular in many types of UI development - but not Javascript/browsers. This is an exploration to see where and when FSM's might be useful in browser UI development. 9 | 10 | FSM theoretically could be useful in browsers because they: 11 | - have declarative states, e.g. jquery vs. react 12 | - ensure "correctness", e.g. it is not possible for a traffic light to transition from green to red, it must go through orange 13 | - See all the reading below for more information. 14 | 15 | This is relevant in modern browser UIs because: 16 | - apps are increasingly complex: 17 | - lots of business logic is implemented client-side 18 | - apps have many different states (loading, error, success, toggles, pagination, etc) 19 | - testing (unit, integration, etc) is not great. Each component has it's own logic and states, and are often async. How can you possibly test all the permutations? Current best practice seems to be a UI integration test: generate fake data, render the whole tree for each permutation, snapshot it, then eyeball any difference to spot errors/regressions. Pretty sub-optimal. 20 | - some recent-ish ecosystem developments potentially make FSM's much more useful: 21 | - declarative UI (React) where UI is a function of state (view = f(state)) 22 | - type systems (Typescript, Flow, Mobx State Tree) 23 | 24 | The idea is to combine the following: 25 | - declarative state using types 26 | - declarative transitions between states 27 | - declarative UI 28 | 29 | If you combine those you statically have all the information you need to: 30 | - render all possible permutations of the UI 31 | - ensure the UI cannot put the FSM into an invalid state. 32 | 33 | That sounds pretty useful for automated testing. 34 | 35 | ### Implementation 36 | The best way to see what I'm talking about is to see the example folders - e.g. /examples/traffic_light. Start in the index.view.js file then the store then the state files. Once you think you've groked what is happening, have a look at the spec/test file. 37 | 38 | And if you're brave have a look inside the lib folder. 39 | 40 | ### Conclusions 41 | Using a FSM with MST types and React we're able to achieve: 42 | - one-line auto-generation of a state chart diagram that can embed the corresponding rendered UI for each state. 43 | - one-line test that snapshots all possible permutations of a React tree given a machine, with deterministic fake value generation so that snapshots are consistent on subsequent runs. 44 | - one-line test that monkey tests your react tree. It starts from initial state, randomly clicks a click-able element to transition to next state, re-renders react tree, and repeats n times. It ensures your UI and machine can never be in an invalid state. It would also be possible to simulate other UI events like input typing, etc. 45 | 46 | However: 47 | - Current implementation is not purely static. In practice, you would need to mock some things like API, etc. It should be possible to parse the code (e.g. using babel/babylon) to get all the required informaton, and then only React render function needs to be called with props to generate tree. 48 | - There is likely a much more flexible implementation somehow using above point (static analysis), with a proper type system like Typescript, Flow, (Graphql?). 49 | - With that in mind a future implementation would need to: 50 | - statically extract all possible states and their transitions (a state chart) 51 | - statically extract the shape of each state (types) 52 | - pure render React tree given props 53 | - pass a 'transition' function in props, which requires a runtime of some kind 54 | 55 | ### Reading 56 | - 57 | - 58 | - 59 | - 60 | - 61 | - 62 | - 63 | - 64 | - 65 | - 66 | - 67 | - 68 | - 69 | - 70 | - 71 | - 72 | - see bottom of 73 | - 74 | 75 | ### TODO 76 | - jsonSchemaForMachine needs to return proper schema for machine model (initial, states, stack, etc) 77 | - reactTreesForMachine needs to pass in a real machine instance that has had applySnapshot done with fake json schema data, otherwise computeds, sub machines, etc won't work 78 | - write lib test with sub machine 79 | - call machine.transition from stores 80 | - event payloads 81 | - api naming/structure should follow xstate, scxml (see bottom of https://statecharts.github.io/how-to-use-statecharts.html) 82 | - https://github.com/mapbox/pixelmatch/blob/master/README.md 83 | - make all machinations support sub machines 84 | - e.g. reactTreesForMachine needs to render for all possible subtree combinations 85 | - history (undo/redo, pause/replay) 86 | - https://stackoverflow.com/questions/701131/data-structure-used-to-implement-undo-and-redo-option 87 | - structural sharing of states 88 | - a.data.label is not the same observable as b.data.label, which forgoes many of the benefits of mobx 89 | - whole component tree will be re-rendered on state change rather than just deep observer components 90 | - could automatically create generic union type from provided types that dynamically references the correct prop of current state. Views would then use this guy to render, etc. see https://github.com/mobxjs/mobx-state-tree#customizable-references -------------------------------------------------------------------------------- /examples/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/src/**/*.spec.js'], 3 | moduleFileExtensions: ['js'], 4 | globalSetup: './test/setup.js', 5 | collectCoverage: true, 6 | collectCoverageFrom: [ 7 | '**/src/**/*.js', 8 | '!**/dist/**/*.js', 9 | '!**/node_modules/**' 10 | ], 11 | coverageDirectory: './test/coverage', 12 | coverageThreshold: { 13 | global: { 14 | branches: 0, 15 | functions: 0, 16 | lines: 0, 17 | statements: 0 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /examples/rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import babel from 'rollup-plugin-babel'; 4 | import replace from 'rollup-plugin-replace'; 5 | 6 | export default { 7 | input: './src/index.js', 8 | output: { 9 | file: '../dev/index.js', 10 | format: 'iife' 11 | }, 12 | plugins: [ 13 | replace({ 14 | 'process.env.NODE_ENV': JSON.stringify('development') 15 | }), 16 | nodeResolve(), 17 | commonjs({ 18 | include: ['../node_modules/**', '../dist/**'], 19 | namedExports: { 20 | '../node_modules/react/index.js': [ 21 | 'createElement', 22 | 'Component', 23 | 'Children' 24 | ], 25 | '../node_modules/react-dom/index.js': [ 26 | 'findDOMNode', 27 | 'unstable_batchedUpdates' 28 | ] 29 | } 30 | }), 31 | babel({ 32 | babelrc: false, 33 | exclude: 'node_modules/**', 34 | presets: [['env', { modules: false }], 'react', 'stage-0'], 35 | plugins: ['external-helpers'] 36 | }) 37 | ] 38 | }; 39 | -------------------------------------------------------------------------------- /examples/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ReactDOM from 'react-dom'; 4 | import Simple from './simple/index.view'; 5 | import TrafficLight from './traffic_light/index.view'; 6 | 7 | const Example = ({ title, children }) => ( 8 |
9 |

{title}

10 |
{children}
11 |
12 | ); 13 | 14 | Example.propTypes = { 15 | title: PropTypes.string.isRequired, 16 | children: PropTypes.element.isRequired 17 | }; 18 | 19 | ReactDOM.render( 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
, 28 | document.getElementById('app') 29 | ); 30 | -------------------------------------------------------------------------------- /examples/src/simple/index.spec.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { 3 | reactTreesForMachine, 4 | stateChartForMachine, 5 | monkeyForMachine 6 | } from '../../../dist/node/index.node'; 7 | import IndexStore from './index.store'; 8 | import { IndexView } from './index.view'; 9 | 10 | test('reactTreesForMachine', () => { 11 | expect(reactTreesForMachine(IndexStore, IndexView)).toMatchSnapshot(); 12 | }); 13 | 14 | test('stateChartForMachine', async () => { 15 | const chart = await stateChartForMachine(IndexStore, IndexView); 16 | fs.writeFileSync('./test/statechart.example.simple.png', chart.png, 'base64'); 17 | fs.writeFileSync('./test/statechart.example.simple.pdf', chart.pdf, 'base64'); 18 | expect(chart.json).toMatchSnapshot(); 19 | }); 20 | 21 | test('monkeyForMachine', () => { 22 | monkeyForMachine(IndexStore, IndexView); 23 | expect(true).toBe(true); 24 | }); 25 | -------------------------------------------------------------------------------- /examples/src/simple/index.state.js: -------------------------------------------------------------------------------- 1 | export default { 2 | initial: 'first', 3 | states: { 4 | first: { 5 | NEXT: 'second' 6 | }, 7 | second: { 8 | NEXT: 'third' 9 | }, 10 | third: { 11 | NEXT: 'first' 12 | } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /examples/src/simple/index.store.js: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree'; 2 | import { Machine } from '../../../dist/browser/index'; 3 | import states from './index.state'; 4 | 5 | const first = types.model('first', { 6 | string: types.literal('first'), 7 | number: types.literal(1) 8 | }); 9 | first.guard = () => ({ 10 | string: 'first', 11 | number: 1 12 | }); 13 | 14 | const second = types.model('second', { 15 | string: types.literal('second'), 16 | number: types.literal(2) 17 | }); 18 | second.guard = () => ({ 19 | string: 'second', 20 | number: 2 21 | }); 22 | 23 | const third = types.model('third', { 24 | string: types.literal('third'), 25 | number: types.literal(3) 26 | }); 27 | third.guard = () => ({ 28 | string: 'third', 29 | number: 3 30 | }); 31 | 32 | export default Machine('Index', states, { first, second, third }, err => { 33 | throw err; 34 | }); 35 | -------------------------------------------------------------------------------- /examples/src/simple/index.view.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Machinist } from '../../../dist/browser/index'; 4 | import IndexStore from './index.store'; 5 | 6 | export const IndexView = ({ data, transition }) => ( 7 |
8 | String: {data.string} 9 |
10 | Number: {data.number} 11 |
12 | 13 |
14 | ); 15 | 16 | IndexView.propTypes = { 17 | data: PropTypes.shape({ 18 | string: PropTypes.string.isRequired, 19 | number: PropTypes.number.isRequired 20 | }).isRequired, 21 | transition: PropTypes.func.isRequired 22 | }; 23 | 24 | export default () => ( 25 | {IndexView} 26 | ); 27 | -------------------------------------------------------------------------------- /examples/src/traffic_light/index.spec.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { 3 | reactTreesForMachine, 4 | stateChartForMachine, 5 | monkeyForMachine 6 | } from '../../../dist/node/index.node'; 7 | import IndexStore from './index.store'; 8 | import { IndexView } from './index.view'; 9 | 10 | test('reactTreesForMachine', () => { 11 | expect(reactTreesForMachine(IndexStore, IndexView)).toMatchSnapshot(); 12 | }); 13 | 14 | test('stateChartForMachine', async () => { 15 | const chart = await stateChartForMachine(IndexStore, IndexView); 16 | fs.writeFileSync( 17 | './test/statechart.example.traffic_light.png', 18 | chart.png, 19 | 'base64' 20 | ); 21 | fs.writeFileSync( 22 | './test/statechart.example.traffic_light.pdf', 23 | chart.pdf, 24 | 'base64' 25 | ); 26 | expect(chart.json).toMatchSnapshot(); 27 | }); 28 | 29 | test('monkeyForMachine', () => { 30 | monkeyForMachine(IndexStore, IndexView); 31 | expect(true).toBe(true); 32 | }); 33 | -------------------------------------------------------------------------------- /examples/src/traffic_light/index.state.js: -------------------------------------------------------------------------------- 1 | export const index = { 2 | initial: 'green', 3 | states: { 4 | green: { 5 | CHANGE: 'yellow' 6 | }, 7 | yellow: { 8 | CHANGE: 'red' 9 | }, 10 | red: { 11 | CHANGE: 'green', 12 | MAINTENANCE: 'maintenance' 13 | }, 14 | maintenance: { 15 | CHANGE: 'red' 16 | } 17 | } 18 | }; 19 | 20 | export const admin = { 21 | initial: 'john', 22 | states: { 23 | john: { 24 | CHANGE: 'paul' 25 | }, 26 | paul: { 27 | CHANGE: 'john' 28 | } 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /examples/src/traffic_light/index.store.js: -------------------------------------------------------------------------------- 1 | import { types } from 'mobx-state-tree'; 2 | import { Machine } from '../../../dist/browser/index'; 3 | import { index, admin } from './index.state'; 4 | 5 | const green = types.model('green', { 6 | label: types.literal('green'), 7 | canMaintain: types.literal(false) 8 | }); 9 | green.guard = () => ({ 10 | label: 'green', 11 | canMaintain: false 12 | }); 13 | 14 | const yellow = types.model('yellow', { 15 | label: types.literal('yellow'), 16 | canMaintain: types.literal(false) 17 | }); 18 | yellow.guard = () => ({ 19 | label: 'yellow', 20 | canMaintain: false 21 | }); 22 | 23 | const red = types.model('red', { 24 | label: types.literal('red'), 25 | canMaintain: types.literal(true) 26 | }); 27 | red.guard = () => ({ 28 | label: 'red', 29 | canMaintain: true 30 | }); 31 | 32 | const john = types.model('john', { 33 | name: types.literal('john') 34 | }); 35 | john.guard = () => ({ 36 | name: 'john' 37 | }); 38 | 39 | const paul = types.model('paul', { 40 | name: types.literal('paul') 41 | }); 42 | paul.guard = () => ({ 43 | name: 'paul' 44 | }); 45 | 46 | const maintenance = types.model('maintenance', { 47 | label: types.literal('maintenance'), 48 | canMaintain: types.literal(false), 49 | admin: Machine('Admin', admin, { john, paul }, err => { 50 | throw err; 51 | }) 52 | }); 53 | maintenance.guard = () => ({ 54 | label: 'maintenance', 55 | canMaintain: false, 56 | admin: {} 57 | }); 58 | 59 | export default Machine( 60 | 'Index', 61 | index, 62 | { green, yellow, red, maintenance }, 63 | err => { 64 | throw err; 65 | } 66 | ); 67 | -------------------------------------------------------------------------------- /examples/src/traffic_light/index.view.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Machinist } from '../../../dist/browser/index'; 4 | import IndexStore from './index.store'; 5 | 6 | const Admin = ({ data, transition, undo }) => ( 7 |
8 | Current admin: {data.name} 9 | 10 | {undo && } 11 |
12 | ); 13 | 14 | Admin.propTypes = { 15 | data: PropTypes.shape({ 16 | name: PropTypes.string.isRequired 17 | }).isRequired, 18 | transition: PropTypes.func.isRequired, 19 | undo: PropTypes.func.isRequired 20 | }; 21 | 22 | export const IndexView = ({ data, transition, undo }) => ( 23 |
24 |

{data.label}

25 | 26 | {undo && } 27 | {data.canMaintain && ( 28 | 29 | )} 30 | {data.admin && {Admin}} 31 |
32 | ); 33 | 34 | IndexView.propTypes = { 35 | data: PropTypes.shape({ 36 | label: PropTypes.string.isRequired, 37 | canMaintain: PropTypes.bool.isRequired, 38 | admin: PropTypes.shape({ 39 | name: PropTypes.string.isRequired 40 | }) 41 | }).isRequired, 42 | transition: PropTypes.func.isRequired, 43 | undo: PropTypes.func.isRequired 44 | }; 45 | 46 | export default () => ( 47 | {IndexView} 48 | ); 49 | -------------------------------------------------------------------------------- /examples/test/setup.js: -------------------------------------------------------------------------------- 1 | // taken from http://airbnb.io/enzyme/docs/guides/jsdom.html 2 | require('raf/polyfill'); 3 | const { JSDOM } = require('jsdom'); 4 | 5 | module.exports = async function setup() { 6 | const jsdom = new JSDOM(''); 7 | const { window } = jsdom; 8 | 9 | function copyProps(src, target) { 10 | const props = Object.getOwnPropertyNames(src) 11 | .filter(prop => typeof target[prop] === 'undefined') 12 | .reduce((result, prop) => Object.assign( 13 | {}, 14 | result, 15 | { [prop]: Object.getOwnPropertyDescriptor(src, prop) } 16 | ), {}); 17 | Object.defineProperties(target, props); 18 | } 19 | 20 | global.window = window; 21 | global.document = window.document; 22 | global.navigator = { 23 | userAgent: 'node.js', 24 | }; 25 | copyProps(window, global); 26 | }; 27 | -------------------------------------------------------------------------------- /examples/test/statechart.example.main.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paul-veevers/mst-fsm/46a302344b04908d40f2ad03f838fcb2d8e36a8d/examples/test/statechart.example.main.pdf -------------------------------------------------------------------------------- /examples/test/statechart.example.main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paul-veevers/mst-fsm/46a302344b04908d40f2ad03f838fcb2d8e36a8d/examples/test/statechart.example.main.png -------------------------------------------------------------------------------- /examples/test/statechart.example.simple.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paul-veevers/mst-fsm/46a302344b04908d40f2ad03f838fcb2d8e36a8d/examples/test/statechart.example.simple.pdf -------------------------------------------------------------------------------- /examples/test/statechart.example.simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paul-veevers/mst-fsm/46a302344b04908d40f2ad03f838fcb2d8e36a8d/examples/test/statechart.example.simple.png -------------------------------------------------------------------------------- /examples/test/statechart.example.traffic_light.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paul-veevers/mst-fsm/46a302344b04908d40f2ad03f838fcb2d8e36a8d/examples/test/statechart.example.traffic_light.pdf -------------------------------------------------------------------------------- /examples/test/statechart.example.traffic_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paul-veevers/mst-fsm/46a302344b04908d40f2ad03f838fcb2d8e36a8d/examples/test/statechart.example.traffic_light.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/lib/**/*.spec.js'], 3 | moduleFileExtensions: ['js'], 4 | globalSetup: './test/setup.js', 5 | collectCoverage: true, 6 | collectCoverageFrom: [ 7 | '**/lib/**/*.js', 8 | '!**/node_modules/**', 9 | '!**/lib/**/state_chart.js' 10 | ], 11 | coverageDirectory: './test/coverage', 12 | coverageThreshold: { 13 | global: { 14 | branches: 0, 15 | functions: 0, 16 | lines: 0, 17 | statements: 0 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /lib/__snapshots__/index.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`reactTreesForMachine 1`] = ` 4 | Object { 5 | "first":
6 | first 7 | : 8 | 1 9 | 14 | 19 |
, 20 | "second":
21 | second 22 | : 23 | 2 24 | 29 | 34 |
, 35 | "third":
36 | third 37 | : 38 | 3 39 | 44 | 49 |
, 50 | } 51 | `; 52 | 53 | exports[`stateChartForMachine 1`] = ` 54 | Object { 55 | "boxSelectionEnabled": true, 56 | "elements": Object { 57 | "edges": Array [ 58 | Object { 59 | "classes": "", 60 | "data": Object { 61 | "id": "first:second", 62 | "label": "NEXT", 63 | "source": "machine.first", 64 | "target": "machine.second", 65 | }, 66 | "grabbable": true, 67 | "group": "edges", 68 | "locked": false, 69 | "position": Object {}, 70 | "removed": false, 71 | "selectable": true, 72 | "selected": false, 73 | }, 74 | Object { 75 | "classes": "", 76 | "data": Object { 77 | "id": "second:third", 78 | "label": "NEXT", 79 | "source": "machine.second", 80 | "target": "machine.third", 81 | }, 82 | "grabbable": true, 83 | "group": "edges", 84 | "locked": false, 85 | "position": Object {}, 86 | "removed": false, 87 | "selectable": true, 88 | "selected": false, 89 | }, 90 | Object { 91 | "classes": "", 92 | "data": Object { 93 | "id": "third:first", 94 | "label": "NEXT", 95 | "source": "machine.third", 96 | "target": "machine.first", 97 | }, 98 | "grabbable": true, 99 | "group": "edges", 100 | "locked": false, 101 | "position": Object {}, 102 | "removed": false, 103 | "selectable": true, 104 | "selected": false, 105 | }, 106 | Object { 107 | "classes": "", 108 | "data": Object { 109 | "id": "$initial:first", 110 | "label": "", 111 | "source": "machine.$initial", 112 | "target": "machine.first", 113 | }, 114 | "grabbable": true, 115 | "group": "edges", 116 | "locked": false, 117 | "position": Object {}, 118 | "removed": false, 119 | "selectable": true, 120 | "selected": false, 121 | }, 122 | ], 123 | "nodes": Array [ 124 | Object { 125 | "classes": "", 126 | "data": Object { 127 | "id": "machine.first", 128 | "label": "first", 129 | "parent": undefined, 130 | }, 131 | "grabbable": true, 132 | "group": "nodes", 133 | "locked": false, 134 | "position": Object { 135 | "x": 0, 136 | "y": 0, 137 | }, 138 | "removed": false, 139 | "selectable": true, 140 | "selected": false, 141 | }, 142 | Object { 143 | "classes": "", 144 | "data": Object { 145 | "id": "machine.second", 146 | "label": "second", 147 | "parent": undefined, 148 | }, 149 | "grabbable": true, 150 | "group": "nodes", 151 | "locked": false, 152 | "position": Object { 153 | "x": 0, 154 | "y": 0, 155 | }, 156 | "removed": false, 157 | "selectable": true, 158 | "selected": false, 159 | }, 160 | Object { 161 | "classes": "", 162 | "data": Object { 163 | "id": "machine.third", 164 | "label": "third", 165 | "parent": undefined, 166 | }, 167 | "grabbable": true, 168 | "group": "nodes", 169 | "locked": false, 170 | "position": Object { 171 | "x": 0, 172 | "y": 0, 173 | }, 174 | "removed": false, 175 | "selectable": true, 176 | "selected": false, 177 | }, 178 | Object { 179 | "classes": "", 180 | "data": Object { 181 | "id": "machine.$initial", 182 | "label": "$initial", 183 | "parent": undefined, 184 | }, 185 | "grabbable": true, 186 | "group": "nodes", 187 | "locked": false, 188 | "position": Object { 189 | "x": 0, 190 | "y": 0, 191 | }, 192 | "removed": false, 193 | "selectable": true, 194 | "selected": false, 195 | }, 196 | ], 197 | }, 198 | "hideEdgesOnViewport": undefined, 199 | "maxZoom": 1e+50, 200 | "minZoom": 1e-50, 201 | "motionBlur": undefined, 202 | "pan": Object { 203 | "x": 0, 204 | "y": 0, 205 | }, 206 | "panningEnabled": true, 207 | "renderer": Object { 208 | "name": "null", 209 | }, 210 | "textureOnViewport": undefined, 211 | "userPanningEnabled": true, 212 | "userZoomingEnabled": true, 213 | "wheelSensitivity": undefined, 214 | "zoom": 1, 215 | "zoomingEnabled": true, 216 | } 217 | `; 218 | -------------------------------------------------------------------------------- /lib/index.browser.js: -------------------------------------------------------------------------------- 1 | import Machine from './machine'; 2 | import Machinist from './machinist'; 3 | 4 | export { Machine, Machinist }; 5 | -------------------------------------------------------------------------------- /lib/index.node.js: -------------------------------------------------------------------------------- 1 | import Machine from './machine'; 2 | import Machinist from './machinist'; 3 | import { 4 | reactTreesForMachine, 5 | stateChartForMachine, 6 | monkeyForMachine 7 | } from './machination'; 8 | 9 | export { 10 | Machine, 11 | Machinist, 12 | reactTreesForMachine, 13 | stateChartForMachine, 14 | monkeyForMachine 15 | }; 16 | -------------------------------------------------------------------------------- /lib/index.spec.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { types } from 'mobx-state-tree'; 5 | import { 6 | Machine, 7 | reactTreesForMachine, 8 | stateChartForMachine, 9 | monkeyForMachine 10 | } from './index.node'; 11 | 12 | const states = { 13 | initial: 'first', 14 | states: { 15 | first: { 16 | NEXT: 'second' 17 | }, 18 | second: { 19 | NEXT: 'third' 20 | }, 21 | third: { 22 | NEXT: 'first' 23 | } 24 | } 25 | }; 26 | 27 | const first = types.model('first', { 28 | array: types.array(types.string), 29 | boolean: types.boolean, 30 | date: types.Date, 31 | enumeration: types.enumeration(['enumeration1', 'enumeration2']), 32 | identifier: types.identifier(types.string), 33 | literal: types.literal('literal'), 34 | map: types.map(types.string), 35 | maybe: types.maybe(types.string), 36 | model: types.model({ foo: types.string }), 37 | null: types.null, 38 | optional: types.optional(types.string, 'optional string'), 39 | undefined: types.undefined, 40 | union: types.union(null, types.string, types.number), 41 | // number: types.number, 42 | // string: types.string, 43 | number: types.literal(1), 44 | string: types.literal('first') 45 | }); 46 | 47 | first.guard = () => ({ 48 | number: 1, 49 | string: 'first' 50 | }); 51 | 52 | const second = types.model('second', { 53 | number: types.literal(2), 54 | string: types.literal('second') 55 | }); 56 | 57 | second.guard = () => ({ 58 | number: 2, 59 | string: 'second' 60 | }); 61 | 62 | const third = types.model('third', { 63 | number: types.literal(3), 64 | string: types.literal('third') 65 | }); 66 | 67 | third.guard = () => ({ 68 | number: 3, 69 | string: 'third' 70 | }); 71 | 72 | const cmpnt = ({ data, transition, undo }) => ( 73 |
74 | {data.string} : {data.number} 75 | 76 | 77 |
78 | ); 79 | 80 | cmpnt.propTypes = { 81 | data: PropTypes.shape({ 82 | string: PropTypes.string.isRequired, 83 | number: PropTypes.number.isRequired 84 | }).isRequired, 85 | transition: PropTypes.func.isRequired, 86 | undo: PropTypes.func.isRequired 87 | }; 88 | 89 | const MachineType = Machine('Foo', states, { first, second, third }, err => { 90 | throw err; 91 | }); 92 | 93 | test('reactTreesForMachine', () => { 94 | expect(reactTreesForMachine(MachineType, cmpnt)).toMatchSnapshot(); 95 | }); 96 | 97 | test( 98 | 'stateChartForMachine', 99 | async () => { 100 | const chart = await stateChartForMachine(MachineType, cmpnt); 101 | fs.writeFileSync('./test/statechart.lib.png', chart.png, 'base64'); 102 | fs.writeFileSync('./test/statechart.lib.pdf', chart.pdf, 'base64'); 103 | expect(chart.json).toMatchSnapshot(); 104 | }, 105 | 10000 106 | ); 107 | 108 | test('monkeyForMachine', async () => { 109 | const results = await monkeyForMachine(MachineType, cmpnt); 110 | expect(results.length).toBe(10); 111 | }); 112 | -------------------------------------------------------------------------------- /lib/machination.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-use-before-define */ 2 | import { types, getSnapshot } from 'mobx-state-tree'; 3 | import jsf from 'json-schema-faker'; 4 | import revHash from 'rev-hash'; 5 | import seedrandom from 'seedrandom'; 6 | import ReactTestRenderer from 'react-test-renderer'; 7 | import { configure, mount } from 'enzyme'; 8 | import Adapter from 'enzyme-adapter-react-16'; 9 | import stateChart from './state_chart'; 10 | import { 11 | find, 12 | includes, 13 | mapValues, 14 | values, 15 | keys, 16 | reduce, 17 | assign, 18 | asyncIterate, 19 | arrayRemoveRight, 20 | count 21 | } from './utils'; 22 | 23 | configure({ adapter: new Adapter() }); 24 | 25 | function jsonSchemaForType(type) { 26 | if (!type.isType) throw new Error(`Is not an mst type: ${type}`); 27 | if (type.properties && type.properties.initial) { 28 | // is a sub machine 29 | return jsonSchemaForMachine(type); 30 | } 31 | switch (type.constructor) { 32 | case types.array(types.null).constructor: 33 | return [jsonSchemaForType(type.subType)]; 34 | // case types.compose(types.model({}), types.model({})).constructor: 35 | // throw new Error(`Unsupported type: ${type.describe()}`); 36 | // case types.enumeration(['a']).constructor: 37 | // throw new Error('types.enumeration is a union type'); 38 | case types.union(null, types.string, types.number).constructor: 39 | return jsonSchemaForType(type.types[0]); 40 | case types.frozen.constructor: 41 | throw new Error(`Unsupported type: ${type.describe()}`); 42 | case types.identifier(types.string).constructor: 43 | return jsonSchemaForType(type.identifierType); 44 | case types.late(() => {}).constructor: 45 | throw new Error(`Unsupported type: ${type.describe()}`); 46 | case types.literal().constructor: 47 | return { default: type.value }; 48 | case types.map(types.string).constructor: { 49 | return { type: 'object' }; 50 | } 51 | case types.maybe(types.string).constructor: 52 | return jsonSchemaForType(type.type); 53 | case types.model({}).constructor: { 54 | return { 55 | type: 'object', 56 | properties: mapValues(type.properties, jsonSchemaForType), 57 | required: keys(type.properties) 58 | }; 59 | } 60 | case types.optional(types.null, null).constructor: 61 | return jsonSchemaForType(type.type); 62 | case types.reference(types.model({ a: types.identifier(types.string) })) 63 | .constructor: 64 | throw new Error(`Unsupported type: ${type.describe()}`); 65 | case types.refinement(types.string, () => {}).constructor: 66 | throw new Error(`Unsupported type: ${type.describe()}`); 67 | default: 68 | } 69 | switch (type.name) { 70 | case 'boolean': 71 | return { type: 'boolean' }; 72 | case 'Date': 73 | return { default: 1516689808463 }; 74 | case 'null': 75 | return { default: null }; 76 | case 'number': 77 | return { type: 'number' }; 78 | case 'undefined': 79 | return { default: undefined }; 80 | case 'string': 81 | return { type: 'string' }; 82 | default: 83 | } 84 | throw new Error(`Unsupported type: ${type.describe()}`); 85 | } 86 | 87 | // build an array that contains each reachable state at least once 88 | // while still being a valid transition path 89 | // undo is allowed in order to get out of dead ends 90 | // should be deterministic, so re-runs produce same output (referential transparency) 91 | function pathBuilder(states, next, stack = [], undo) { 92 | stack.push(next); 93 | const v = find(values(states[next]), a => !includes(stack, a)); 94 | // dead end 95 | if (!v) { 96 | if (undo) return undo(); 97 | // we've come back to the beginning and have no more paths to follow 98 | // remove duplicates from end of stack 99 | return arrayRemoveRight(stack, a => count(stack, a) > 1); 100 | } 101 | return pathBuilder(states, v, stack, () => 102 | pathBuilder(states, next, stack, undo) 103 | ); 104 | } 105 | 106 | function jsonSchemaForMachine(machine) { 107 | const initialSchema = jsonSchemaForType(machine.properties.initial); 108 | const statesSchema = jsonSchemaForType(machine.properties.states); 109 | const stateTypeSchemas = reduce( 110 | machine.properties.stack.type.subType.properties.data.types, 111 | (acc, v) => 112 | assign({}, acc, { 113 | [v.name]: jsonSchemaForType(v) 114 | }), 115 | {} 116 | ); 117 | return { 118 | initial: initialSchema, 119 | states: statesSchema, 120 | stack: pathBuilder( 121 | fakeJsonForSchema(machine, statesSchema), 122 | fakeJsonForSchema(machine, initialSchema) 123 | ).map(name => ({ 124 | state: { default: name }, 125 | data: stateTypeSchemas[name] 126 | })) 127 | }; 128 | } 129 | 130 | function fakeJsonForSchema(machine, schema) { 131 | jsf.option({ 132 | random: seedrandom(revHash(machine.describe())), 133 | useDefaultValue: true 134 | }); 135 | return jsf(schema); 136 | } 137 | 138 | function fakeJsonForMachine(machine) { 139 | return fakeJsonForSchema(machine, jsonSchemaForMachine(machine)); 140 | } 141 | 142 | function statesForMachine(machine) { 143 | return { 144 | initial: machine.properties.initial.type.value, 145 | states: mapValues(machine.properties.states.type.properties, v => 146 | mapValues(v.properties, a => a.value) 147 | ) 148 | }; 149 | } 150 | 151 | export function reactTreesForMachine(machine, cmpnt) { 152 | return mapValues(fakeJsonForMachine(machine), d => { 153 | const data = machine.create(d).current.data; 154 | return ReactTestRenderer.create( 155 | cmpnt({ data, transition: () => {}, undo: () => {} }) 156 | ).toJSON(); 157 | }); 158 | } 159 | 160 | export function stateChartForMachine(machine) { 161 | return stateChart(statesForMachine(machine)); 162 | } 163 | 164 | export function monkeyForMachine(machine, cmpnt, parallel = 10, times = 100) { 165 | const simulator = () => { 166 | const machineInstance = machine.create(); 167 | return () => { 168 | const elem = mount( 169 | cmpnt({ 170 | data: machineInstance.current.data, 171 | transition: machineInstance.transition, 172 | undo: machineInstance.canUndo ? machineInstance.undo : null 173 | }) 174 | ); 175 | const clickables = elem.findWhere( 176 | node => typeof node.props().onClick === 'function' 177 | ); 178 | if (!clickables.length) 179 | throw new Error('No clickable elements in component.'); 180 | const rand = Math.floor(Math.random() * Math.floor(clickables.length)); 181 | const clickMe = clickables.at(rand); 182 | const frozen = { 183 | state: machineInstance.current.state, 184 | store: getSnapshot(machineInstance.current.data), 185 | view: elem.debug(), 186 | clicked: clickMe.debug() 187 | }; 188 | clickMe.simulate('click'); 189 | return frozen; 190 | }; 191 | }; 192 | return asyncIterate(simulator, parallel, times); 193 | } 194 | -------------------------------------------------------------------------------- /lib/machine.js: -------------------------------------------------------------------------------- 1 | import { types, getSnapshot } from 'mobx-state-tree'; 2 | import { last, keys, values, mapValues } from './utils'; 3 | 4 | export default function Machine(name, state, stateTypes, onError) { 5 | return types 6 | .model(`${name}mstfsmmachine`, { 7 | initial: types.optional(types.literal(state.initial), state.initial), 8 | states: types.optional( 9 | types.model( 10 | 'MachineStates', 11 | mapValues(state.states, (v, k) => 12 | types.model(k, mapValues(v, a => types.literal(a))) 13 | ) 14 | ), 15 | state.states 16 | ), 17 | stack: types.optional( 18 | types.array( 19 | types.model('StackModel', { 20 | state: types.enumeration(keys(state.states)), 21 | data: types.union(null, ...values(stateTypes)) 22 | }) 23 | ), 24 | [] 25 | ) 26 | }) 27 | .views(self => ({ 28 | get current() { 29 | return last(self.stack); 30 | }, 31 | canTransition(event) { 32 | return !!self.states[self.current.state][event]; 33 | }, 34 | get canUndo() { 35 | return self.stack.length > 1; 36 | } 37 | })) 38 | .actions(self => ({ 39 | afterCreate() { 40 | self.pushStack(state.initial); 41 | }, 42 | pushStack(next) { 43 | const snap = getSnapshot(self.stack); 44 | try { 45 | if (!self.states[next]) throw new Error(`no such state ${next}`); 46 | if (!stateTypes[next]) 47 | throw new Error(`no corresponding type for the event ${next}`); 48 | if (typeof stateTypes[next].guard !== 'function') 49 | throw new Error(`type has no guard static method: ${next}`); 50 | const data = stateTypes[next].guard(); 51 | if (!data) 52 | throw new Error(`${next}.guard should not return a false-y value.`); 53 | self.stack.push({ 54 | state: next, 55 | data 56 | }); 57 | } catch (err) { 58 | // rollback any changes, atomic 59 | self.stack = snap; 60 | onError(err, snap); 61 | } 62 | }, 63 | transition(event) { 64 | if (!self.canTransition(event)) { 65 | onError( 66 | new Error(`cannot transition ${self.current} -> ${event}`), 67 | getSnapshot(self.stack) 68 | ); 69 | return; 70 | } 71 | self.pushStack(self.states[self.current.state][event]); 72 | }, 73 | undo() { 74 | if (!self.canUndo) { 75 | onError(new Error('Cannot undo'), getSnapshot(self.stack)); 76 | return; 77 | } 78 | self.stack.pop(); 79 | } 80 | })); 81 | } 82 | -------------------------------------------------------------------------------- /lib/machinist.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Observer, PropTypes as MobxPropTypes } from 'mobx-react'; 4 | 5 | const Machinist = ({ machine, children }) => ( 6 | 7 | {() => 8 | children({ 9 | data: machine.current.data, 10 | transition: machine.transition, 11 | undo: machine.canUndo ? machine.undo : null 12 | }) 13 | } 14 | 15 | ); 16 | 17 | Machinist.propTypes = { 18 | machine: PropTypes.shape({ 19 | states: PropTypes.shape({}).isRequired, 20 | stack: MobxPropTypes.arrayOrObservableArrayOf( 21 | PropTypes.shape({ 22 | state: PropTypes.string.isRequired, 23 | data: PropTypes.object.isRequired 24 | }) 25 | ).isRequired, 26 | transition: PropTypes.func.isRequired, 27 | undo: PropTypes.func.isRequired, 28 | canUndo: PropTypes.bool.isRequired 29 | }).isRequired, 30 | children: PropTypes.func.isRequired 31 | }; 32 | 33 | export default Machinist; 34 | -------------------------------------------------------------------------------- /lib/state_chart.js: -------------------------------------------------------------------------------- 1 | import cytoscape from 'cytoscape'; 2 | import puppeteer from 'puppeteer'; 3 | import { keys, map, assign, flatten } from './utils'; 4 | 5 | const baseOpts = { 6 | style: ` 7 | node[label != '$initial'] { 8 | content: data(label); 9 | text-valign: center; 10 | text-halign: center; 11 | shape: roundrectangle; 12 | width: label; 13 | height: label; 14 | padding-left: 5px; 15 | padding-right: 5px; 16 | padding-top: 5px; 17 | padding-bottom: 5px; 18 | background-color: white; 19 | border-width: 1px; 20 | border-color: black; 21 | font-size: 10px; 22 | font-family: Helvetica Neue; 23 | } 24 | node:active { 25 | overlay-color: black; 26 | overlay-padding: 0; 27 | overlay-opacity: 0.1; 28 | } 29 | .foo { 30 | background-color: blue; 31 | } 32 | node[label = '$initial'] { 33 | visibility: hidden; 34 | } 35 | $node > node { 36 | padding-top: 1px; 37 | padding-left: 10px; 38 | padding-bottom: 10px; 39 | padding-right: 10px; 40 | text-valign: top; 41 | text-halign: center; 42 | border-width: 1px; 43 | border-color: black; 44 | background-color: white; 45 | } 46 | edge { 47 | curve-style: bezier; 48 | width: 1px; 49 | target-arrow-shape: triangle; 50 | label: data(label); 51 | font-size: 5px; 52 | font-weight: bold; 53 | text-background-color: #fff; 54 | text-background-padding: 3px; 55 | line-color: black; 56 | target-arrow-color: black; 57 | z-index: 100; 58 | text-wrap: wrap; 59 | text-background-color: white; 60 | text-background-opacity: 1; 61 | target-distance-from-node: 2px; 62 | } 63 | edge[label = ''] { 64 | source-arrow-shape: circle; 65 | source-arrow-color: black; 66 | } 67 | `, 68 | layout: { 69 | name: 'cose', 70 | randomize: true, 71 | idealEdgeLength: 70, 72 | animate: false 73 | } 74 | }; 75 | 76 | const html = ` 77 | 78 | 79 | 80 | 81 | 85 | 86 | 87 |
88 | 89 | 90 | `; 91 | 92 | const getOptsForState = state => { 93 | const nodes = map(keys(state.states), k => ({ 94 | data: { 95 | id: `machine.${k}`, 96 | label: k, 97 | parent: 'machine' 98 | } 99 | })).concat([ 100 | { data: { id: 'machine.$initial', label: '$initial', parent: 'machine' } } 101 | ]); 102 | const edges = flatten( 103 | map(keys(state.states), k => 104 | map(keys(state.states[k]), v => ({ 105 | data: { 106 | id: `${k}:${state.states[k][v]}`, 107 | source: `machine.${k}`, 108 | target: `machine.${state.states[k][v]}`, 109 | label: v 110 | } 111 | })) 112 | ) 113 | ).concat([ 114 | { 115 | data: { 116 | id: `$initial:${state.initial}`, 117 | source: 'machine.$initial', 118 | target: `machine.${state.initial}`, 119 | label: '' 120 | } 121 | } 122 | ]); 123 | return { 124 | elements: { 125 | nodes, 126 | edges 127 | } 128 | }; 129 | }; 130 | 131 | export default async function stateChart(state) { 132 | const opts = assign({}, baseOpts, getOptsForState(state)); 133 | const browser = await puppeteer.launch(); 134 | const page = await browser.newPage(); 135 | await page.setViewport({ width: 1000, height: 1000 }); 136 | await page.setContent(html); 137 | await page.waitForFunction(() => typeof window.cytoscape === 'function'); 138 | await page.evaluate(cytoOpts => { 139 | cytoOpts.container = document.getElementById('cy'); 140 | window.cytoscape(cytoOpts); 141 | }, opts); 142 | const png = await page.screenshot({ type: 'png', omitBackground: true }); 143 | const pdf = await page.pdf(); 144 | await browser.close(); 145 | return { 146 | png, 147 | pdf, 148 | json: cytoscape(opts).json() 149 | }; 150 | } 151 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | export function last(arr) { 2 | return arr[arr.length - 1]; 3 | } 4 | 5 | export function find(arr, fn) { 6 | return arr.find(fn); 7 | } 8 | 9 | export function includes(arr, val) { 10 | return arr.includes(val); 11 | } 12 | 13 | export function keys(obj) { 14 | return Object.keys(obj); 15 | } 16 | 17 | export function values(obj) { 18 | return Object.values(obj); 19 | } 20 | 21 | export function assign(...objs) { 22 | return Object.assign(...objs); 23 | } 24 | 25 | export function reduce(arr, fn, acc) { 26 | return arr.reduce(fn, acc); 27 | } 28 | 29 | export function map(arr, fn) { 30 | return arr.map(fn); 31 | } 32 | 33 | export function mapValues(obj, fn) { 34 | return reduce( 35 | keys(obj), 36 | (acc, k) => assign({}, acc, { [k]: fn(obj[k], k) }), 37 | {} 38 | ); 39 | } 40 | 41 | export function flatten(arr) { 42 | return arr.reduce((a, b) => a.concat(b), []); 43 | } 44 | 45 | function pImmediate() { 46 | return new Promise(resolve => { 47 | setImmediate(() => { 48 | resolve(); 49 | }); 50 | }); 51 | } 52 | 53 | function pImmediateIterator(func, times) { 54 | let counter = 0; 55 | const results = []; 56 | const iterate = resolve => { 57 | counter += 1; 58 | if (counter > times) return resolve(results); 59 | return pImmediate().then(() => { 60 | results.push(func()); 61 | return iterate(resolve); 62 | }); 63 | }; 64 | return new Promise(iterate); 65 | } 66 | 67 | export function asyncIterate(func, parallel, times) { 68 | return Promise.all( 69 | Array(parallel) 70 | .fill(0) 71 | .map(() => pImmediateIterator(func(), times)) 72 | ); 73 | } 74 | 75 | export function arrayRemoveRight(arr, fn) { 76 | const a = arr.slice(); 77 | const rev = arr.slice().reverse(); 78 | let i = 0; 79 | while (i < arr.length && fn(rev[i])) { 80 | a.splice(a.lastIndexOf(rev[i]), 1); 81 | i += 1; 82 | } 83 | return a; 84 | } 85 | 86 | export function count(arr, val) { 87 | return arr.filter(a => a === val).length; 88 | } 89 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mst-fsm", 3 | "version": "0.0.1", 4 | "description": "", 5 | "keywords": [], 6 | "author": "Paul Veevers", 7 | "license": "MIT", 8 | "browser": "dist/browser/index.js", 9 | "main": "dist/node/index.node.js", 10 | "files": [ 11 | "dist/" 12 | ], 13 | "scripts": { 14 | "test": "scripts/test.sh", 15 | "test:examples": "scripts/test.examples.sh", 16 | "start": "scripts/start.sh", 17 | "build": "scripts/build.sh", 18 | "prepublishOnly": "npm test && npm run test:examples && npm run build", 19 | "prepush": "npm test", 20 | "postmerge": "npm i" 21 | }, 22 | "peerDependencies": { 23 | "mobx": "^3.4.1", 24 | "mobx-react": "^4.3.5", 25 | "mobx-state-tree": "^1.3.1", 26 | "prop-types": "^15.6.0", 27 | "react": "^16.2.0", 28 | "react-dom": "^16.2.0" 29 | }, 30 | "devDependencies": { 31 | "babel-cli": "^6.26.0", 32 | "babel-core": "^6.26.0", 33 | "babel-eslint": "^8.0.3", 34 | "babel-plugin-external-helpers": "^6.22.0", 35 | "babel-plugin-transform-runtime": "^6.23.0", 36 | "babel-preset-env": "^1.6.1", 37 | "babel-preset-react": "^6.24.1", 38 | "babel-preset-stage-0": "^6.24.1", 39 | "eslint": "^4.16.0", 40 | "eslint-config-airbnb": "^15.1.0", 41 | "eslint-config-prettier": "^2.9.0", 42 | "eslint-plugin-import": "^2.8.0", 43 | "eslint-plugin-jsx-a11y": "^5.1.1", 44 | "eslint-plugin-react": "^7.5.1", 45 | "husky": "^0.14.3", 46 | "jest": "^22.1.4", 47 | "mobx": "^3.4.1", 48 | "mobx-react": "^4.3.5", 49 | "mobx-state-tree": "^1.3.1", 50 | "prettier": "^1.8.2", 51 | "prop-types": "^15.6.0", 52 | "raf": "^3.4.0", 53 | "react": "^16.2.0", 54 | "react-dom": "^16.2.0", 55 | "rollup": "^0.55.0", 56 | "rollup-plugin-babel": "^3.0.3", 57 | "rollup-plugin-commonjs": "^8.2.6", 58 | "rollup-plugin-filesize": "^1.5.0", 59 | "rollup-plugin-node-resolve": "^3.0.2", 60 | "rollup-plugin-replace": "^2.0.0" 61 | }, 62 | "dependencies": { 63 | "cytoscape": "^3.2.8", 64 | "enzyme": "^3.3.0", 65 | "enzyme-adapter-react-16": "^1.1.1", 66 | "jsdom": "^11.6.0", 67 | "json-schema-faker": "^0.4.3", 68 | "puppeteer": "^1.0.0", 69 | "react-test-renderer": "^16.2.0", 70 | "rev-hash": "^2.0.0", 71 | "seedrandom": "^2.4.3" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import filesize from 'rollup-plugin-filesize'; 3 | import pkg from './package.json'; 4 | 5 | export default { 6 | input: './lib/index.browser.js', 7 | output: { 8 | file: './dist/browser/index.js', 9 | format: 'cjs' 10 | }, 11 | external: Object.keys(pkg.peerDependencies), 12 | plugins: [ 13 | babel({ 14 | babelrc: false, 15 | exclude: 'node_modules/**', 16 | presets: [['env', { modules: false }], 'react', 'stage-0'], 17 | plugins: ['external-helpers'] 18 | }), 19 | filesize() 20 | ] 21 | }; 22 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | rm -rf dist 5 | mkdir dist 6 | 7 | rollup -c 8 | babel lib --out-dir dist/node 9 | -------------------------------------------------------------------------------- /scripts/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | rm -rf dev 5 | mkdir dev 6 | 7 | cp ./examples/src/index.html ./dev/index.html 8 | 9 | rollup -c -w & cd examples && rollup -c -w & cd dev && python -m SimpleHTTPServer 8000 10 | -------------------------------------------------------------------------------- /scripts/test.examples.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | npm run build 5 | 6 | prettier --write --single-quote 'examples/src/**/*.js' 7 | eslint 'examples/src/**/*.js' 8 | cd examples 9 | jest -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | prettier --write --single-quote 'lib/**/*.js' '*.js' 5 | eslint 'lib/**/*.js' 6 | jest -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | // taken from http://airbnb.io/enzyme/docs/guides/jsdom.html 2 | require('raf/polyfill'); 3 | const { JSDOM } = require('jsdom'); 4 | 5 | module.exports = async function setup() { 6 | const jsdom = new JSDOM(''); 7 | const { window } = jsdom; 8 | 9 | function copyProps(src, target) { 10 | const props = Object.getOwnPropertyNames(src) 11 | .filter(prop => typeof target[prop] === 'undefined') 12 | .reduce((result, prop) => Object.assign( 13 | {}, 14 | result, 15 | { [prop]: Object.getOwnPropertyDescriptor(src, prop) } 16 | ), {}); 17 | Object.defineProperties(target, props); 18 | } 19 | 20 | global.window = window; 21 | global.document = window.document; 22 | global.navigator = { 23 | userAgent: 'node.js', 24 | }; 25 | copyProps(window, global); 26 | }; 27 | -------------------------------------------------------------------------------- /test/statechart.lib.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paul-veevers/mst-fsm/46a302344b04908d40f2ad03f838fcb2d8e36a8d/test/statechart.lib.pdf -------------------------------------------------------------------------------- /test/statechart.lib.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paul-veevers/mst-fsm/46a302344b04908d40f2ad03f838fcb2d8e36a8d/test/statechart.lib.png --------------------------------------------------------------------------------