├── .gitignore ├── xstate-to-gherkin.png ├── bin └── cli.js ├── jest.config.js ├── .github └── workflows │ └── ci.yaml ├── tsconfig.json ├── LICENSE ├── package.json ├── test ├── __snapshots__ │ └── example.ts.snap └── example.ts ├── patches └── @xstate+machine-extractor+0.7.1.patch ├── src ├── cli.ts └── index.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /xstate-to-gherkin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplystated/xstate-to-gherkin/HEAD/xstate-to-gherkin.png -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | const cli = require("../dist/cli.js"); 6 | 7 | cli.run(process.argv.slice(2)); -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testMatch: ["**/test/**/*.[jt]s?(x)", "test/**/*.[jt]s?(x)", "test/*.[jt]s?(x)"] 6 | }; -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | 7 | jobs: 8 | build: 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: 'npm' 16 | - run: npm ci 17 | - run: npm test 18 | - run: npm run build -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016", 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 | "node_modules", 30 | "test" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Simply Stated Software. and its affiliates. 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@simplystated/xstate-to-gherkin", 3 | "version": "0.3.0", 4 | "description": "Generate gherkin test scripts from an xstate statechart", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "build": "tsc", 10 | "update-snapshots": "jest --updateSnapshot", 11 | "prettier": "prettier --write '{src,test}/**/*.{ts,js,yaml,yml,json}'", 12 | "postinstall": "patch-package" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/simplystated/xstate-to-gherkin.git" 17 | }, 18 | "keywords": [ 19 | "xstate" 20 | ], 21 | "author": "Adam Berger ", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/simplystated/xstate-to-gherkin/issues" 25 | }, 26 | "homepage": "https://github.com/simplystated/xstate-to-gherkin#readme", 27 | "files": [ 28 | "README", 29 | "LICENSE", 30 | "dist", 31 | "package.json", 32 | "patches" 33 | ], 34 | "bin": "./bin/cli.js", 35 | "devDependencies": { 36 | "@cucumber/gherkin": "^24.0.0", 37 | "@cucumber/messages": "^19.1.4", 38 | "@types/jest": "^29.0.3", 39 | "jest": "^29.1.1", 40 | "prettier": "^2.7.1", 41 | "ts-jest": "^29.0.2", 42 | "typescript": "^4.8.4" 43 | }, 44 | "dependencies": { 45 | "@xstate/graph": "^1.4.2", 46 | "@xstate/machine-extractor": "^0.7.1", 47 | "arg": "^5.0.2", 48 | "install": "^0.13.0", 49 | "npm": "^8.19.2", 50 | "patch-package": "^6.4.7", 51 | "xstate": "^4.33.6" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/__snapshots__/example.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`xstate to gherkin script works for a basic example with conditions 1`] = ` 4 | Map { 5 | "my-feature.feature" => "Feature: My Feature 6 | 7 | Scenario: Success 8 | Given I can see the start page 9 | When I tap the next button with good input 10 | Then I can see the success page 11 | And I see the first step of the success page 12 | 13 | Scenario: Failure 14 | Given I can see the start page 15 | When I tap the next button with bad input 16 | Then I see the failure page", 17 | } 18 | `; 19 | 20 | exports[`xstate to gherkin works for a basic example with conditions 1`] = ` 21 | [ 22 | { 23 | "feature": "My Feature", 24 | "scenarios": [ 25 | { 26 | "scenario": "Success", 27 | "steps": [ 28 | { 29 | "keyword": "Given", 30 | "step": "I can see the start page", 31 | }, 32 | { 33 | "keyword": "When", 34 | "step": "I tap the next button with good input", 35 | }, 36 | { 37 | "keyword": "Then", 38 | "step": "I can see the success page", 39 | }, 40 | { 41 | "keyword": "And", 42 | "step": "I see the first step of the success page", 43 | }, 44 | ], 45 | }, 46 | { 47 | "scenario": "Failure", 48 | "steps": [ 49 | { 50 | "keyword": "Given", 51 | "step": "I can see the start page", 52 | }, 53 | { 54 | "keyword": "When", 55 | "step": "I tap the next button with bad input", 56 | }, 57 | { 58 | "keyword": "Then", 59 | "step": "I see the failure page", 60 | }, 61 | ], 62 | }, 63 | ], 64 | }, 65 | ] 66 | `; 67 | 68 | exports[`xstate to gherkin works for a basic example with transient transitions 1`] = ` 69 | [ 70 | { 71 | "feature": "My Feature", 72 | "scenarios": [ 73 | { 74 | "scenario": "Success", 75 | "steps": [ 76 | { 77 | "keyword": "Given", 78 | "step": "I can see the start page", 79 | }, 80 | { 81 | "keyword": "When", 82 | "step": "good input", 83 | }, 84 | { 85 | "keyword": "Then", 86 | "step": "I can see the success page", 87 | }, 88 | { 89 | "keyword": "And", 90 | "step": "I see the first step of the success page", 91 | }, 92 | ], 93 | }, 94 | { 95 | "scenario": "Failure", 96 | "steps": [ 97 | { 98 | "keyword": "Given", 99 | "step": "I can see the start page", 100 | }, 101 | { 102 | "keyword": "When", 103 | "step": "bad input", 104 | }, 105 | { 106 | "keyword": "Then", 107 | "step": "I see the failure page", 108 | }, 109 | ], 110 | }, 111 | ], 112 | }, 113 | ] 114 | `; 115 | -------------------------------------------------------------------------------- /patches/@xstate+machine-extractor+0.7.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@xstate/machine-extractor/dist/xstate-machine-extractor.cjs.dev.js b/node_modules/@xstate/machine-extractor/dist/xstate-machine-extractor.cjs.dev.js 2 | index 328a049..7014100 100644 3 | --- a/node_modules/@xstate/machine-extractor/dist/xstate-machine-extractor.cjs.dev.js 4 | +++ b/node_modules/@xstate/machine-extractor/dist/xstate-machine-extractor.cjs.dev.js 5 | @@ -1035,7 +1035,10 @@ const Invoke = maybeArrayOf(InvokeConfigObject); 6 | 7 | const MetaDescription = unionType([StringLiteral, TemplateLiteral]); 8 | const StateMeta = objectTypeWithKnownKeys({ 9 | - description: MetaDescription 10 | + description: MetaDescription, 11 | + gherkinFeature: MetaDescription, 12 | + gherkinAssert: MetaDescription, 13 | + gherkinScenario: MetaDescription, 14 | }); 15 | 16 | const Schema = objectTypeWithKnownKeys({ 17 | @@ -1214,9 +1217,12 @@ const parseStateNode = (astResult, opts) => { 18 | config.always = getTransitions(astResult.always, opts); 19 | } 20 | 21 | - if ((_astResult$meta = astResult.meta) !== null && _astResult$meta !== void 0 && _astResult$meta.description) { 22 | + if ((_astResult$meta = astResult.meta) !== null && _astResult$meta !== void 0) { 23 | config.meta = { 24 | - description: astResult.meta.description.value 25 | + description: astResult.meta.description && astResult.meta.description.value, 26 | + gherkinAssert: astResult.meta.gherkinAssert && astResult.meta.gherkinAssert.value, 27 | + gherkinScenario: astResult.meta.gherkinScenario && astResult.meta.gherkinScenario.value, 28 | + gherkinFeature: astResult.meta.gherkinFeature && astResult.meta.gherkinFeature.value 29 | }; 30 | } 31 | 32 | diff --git a/node_modules/@xstate/machine-extractor/dist/xstate-machine-extractor.cjs.prod.js b/node_modules/@xstate/machine-extractor/dist/xstate-machine-extractor.cjs.prod.js 33 | index 328a049..7014100 100644 34 | --- a/node_modules/@xstate/machine-extractor/dist/xstate-machine-extractor.cjs.prod.js 35 | +++ b/node_modules/@xstate/machine-extractor/dist/xstate-machine-extractor.cjs.prod.js 36 | @@ -1035,7 +1035,10 @@ const Invoke = maybeArrayOf(InvokeConfigObject); 37 | 38 | const MetaDescription = unionType([StringLiteral, TemplateLiteral]); 39 | const StateMeta = objectTypeWithKnownKeys({ 40 | - description: MetaDescription 41 | + description: MetaDescription, 42 | + gherkinFeature: MetaDescription, 43 | + gherkinAssert: MetaDescription, 44 | + gherkinScenario: MetaDescription, 45 | }); 46 | 47 | const Schema = objectTypeWithKnownKeys({ 48 | @@ -1214,9 +1217,12 @@ const parseStateNode = (astResult, opts) => { 49 | config.always = getTransitions(astResult.always, opts); 50 | } 51 | 52 | - if ((_astResult$meta = astResult.meta) !== null && _astResult$meta !== void 0 && _astResult$meta.description) { 53 | + if ((_astResult$meta = astResult.meta) !== null && _astResult$meta !== void 0) { 54 | config.meta = { 55 | - description: astResult.meta.description.value 56 | + description: astResult.meta.description && astResult.meta.description.value, 57 | + gherkinAssert: astResult.meta.gherkinAssert && astResult.meta.gherkinAssert.value, 58 | + gherkinScenario: astResult.meta.gherkinScenario && astResult.meta.gherkinScenario.value, 59 | + gherkinFeature: astResult.meta.gherkinFeature && astResult.meta.gherkinFeature.value 60 | }; 61 | } 62 | 63 | -------------------------------------------------------------------------------- /test/example.ts: -------------------------------------------------------------------------------- 1 | import { createMachine } from "xstate"; 2 | import { 3 | AstBuilder, 4 | GherkinClassicTokenMatcher, 5 | Parser, 6 | } from "@cucumber/gherkin"; 7 | import { IdGenerator } from "@cucumber/messages"; 8 | import { toGherkinScripts, xstateToGherkin } from "../src"; 9 | 10 | const basicWithConditions = createMachine({ 11 | predictableActionArguments: true, 12 | id: "basicWithConditions", 13 | initial: "start", 14 | states: { 15 | start: { 16 | meta: { 17 | gherkinAssert: "I can see the start page", 18 | gherkinFeature: "My Feature", 19 | }, 20 | on: { 21 | "I tap the next button": [ 22 | { 23 | target: "success", 24 | cond: "good input", 25 | }, 26 | { 27 | target: "failure", 28 | cond: "bad input", 29 | }, 30 | ], 31 | }, 32 | }, 33 | success: { 34 | meta: { 35 | gherkinAssert: "I can see the success page", 36 | gherkinScenario: "Success", 37 | }, 38 | initial: "successStep1", 39 | states: { 40 | successStep1: { 41 | meta: { 42 | gherkinAssert: "I see the first step of the success page", 43 | }, 44 | }, 45 | }, 46 | }, 47 | failure: { 48 | meta: { 49 | gherkinAssert: "I see the failure page", 50 | gherkinScenario: "Failure", 51 | }, 52 | }, 53 | }, 54 | }); 55 | 56 | const basicWithTransientTransitions = createMachine({ 57 | predictableActionArguments: true, 58 | id: "basicWithTransientTransitions", 59 | initial: "start", 60 | states: { 61 | start: { 62 | meta: { 63 | gherkinAssert: "I can see the start page", 64 | gherkinFeature: "My Feature", 65 | }, 66 | always: [ 67 | { target: "success", cond: "good input" }, 68 | { target: "failure", cond: "bad input" }, 69 | ], 70 | }, 71 | success: { 72 | meta: { 73 | gherkinAssert: "I can see the success page", 74 | gherkinScenario: "Success", 75 | }, 76 | initial: "successStep1", 77 | states: { 78 | successStep1: { 79 | meta: { 80 | gherkinAssert: "I see the first step of the success page", 81 | }, 82 | }, 83 | }, 84 | }, 85 | failure: { 86 | meta: { 87 | gherkinAssert: "I see the failure page", 88 | gherkinScenario: "Failure", 89 | }, 90 | }, 91 | }, 92 | }); 93 | 94 | describe("xstate to gherkin", () => { 95 | it("works for a basic example with conditions", () => { 96 | expect(xstateToGherkin(basicWithConditions)).toMatchSnapshot(); 97 | }); 98 | 99 | it("works for a basic example with transient transitions", () => { 100 | expect(xstateToGherkin(basicWithTransientTransitions)).toMatchSnapshot(); 101 | }); 102 | }); 103 | 104 | describe("xstate to gherkin script", () => { 105 | it("works for a basic example with conditions", () => { 106 | const scripts = toGherkinScripts(xstateToGherkin(basicWithConditions)); 107 | 108 | const parser = gherkinParser(); 109 | scripts.forEach((script) => { 110 | expect(parser.parse(script).feature?.name).toEqual("My Feature"); 111 | }); 112 | 113 | expect(scripts).toMatchSnapshot(); 114 | }); 115 | }); 116 | 117 | const gherkinParser = () => { 118 | const uuidFn = IdGenerator.uuid(); 119 | const builder = new AstBuilder(uuidFn); 120 | const matcher = new GherkinClassicTokenMatcher(); 121 | 122 | return new Parser(builder, matcher); 123 | }; 124 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import * as arg from "arg"; 2 | import { existsSync, readFileSync, writeFileSync } from "fs"; 3 | import { createMachine } from "xstate"; 4 | import { parseMachinesFromFile } from "@xstate/machine-extractor"; 5 | import { toGherkinScripts, xstateToGherkin } from "."; 6 | import * as path from "node:path"; 7 | 8 | export const run = (argv: Array): number => { 9 | const args = arg( 10 | { 11 | "--help": Boolean, 12 | "--version": Boolean, 13 | "--output-dir": String, 14 | "--machine": String, 15 | "--force": Boolean, 16 | 17 | // aliases 18 | "-h": "--help", 19 | "-v": "--version", 20 | "-o": "--output-dir", 21 | "-m": "--machine", 22 | "-f": "--force", 23 | }, 24 | { 25 | stopAtPositional: true, 26 | argv, 27 | } 28 | ); 29 | 30 | if (args["--help"]) { 31 | usage(); 32 | return 0; 33 | } 34 | 35 | if (args["--version"]) { 36 | const packagejson = JSON.parse( 37 | readFileSync(path.join(__dirname, "..", "package.json"), "utf8") 38 | ); 39 | console.log(` 40 | xstate-to-gherkin v${packagejson.version} 41 | `); 42 | return 0; 43 | } 44 | 45 | const outputDir = args["--output-dir"]; 46 | const positionalArgs = args["_"]; 47 | const selectedMachine = args["--machine"]; 48 | const force = !!args["--force"]; 49 | 50 | if (!outputDir) { 51 | console.error("--output-dir is required"); 52 | usage(); 53 | return 1; 54 | } 55 | 56 | if (!positionalArgs || positionalArgs.length !== 1) { 57 | console.error( 58 | "path to js/ts file is required (pass it after all other arguments)" 59 | ); 60 | usage(); 61 | return 1; 62 | } 63 | 64 | return main(outputDir, positionalArgs[0], selectedMachine ?? null, force); 65 | }; 66 | 67 | const usage = () => { 68 | console.log(` 69 | xstate-to-gherkin converts annotated xstate statecharts into gherkin test scripts. 70 | 71 | Usage: 72 | xstate-to-gherkin [--machine-name ] [--force] --output-dir 73 | 74 | Generates a set of .feature files in by traversing all simple paths in the statechart 75 | contained in . If multiple machines are present in 76 | , specify --machine-name to pick one to use. 77 | By default, xstate-to-gherkin will not overwrite any files. Pass --force to overwrite. 78 | 79 | Statechart annotation: 80 | This tool requires each state to be annotated with up to 3 meta fields: 81 | gherkinFeature - (optional) specifies the feature this state is a part of 82 | gherkinScenario - (optional) specifies the scenario this state is a part of 83 | gherkinAssert - specifies the assertion that should hold in this state 84 | `); 85 | }; 86 | 87 | const main = ( 88 | outputDir: string, 89 | inputPath: string, 90 | selectedMachine: string | null, 91 | force: boolean 92 | ): number => { 93 | const js = readFileSync(inputPath, "utf8"); 94 | const machines = parseMachinesFromFile(js); 95 | const machineConfigs = machines.machines.map((m) => m.toConfig()); 96 | if (machineConfigs.length > 1 && !selectedMachine) { 97 | console.error( 98 | `multiple machines found at ${inputPath}. specify --machine to select one.` 99 | ); 100 | return 1; 101 | } 102 | const machineConfig = selectedMachine 103 | ? machineConfigs.filter((c) => c?.id === selectedMachine)[0] 104 | : machineConfigs[0]; 105 | if (!machineConfig) { 106 | if (selectedMachine) { 107 | console.error( 108 | `no machine named ${selectedMachine} found in ${inputPath}. found machines: [${machineConfigs 109 | .map((m) => m?.id) 110 | .join(", ")}].` 111 | ); 112 | } else { 113 | console.error(`no machines found in ${inputPath}.`); 114 | } 115 | return 1; 116 | } 117 | 118 | const scriptsByName = toGherkinScripts( 119 | xstateToGherkin( 120 | createMachine({ ...machineConfig, predictableActionArguments: true }) 121 | ) 122 | ); 123 | for (const [filename, script] of scriptsByName.entries()) { 124 | const filePath = path.join(outputDir, filename); 125 | if (!force && existsSync(filePath)) { 126 | console.error( 127 | `refusing to overwrite existing file ${filePath}. pass --force to overwrite.` 128 | ); 129 | return 1; 130 | } 131 | writeFileSync(path.join(outputDir, filename), script, { encoding: "utf8" }); 132 | } 133 | 134 | return 0; 135 | }; 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xstate-to-gherkin · [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/simplystated/xstate-to-gherkin/blob/main/LICENSE) [![npm version](https://img.shields.io/npm/v/@simplystated/xstate-to-gherkin.svg?style=flat)](https://www.npmjs.com/package/@simplystated/xstate-to-gherkin) [![CI](https://github.com/simplystated/xstate-to-gherkin/actions/workflows/ci.yaml/badge.svg)](https://github.com/simplystated/xstate-to-gherkin/actions/workflows/ci.yaml) 2 | 3 | xstate-to-gherkin is a library and cli tool to generate [Gherkin](https://cucumber.io/docs/gherkin/reference) test scripts from an [XState](https://github.com/statelyai/xstate) statechart. 4 | 5 | ![Logo](./xstate-to-gherkin.png) 6 | 7 | # Quickstart 8 | 9 | **Check out a codesandbox demo [here](https://codesandbox.io/s/simply-stated-xstate-to-gherkin-shdqv2) to play with xstate-to-gherkin from your browser.** 10 | 11 | You'll need to have an XState [machine definition](https://xstate.js.org/docs/guides/machines.html). 12 | xstate-to-gherkin requires you to annotate your machine with a few additional pieces of metadata. 13 | On any state you would like to make an assertion for, set `meta.gherkinAssert` to a string describing what should be true in that state. 14 | Optionally, on any state that is part of a feature, set `meta.gherkinFeature` to a string describing that feature. 15 | Optionally, on any state that is part of a scenario, set `meta.gherkinScenario` to a string describing that scenario. 16 | 17 | Here's an example. 18 | 19 | Assume you have a `machine.ts` file like this: 20 | 21 | ```typescript 22 | const basicWithConditions = createMachine({ 23 | predictableActionArguments: true, 24 | id: "basicWithConditions", 25 | initial: "start", 26 | states: { 27 | start: { 28 | meta: { 29 | // whenever you're in this state, this should be true 30 | gherkinAssert: "I can see the start page", 31 | // this state is part of My Feature. 32 | gherkinFeature: "My Feature", 33 | }, 34 | on: { 35 | "I tap the next button": [ 36 | { 37 | target: "success", 38 | // guards will be turned into "When ... with ." 39 | // So this will be turned into "When I tap on the next button with good input" 40 | cond: "good input", 41 | }, 42 | { 43 | target: "failure", 44 | cond: "bad input", 45 | }, 46 | ], 47 | }, 48 | }, 49 | success: { 50 | meta: { 51 | gherkinAssert: "I can see the success page", 52 | gherkinScenario: "Success", 53 | }, 54 | initial: "successStep1", 55 | states: { 56 | successStep1: { 57 | meta: { 58 | gherkinAssert: "I see the first step of the success page", 59 | }, 60 | }, 61 | }, 62 | }, 63 | failure: { 64 | meta: { 65 | gherkinAssert: "I see the failure page", 66 | gherkinScenario: "Failure", 67 | }, 68 | }, 69 | }, 70 | }); 71 | ``` 72 | 73 | After running 74 | 75 | ```bash 76 | npx @simplystated/xstate-to-gherkin --output-dir out machine.ts 77 | ``` 78 | 79 | You should see a new file, `out/my-feature.feature` with contents like this: 80 | 81 | ```gherkin 82 | Feature: My Feature 83 | 84 | Scenario: Success 85 | Given I can see the start page 86 | When I tap the next button with good input 87 | Then I can see the success page 88 | And I see the first step of the success page 89 | 90 | Scenario: Failure 91 | Given I can see the start page 92 | When I tap the next button with bad input 93 | Then I see the failure page 94 | ``` 95 | 96 | # Using xstate-to-gherkin as a library 97 | 98 | Install: 99 | 100 | ```bash 101 | npm install --save-dev @simplystated/xstate-to-gherkin 102 | ``` 103 | 104 | Then, in code: 105 | 106 | ```typescript 107 | import { xstateToGherkinScripts } from "@simplystated/xstate-to-gherkin"; 108 | import { createMachine } from "xstate"; 109 | 110 | const machine = createMachine(...); 111 | 112 | // scriptsByFilenames will contain a Map from filenames (e.g. my-feature.feature) to the Gherkin content of the file. 113 | const scriptsByFilenames = xstateToGherkinScripts(machine); 114 | ``` 115 | 116 | # Why would you want to generate a Gherkin script from a statechart? 117 | 118 | First off, it's important to note that you can describe any reactive system as a statechart. 119 | That means that you can certainly model anything you are trying to test with Gherkin as a statechart. 120 | Generally, modeling these types of systems as statecharts is quite straightforward because the structure of the statechart closely matches the way we humans tend to think about these systems. 121 | 122 | Next, realize that you need to write a number of test scripts proportional to the number of paths through your system (statechart) if you want to cover all of your functionality. 123 | Users do not just experience states; they experience paths through states. 124 | Suffice it to say that there are many more paths than there are states. 125 | If you try to explicitly list every path, you will have an enormous amount of work to do, especially if you add new edges near your initial state. 126 | 127 | So, we can take advantage of the power of the declarative nature of statecharts AND the fact that they are easy to use to model any system we might care about AND the fact that they allow us to do work in proportion to the number of states instead of the number of paths to let our computer do the hard work for us. 128 | 129 | # Simply Stated 130 | 131 | xstate-to-gherkin is a small tool built by [Simply Stated](https://www.simplystated.dev). 132 | At Simply Stated, our goal is to build all of the tooling you need to experience the full power of statecharts. 133 | 134 | # License 135 | 136 | xstate-to-gherkin is [MIT licensed](https://github.com/simplystated/xstate-to-gherkin/blob/main/LICENSE). 137 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getSimplePaths } from "@xstate/graph"; 2 | import { 3 | AnyEventObject, 4 | createMachine, 5 | State, 6 | StateMachine, 7 | StateNode, 8 | StateNodeConfig, 9 | StateNodeDefinition, 10 | } from "xstate"; 11 | 12 | type AnyMachine = StateMachine; 13 | type AnyState = StateNodeDefinition; 14 | type AnyStateConfig = StateNodeConfig; 15 | 16 | /** 17 | * Generate a set of gherkin feature files by walking all simple paths in `machine`. 18 | * 19 | * @param machine xstate machine, annotated with states additional metadata (gherkinAssert, gherkinFeature, gherkinScenario). 20 | * meta.gherkinAssert should be a string that describes some assertion that should hold in that state. 21 | * meta.gherkinFeature should be a string that describes the feature that the state is a part of. 22 | * meta.gherkinScenario should be a string that describes the scenario that the state is a part of. 23 | * @returns a Map from string filenames (e.g. my-feature.feature) to the gherkin-formatted content of those files. No filesystem operations are performed. 24 | */ 25 | export const xstateToGherkinScripts = ( 26 | machine: AnyMachine 27 | ): Map => toGherkinScripts(xstateToGherkin(machine)); 28 | 29 | /** 30 | * Transform an array of `GherkinFeature`s into a Map from filenames to gherkin scripts. 31 | * 32 | * @param features An array of `GherkinFeature`s, as returned by `xstateToGherkin`. 33 | * @returns a Map from string filenames (e.g. my-feature.feature) to the gherkin-formatted content of those files. No filesystem operations are performed. 34 | */ 35 | export const toGherkinScripts = ( 36 | features: Array 37 | ): Map => 38 | features.reduce((filenamesToScripts, gherkinFeature) => { 39 | const filename = toFilename(gherkinFeature.feature) + ".feature"; 40 | const contents = featureToScript(gherkinFeature); 41 | filenamesToScripts.set(filename, contents); 42 | return filenamesToScripts; 43 | }, new Map()); 44 | 45 | /** 46 | * Transform an xstate machine into an array of `GherkinFeature`s, with one or more scenarios, each describing a test case. 47 | * A test case is generated for each simple path through the states of the machine. 48 | * 49 | * @param machine xstate machine, annotated with states additional metadata (gherkinAssert, gherkinFeature, gherkinScenario). 50 | * meta.gherkinAssert should be a string that describes some assertion that should hold in that state. 51 | * meta.gherkinFeature should be a string that describes the feature that the state is a part of. 52 | * meta.gherkinScenario should be a string that describes the scenario that the state is a part of. 53 | * @returns an array of `GherkinFeature`s. 54 | */ 55 | export const xstateToGherkin = (machine: AnyMachine): Array => { 56 | const canonicalDefn = machine.definition; 57 | const flattened = fixupStateDefinition(flattenConds(canonicalDefn)); 58 | const pathsByEndState = getSimplePaths( 59 | createMachine({ predictableActionArguments: true, ...flattened }) 60 | ); 61 | const flattenedSteps = Object.keys(pathsByEndState) 62 | .flatMap((endState) => { 63 | const { state, paths } = pathsByEndState[endState]; 64 | return paths.map((path) => { 65 | const features = path.segments.reduce((features, segment) => { 66 | gherkinFeaturesFromState(segment.state).forEach((gherkinFeature) => 67 | features.add(gherkinFeature) 68 | ); 69 | return features; 70 | }, new Set(gherkinFeaturesFromState(path.state))); 71 | const disambiguatingScenarios = Array.from( 72 | path.segments.reduce((scenarios, segment) => { 73 | gherkinScenariosFromState(segment.state).forEach( 74 | (gherkinScenario) => scenarios.add(gherkinScenario) 75 | ); 76 | return scenarios; 77 | }, new Set()) 78 | ); 79 | const scenarios = gherkinScenariosFromState(path.state); 80 | const steps = path.segments.flatMap((segment, idx) => { 81 | const gherkinStepsForState = gherkinStepsFromState( 82 | segment.state, 83 | idx === 0 84 | ); 85 | const gherkinStepsForEvent = gherkinStepsFromEvent(segment.event); 86 | return gherkinStepsForState 87 | .concat(gherkinStepsForEvent) 88 | .concat( 89 | gherkinStepsFromState(path.state, path.segments.length === 0) 90 | ); 91 | }); 92 | return { 93 | feature: 94 | features.size > 0 95 | ? Array.from(features).join(" & ") 96 | : "Default Feature", 97 | scenario: 98 | scenarios.length > 0 ? scenarios.join(" & ") : "Default Scenario", 99 | disambiguatingScenarios, 100 | steps, 101 | }; 102 | }); 103 | }) 104 | .filter((feature) => feature.steps.length > 0); 105 | return flattenedStepsToScenarios(flattenedSteps); 106 | }; 107 | 108 | const toFilename = (s: string): string => 109 | s.toLowerCase().replace(/[^a-zA-Z0-9_]+/g, "-"); 110 | 111 | const featureToScript = (feature: GherkinFeature): string => { 112 | const script = `Feature: ${feature.feature}`; 113 | const featuresScript = feature.scenarios 114 | .map((scenario) => { 115 | const scenarioHeader = `Scenario: ${scenario.scenario}`; 116 | const steps = scenario.steps.map( 117 | (step) => `${step.keyword} ${step.step}` 118 | ); 119 | return [indent(2, scenarioHeader)] 120 | .concat(steps.map(indent.bind(null, 4))) 121 | .join("\n"); 122 | }) 123 | .join("\n\n"); 124 | return [script, featuresScript].join("\n\n"); 125 | }; 126 | 127 | const indent = (spaces: number, s: string): string => 128 | new Array(spaces + 1).join(" ") + s; 129 | 130 | const flattenedStepsToScenarios = ( 131 | flattenedSteps: Array 132 | ): Array => { 133 | const featuresByName = flattenedSteps.reduce( 134 | ( 135 | featuresByName: Map>>, 136 | flattenedStep 137 | ) => { 138 | const { feature, scenario, steps, disambiguatingScenarios } = 139 | flattenedStep; 140 | const scenariosByName = 141 | featuresByName.get(feature) ?? new Map>(); 142 | const needDisambiguation = scenariosByName.has(scenario); 143 | const disambiguatedScenarioName = needDisambiguation 144 | ? `${[scenario].concat(disambiguatingScenarios).join(" with ")}` 145 | : scenario; 146 | const disambiguationFailed = scenariosByName.has( 147 | disambiguatedScenarioName 148 | ); 149 | const finalScenarioName = disambiguationFailed 150 | ? `${disambiguatedScenarioName} ${scenariosByName.size}` 151 | : disambiguatedScenarioName; 152 | scenariosByName.set(finalScenarioName, steps); 153 | featuresByName.set(feature, scenariosByName); 154 | return featuresByName; 155 | }, 156 | new Map() 157 | ); 158 | return Array.from(featuresByName.entries()).map( 159 | ([feature, scenariosByName]) => ({ 160 | feature, 161 | scenarios: Array.from(scenariosByName.entries()).map( 162 | ([scenario, steps]) => ({ 163 | scenario, 164 | steps, 165 | }) 166 | ), 167 | }) 168 | ); 169 | }; 170 | 171 | interface GherkinStepsWithFeatureAndScenario { 172 | feature: string; 173 | scenario: string; 174 | disambiguatingScenarios: Array; 175 | steps: Array; 176 | } 177 | 178 | /** 179 | * Represents a gherkin feature, which is named and has one or more scenarios. 180 | */ 181 | export interface GherkinFeature { 182 | feature: string; 183 | scenarios: Array; 184 | } 185 | 186 | /** 187 | * Represents a gherkin scenario, which is named and has one or more steps. 188 | */ 189 | export interface GherkinScenario { 190 | scenario: string; 191 | steps: Array; 192 | } 193 | 194 | /** 195 | * Represents a gherkin step (given, when, then, etc.). 196 | */ 197 | export interface GherkinStep { 198 | keyword: "Given" | "Then" | "And" | "When"; 199 | step: string; 200 | } 201 | 202 | const gherkinStepsFromEvent = (event: AnyEventObject): Array => [ 203 | { keyword: "When", step: event.type.replace(/_/g, " ") }, 204 | ]; 205 | 206 | const gherkinStepsFromState = ( 207 | state: State, 208 | isFirstStep: boolean 209 | ): Array => 210 | valuesSortedByKey(state.meta) 211 | .map((m) => (m as any).gherkinAssert) 212 | .filter((gherkinAssert) => !!gherkinAssert) 213 | .map((step, idx) => { 214 | const keyword = 215 | idx === 0 && isFirstStep ? "Given" : idx === 0 ? "Then" : "And"; 216 | return { keyword, step }; 217 | }); 218 | 219 | const gherkinFeaturesFromState = (state: State): Array => 220 | valuesSortedByKey(state.meta) 221 | .map((m) => (m as any).gherkinFeature) 222 | .filter((gherkinFeature) => !!gherkinFeature); 223 | 224 | const gherkinScenariosFromState = (state: State): Array => 225 | valuesSortedByKey(state.meta) 226 | .map((m) => (m as any).gherkinScenario) 227 | .filter((gherkinScenario) => !!gherkinScenario); 228 | 229 | const valuesSortedByKey = (m: Record): Array => { 230 | const keys = Object.keys(m); 231 | keys.sort(); 232 | return keys.map((k) => m[k]); 233 | }; 234 | 235 | const flattenConds = (state: AnyState): Omit => { 236 | const { transitions, ...stateDefinition } = state; 237 | 238 | stateDefinition.on = Object.keys(stateDefinition.on).reduce((on, evtName) => { 239 | const evts = stateDefinition.on[evtName]; 240 | return evts.reduce((on, evt) => { 241 | const { cond, ...unconditionalEvt } = evt; 242 | if (cond) { 243 | const condName = cond.name; 244 | const newEvtName = 245 | evtName.length > 0 ? `${evtName} with ${condName}` : condName; 246 | return { 247 | ...on, 248 | [newEvtName]: unconditionalEvt, 249 | }; 250 | } 251 | 252 | return { 253 | ...on, 254 | [evtName]: evt, 255 | }; 256 | }, on); 257 | }, {}); 258 | 259 | stateDefinition.states = Object.keys(stateDefinition.states).reduce( 260 | (states, stateName) => ({ 261 | ...states, 262 | [stateName]: flattenConds(stateDefinition.states[stateName]), 263 | }), 264 | {} 265 | ); 266 | 267 | return stateDefinition; 268 | }; 269 | 270 | // we "fixup" state definitions because StateNode.definition returns null state transitions instead of always 271 | // transitions and that is deprecated. this is obviously wildly inefficient. 272 | const fixupStateDefinition = ( 273 | definition: Partial 274 | ): AnyStateConfig => { 275 | const { on, ...stateDefinition } = definition; 276 | const stateConfig: AnyStateConfig = stateDefinition; 277 | const onConfig = on ?? {}; 278 | 279 | const alwaysTransitions = onConfig[""]; 280 | if (alwaysTransitions) { 281 | stateConfig.always = alwaysTransitions; 282 | delete onConfig[""]; 283 | } 284 | stateConfig.on = onConfig; 285 | 286 | const states = stateConfig.states ?? {}; 287 | stateConfig.states = Object.keys(states).reduce( 288 | (fixedStates, stateName) => ({ 289 | ...fixedStates, 290 | [stateName]: fixupStateDefinition(states[stateName] as any as AnyState), 291 | }), 292 | {} 293 | ); 294 | 295 | return stateConfig; 296 | }; 297 | --------------------------------------------------------------------------------