diff --git a/.github/workflows/stepfunctions.yml b/.github/workflows/stepfunctions.yml index c044e04..e580a46 100644 --- a/.github/workflows/stepfunctions.yml +++ b/.github/workflows/stepfunctions.yml @@ -3,8 +3,8 @@ name: Stepfunctions on: push: branches: [master] + # Run on every pull request, including ones stacked on feature branches. pull_request: - branches: [master] jobs: build: @@ -22,6 +22,7 @@ jobs: - run: npm install - run: npm run lint - run: npm test + - run: npm run test:smoke - name: Upload coverage uses: codecov/codecov-action@v4 with: diff --git a/.gitignore b/.gitignore index 00bca62..735d7aa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ node_modules .env *.log *.lock +*.tgz .serverless coverage \ No newline at end of file diff --git a/README.md b/README.md index 5930bf9..d1b1480 100644 --- a/README.md +++ b/README.md @@ -34,20 +34,29 @@ If you are: ## Usage -Include it in your test files, tested with Jest so far. +The package ships both ESM and CommonJS entry points with TypeScript types, so use whichever you prefer: + +```js +// ESM +import Sfn from 'stepfunctions'; +// or: import { StepFunction } from 'stepfunctions'; + +// CommonJS +const Sfn = require('stepfunctions'); +``` + +Include it in your test files (tested with Jest so far). You can pass a bare Amazon States Language definition straight to the constructor, and `startExecution` resolves to the final output: ```js const Sfn = require('stepfunctions'); const sm = new Sfn({ - StateMachine: { - StartAt: 'Test', - States: { - Test: { - Type: 'Task', - Resource: 'arn:aws:lambda:ap-southeast-1:123456789012:function:test', - End: true, - }, + StartAt: 'Test', + States: { + Test: { + Type: 'Task', + Resource: 'arn:aws:lambda:ap-southeast-1:123456789012:function:test', + End: true, }, }, }); @@ -56,20 +65,25 @@ describe('StateMachine Test', () => { it('Check if a task was run', async () => { const mockfn = jest.fn((input) => input.test === 1); sm.bindTaskResource('Test', mockfn); - await sm.startExecution({ test: 1 }); + const result = await sm.startExecution({ test: 1 }); expect(mockfn).toHaveBeenCalled(); + expect(result).toBe(true); }); }); ``` +The `new Sfn({ StateMachine })` form is still supported for backwards compatibility, as is calling `getExecutionResult()` after `startExecution` (it is now idempotent — it no longer consumes the result). + You can see more examples in `/test/stepfunctions.test.js`. ## API ### startExecution(Input, Options); +Resolves to the final output of the execution. + ```js -sm.startExecution(input, { +const output = await sm.startExecution(input, { respectTime: false, maxWaitTime: 30, maxConcurrency: 10, @@ -105,7 +119,7 @@ Must be called before `startExecution`, binds to `Tasks` and replaces their hand ### getExecutionResult() -Must be called after `startExecution`. This function returns the absolute result from the statemachine if it has finished. +Must be called after `startExecution`. This function returns the absolute result from the statemachine if it has finished. It is idempotent — repeated calls return the same value. (`startExecution` also resolves to this value, so you often don't need it.) ### getReport() diff --git a/eslint.config.js b/eslint.config.js index 95e0fab..094d71f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,4 +18,10 @@ module.exports = [ 'no-unused-vars': ['error', { args: 'none', varsIgnorePattern: '^_' }], }, }, + { + files: ['**/*.mjs'], + languageOptions: { + sourceType: 'module', + }, + }, ]; diff --git a/index.d.mts b/index.d.mts new file mode 100644 index 0000000..8ac64c5 --- /dev/null +++ b/index.d.mts @@ -0,0 +1,50 @@ +import { EventEmitter } from 'events'; + +/** Options accepted by `startExecution`. */ +export interface ExecutionOptions { + /** Respect the real durations of `Wait` states instead of capping them. */ + respectTime?: boolean; + /** Maximum number of seconds a `Wait` state may block for. Defaults to 30. */ + maxWaitTime?: number; + /** Maximum number of branches/iterations run concurrently. Defaults to 10. */ + maxConcurrency?: number; +} + +/** A bound Task handler, e.g. a Serverless/Lambda handler. */ +export type TaskResource = (input: any) => any; + +/** An Amazon States Language state machine definition. */ +export type StateMachine = Record; + +export interface Options { + /** Friendly name for the execution. Defaults to the StateMachine `StartAt`. */ + Name?: string; + StateMachine: StateMachine; + /** Map of Task state name to its handler. */ + Resources?: Record; +} + +/** + * An in-memory AWS Step Functions / Amazon States Language interpreter, made to + * run Node.js Lambda handlers inside a test environment. + */ +export declare class StepFunction extends EventEmitter { + constructor(sm: Options | StateMachine); + + /** + * Start an execution, passing `input` to the first state. Resolves to the + * final output (the same value as `getExecutionResult()`). + */ + startExecution(input?: any, opts?: ExecutionOptions): Promise; + + /** Return the final output of the most recent execution. */ + getExecutionResult(): any; + + /** Pretty-print the recorded transitions with `console.table`. */ + getReport(): void; + + /** Replace a Task's resource with the provided handler. */ + bindTaskResource(task: string, fn: TaskResource): void; +} + +export default StepFunction; diff --git a/index.d.ts b/index.d.ts index bfc2c5c..896b68d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -31,13 +31,16 @@ declare namespace StepFunction { * run Node.js Lambda handlers inside a test environment. */ declare class StepFunction extends EventEmitter { - constructor(sm: StepFunction.Options); + constructor(sm: StepFunction.Options | StepFunction.StateMachine); - /** Start an execution, passing `input` to the first state. */ + /** + * Start an execution, passing `input` to the first state. Resolves to the + * final output (the same value as `getExecutionResult()`). + */ startExecution( input?: any, opts?: StepFunction.ExecutionOptions, - ): Promise; + ): Promise; /** Return the final output of the most recent execution. */ getExecutionResult(): any; diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..b9690b2 --- /dev/null +++ b/index.mjs @@ -0,0 +1,4 @@ +import StepFunction from './lib/stepfunctions.js'; + +export default StepFunction; +export { StepFunction }; diff --git a/lib/stepfunctions.js b/lib/stepfunctions.js index 70b9c9f..d7b8c00 100644 --- a/lib/stepfunctions.js +++ b/lib/stepfunctions.js @@ -43,16 +43,19 @@ class StepFunction extends EventEmitter { */ constructor(sm) { super(); - const { isValid, errorsText } = aslValidator(sm.StateMachine); + // Accept either `{ StateMachine, Name?, Resources? }` or a bare Amazon + // States Language definition (`{ StartAt, States, ... }`) directly. + const opts = sm && sm.StateMachine ? sm : { StateMachine: sm }; + const { isValid, errorsText } = aslValidator(opts.StateMachine); if (!isValid) { throw new Error(errorsText()); } - this.Name = sm.Name || sm.StateMachine.StartAt; - this.StateMachine = sm.StateMachine; - this.Resources = typeof sm.Resources === 'object' ? sm.Resources : {}; + this.Name = opts.Name || opts.StateMachine.StartAt; + this.StateMachine = opts.StateMachine; + this.Resources = typeof opts.Resources === 'object' ? opts.Resources : {}; // Default query language for the whole machine; a state may override it. - this.queryLanguage = sm.StateMachine.QueryLanguage || 'JSONPath'; + this.queryLanguage = opts.StateMachine.QueryLanguage || 'JSONPath'; // Each Map iteration / Parallel branch runs in its own variable scope; an // AsyncLocalStorage keeps those scopes isolated even under concurrency. this.variableStore = new AsyncLocalStorage(); @@ -116,6 +119,9 @@ class StepFunction extends EventEmitter { throw err; } } + // Return the result directly so callers can `const out = await + // sm.startExecution(...)` without a separate getExecutionResult() call. + return this.getExecutionResult(); } /** @@ -127,7 +133,9 @@ class StepFunction extends EventEmitter { * @returns {any} */ getExecutionResult() { - return this.steps.pop().output; + // Non-mutating: returns the final output and can be called repeatedly. + const last = this.steps[this.steps.length - 1]; + return last ? last.output : undefined; } /** diff --git a/package-lock.json b/package-lock.json index 42fed44..9c56404 100644 --- a/package-lock.json +++ b/package-lock.json @@ -846,30 +846,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -884,20 +860,6 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -5556,13 +5518,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -5920,9 +5875,9 @@ } }, "node_modules/underscore": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", - "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 2253755..a9d26ae 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,21 @@ "description": "AWS Step Functions implementation in Node, so you can run your Node.js lambda handlers in your test environments. Made to support Serverless JS testing.", "version": "0.2.0", "main": "index.js", + "module": "index.mjs", "types": "index.d.ts", + "exports": { + ".": { + "import": { + "types": "./index.d.mts", + "default": "./index.mjs" + }, + "require": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, + "./package.json": "./package.json" + }, "license": "MIT", "repository": { "type": "git", @@ -16,12 +30,15 @@ "lib", "LICENSE", "index.js", + "index.mjs", "index.d.ts", + "index.d.mts", "package.json" ], "scripts": { "test": "jest", - "lint": "eslint lib test index.js && prettier --check .", + "test:smoke": "node test/smoke.mjs", + "lint": "eslint lib test index.js index.mjs && prettier --check .", "format": "prettier --write .", "prepare": "husky || true" }, @@ -39,6 +56,10 @@ "lint-staged": "^15.2.10", "prettier": "^3.3.3" }, + "overrides": { + "underscore": "^1.13.8", + "js-yaml": "^4.2.0" + }, "peerDependencies": { "serverless": ">=1.74.1" }, @@ -48,7 +69,7 @@ } }, "lint-staged": { - "*.js": [ + "*.{js,mjs}": [ "eslint --fix", "prettier --write" ] diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..61b75c9 --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,29 @@ +// Smoke test for the dual ESM/CJS entry points and the ergonomic API. +// Run with `npm run test:smoke` (and in CI on every supported Node version). +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import Sfn, { StepFunction } from '../index.mjs'; + +const require = createRequire(import.meta.url); +const SfnCjs = require('../index.js'); + +assert.equal(typeof Sfn, 'function', 'ESM default export is the class'); +assert.equal(StepFunction, Sfn, 'ESM named export equals default'); +assert.equal(SfnCjs, Sfn, 'CJS require resolves to the same class'); + +// Ergonomics: a bare state-machine definition, and startExecution that +// resolves to the result and a non-mutating getExecutionResult(). +const sm = new Sfn({ + StartAt: 'A', + States: { A: { Type: 'Pass', End: true } }, +}); +const result = await sm.startExecution({ ok: 1 }); +assert.deepEqual(result, { ok: 1 }, 'startExecution resolves to the result'); +assert.deepEqual(sm.getExecutionResult(), { ok: 1 }); +assert.deepEqual( + sm.getExecutionResult(), + { ok: 1 }, + 'getExecutionResult is idempotent', +); + +console.log('smoke: dual ESM/CJS entry points + ergonomic API OK'); diff --git a/test/stepfunctions.test.js b/test/stepfunctions.test.js index 385695b..dee4be4 100644 --- a/test/stepfunctions.test.js +++ b/test/stepfunctions.test.js @@ -1196,4 +1196,35 @@ describe('Stepfunctions', () => { ]); }); }); + + describe('ergonomics', () => { + it('startExecution resolves to the execution result', async () => { + const sm = new Sfn({ StateMachine: require('./steps/simple.json') }); + sm.bindTaskResource('Test', (input) => input.test === 1); + const result = await sm.startExecution({ test: 1 }); + expect(result).toBe(true); + }); + + it('getExecutionResult is idempotent (does not mutate)', async () => { + const sm = new Sfn({ StateMachine: require('./steps/simple.json') }); + sm.bindTaskResource('Test', (input) => input.test === 1); + await sm.startExecution({ test: 1 }); + expect(sm.getExecutionResult()).toBe(true); + expect(sm.getExecutionResult()).toBe(true); + expect(sm.getExecutionResult()).toBe(true); + }); + + it('accepts a raw state machine definition (no StateMachine wrapper)', async () => { + const sm = new Sfn(require('./steps/simple.json')); + sm.bindTaskResource('Test', (input) => input.test === 1); + const result = await sm.startExecution({ test: 1 }); + expect(result).toBe(true); + }); + + it('still validates a raw state machine definition', () => { + expect(() => new Sfn({ willThrow: true })).toThrow( + /required property 'StartAt'/, + ); + }); + }); });