machina-test 0.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,25 @@
1
1
  # machina-test
2
2
 
3
- Jest/Vitest custom matchers for testing [machina](https://machina-js.org) FSM topology. Assert reachability, catch dead states, and verify structural invariants — all from your existing test suite.
3
+ Testing tools for [machina](https://machina-js.org) FSMs — graph topology matchers and property-based runtime testing.
4
4
 
5
5
  ```ts
6
6
  import "machina-test";
7
+ import { walkAll } from "machina-test";
7
8
 
9
+ // Graph matchers — does the wiring look right?
8
10
  expect(fsm).toHaveNoUnreachableStates();
9
11
  expect(fsm).toAlwaysReach("delivered", { from: "placed" });
10
12
  expect(fsm).toNeverReach("shipped", { from: "cancelled" });
13
+
14
+ // Runtime testing — does it actually work?
15
+ walkAll(() => createMyFsm(), {
16
+ invariant({ ctx }) {
17
+ /* assert a rule that must always hold */
18
+ },
19
+ });
11
20
  ```
12
21
 
13
- Built on [machina-inspect](https://www.npmjs.com/package/machina-inspect). Zero ceremony beyond the import.
22
+ Graph matchers are built on [machina-inspect](https://www.npmjs.com/package/machina-inspect). `walkAll` runs the FSM live with randomized inputs.
14
23
 
15
24
  ## Install
16
25
 
@@ -99,11 +108,52 @@ expect(payment).toNeverReach("entering-details", { from: "authorized" });
99
108
 
100
109
  **The one exception**: `toHaveNoUnreachableStates()` _does_ recurse into children via `inspectGraph()`. An orphaned state in a child surfaces as a failure when called on the parent.
101
110
 
111
+ ## walkAll — Runtime Testing
112
+
113
+ The matchers check graph topology — does a path exist? `walkAll` checks runtime behavior — does the FSM actually work when you feed it random inputs?
114
+
115
+ ```ts
116
+ import { walkAll, WalkFailureError } from "machina-test";
117
+
118
+ const result = walkAll(
119
+ () => createMyFsm(), // factory: fresh FSM per walk
120
+ {
121
+ walks: 200, // 200 independent walks
122
+ maxSteps: 20, // up to 20 handle() calls per walk
123
+ seed: 42, // deterministic — same sequence every run
124
+ inputs: {
125
+ begin: () => Math.floor(Math.random() * 200),
126
+ },
127
+ invariant({ ctx }) {
128
+ // checked after every transition — throw to fail
129
+ if ((ctx as any).balance < 0) {
130
+ throw new Error("balance went negative");
131
+ }
132
+ },
133
+ }
134
+ );
135
+ ```
136
+
137
+ On failure, `WalkFailureError` carries the seed, step number, and full input sequence. Pass the seed back to replay the exact walk that failed:
138
+
139
+ ```ts
140
+ try {
141
+ walkAll(factory, config);
142
+ } catch (err) {
143
+ if (err instanceof WalkFailureError) {
144
+ // err.seed, err.step, err.state, err.inputSequence
145
+ // Replay: walkAll(factory, { ...config, seed: err.seed })
146
+ }
147
+ }
148
+ ```
149
+
150
+ Both `Fsm` and `BehavioralFsm` are supported. See the [docs](https://machina-js.org/tools/machina-test/) for full configuration reference, payload generators, input filtering, and BehavioralFsm client factories.
151
+
102
152
  ## See also
103
153
 
104
- - [machina-inspect](https://www.npmjs.com/package/machina-inspect) — the graph analysis engine these matchers are built on
154
+ - [machina-inspect](https://www.npmjs.com/package/machina-inspect) — the graph analysis engine the matchers are built on
105
155
  - [eslint-plugin-machina](https://www.npmjs.com/package/eslint-plugin-machina) — catch structural issues at lint time in your editor
106
- - [testing-with-machina-test example](../../examples/testing-with-machina-test/) — full working example with flat and hierarchical FSMs
156
+ - [testing-with-machina-test example](../../examples/testing-with-machina-test/) — full working example with matchers and walkAll
107
157
 
108
158
  ## License
109
159
 
package/dist/index.cjs CHANGED
@@ -1,4 +1,6 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
1
2
  let machina_inspect = require("machina-inspect");
3
+ let machina = require("machina");
2
4
 
3
5
  //#region src/reachability.ts
4
6
  /**
@@ -116,6 +118,216 @@ const toNeverReach = (received, targetState, options) => {
116
118
  };
117
119
  };
118
120
 
121
+ //#endregion
122
+ //#region src/walk.ts
123
+ /**
124
+ * Thrown when walkAll detects an invariant violation. Carries the seed and
125
+ * full input sequence so you can replay the exact walk that failed.
126
+ */
127
+ var WalkFailureError = class extends Error {
128
+ /** Seed that produced this walk — pass to walkAll({ seed }) to replay */
129
+ seed;
130
+ /** Which step (1-indexed handle() call) caused the violation */
131
+ step;
132
+ /** The inputs fired up to and including the failing step */
133
+ inputSequence;
134
+ /** State at the time of violation */
135
+ state;
136
+ /** State before the violating transition */
137
+ previousState;
138
+ /** Composite state at the time of violation */
139
+ compositeState;
140
+ /** The FSM context or client at the time of violation */
141
+ ctx;
142
+ constructor(args) {
143
+ const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause);
144
+ const sequenceSummary = args.inputSequence.map((r, i) => ` ${i + 1}. ${r.input}`).join("\n");
145
+ super(`walkAll invariant violation at step ${args.step} (seed: ${args.seed})\n State: ${args.previousState} → ${args.state}\n Composite: ${args.compositeState}\n Cause: ${causeMessage}\nInput sequence:\n${sequenceSummary}`);
146
+ this.name = "WalkFailureError";
147
+ this.seed = args.seed;
148
+ this.step = args.step;
149
+ this.inputSequence = args.inputSequence;
150
+ this.state = args.state;
151
+ this.previousState = args.previousState;
152
+ this.compositeState = args.compositeState;
153
+ this.ctx = args.ctx;
154
+ }
155
+ };
156
+ /**
157
+ * Creates a seeded PRNG using mulberry32.
158
+ * Returns a function that yields deterministic floats in [0, 1).
159
+ * Two PRNGs with the same seed produce identical sequences.
160
+ */
161
+ const createPrng = (seed) => {
162
+ let s = seed;
163
+ return () => {
164
+ s += 1831565813;
165
+ let t = s;
166
+ t = Math.imul(t ^ t >>> 15, t | 1);
167
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
168
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
169
+ };
170
+ };
171
+ /**
172
+ * Pick a random integer in [0, max) using the given PRNG.
173
+ * Used to select a random index from the inputs array each step.
174
+ */
175
+ const randomInt = (prng, max) => {
176
+ return Math.floor(prng() * max);
177
+ };
178
+ const RESERVED_KEYS = new Set([
179
+ "_onEnter",
180
+ "_onExit",
181
+ "_child",
182
+ "*"
183
+ ]);
184
+ /**
185
+ * Extract all distinct input names from an FSM's states object.
186
+ * Skips reserved keys (_onEnter, _onExit, _child, *) — these are lifecycle
187
+ * hooks and catch-alls, not named inputs you'd fire via handle().
188
+ */
189
+ const extractInputs = (fsm) => {
190
+ const inputs = /* @__PURE__ */ new Set();
191
+ for (const stateObj of Object.values(fsm.states)) for (const key of Object.keys(stateObj)) if (!RESERVED_KEYS.has(key)) inputs.add(key);
192
+ return Array.from(inputs);
193
+ };
194
+ const validateConfig = (config, availableInputs) => {
195
+ const walks = config.walks ?? 100;
196
+ const maxSteps = config.maxSteps ?? 50;
197
+ if (walks < 1) throw new Error(`walkAll: walks must be >= 1, got ${walks}`);
198
+ if (maxSteps < 1) throw new Error(`walkAll: maxSteps must be >= 1, got ${maxSteps}`);
199
+ if (config.include && config.exclude) throw new Error("walkAll: include and exclude are mutually exclusive — use one or the other");
200
+ let inputs;
201
+ if (config.include) {
202
+ const knownInputs = new Set(availableInputs);
203
+ const unknown = config.include.filter((name) => !knownInputs.has(name));
204
+ if (unknown.length > 0) throw new Error(`walkAll: include contains inputs not found in the FSM: ${unknown.join(", ")}`);
205
+ inputs = config.include;
206
+ } else if (config.exclude) {
207
+ const excluded = new Set(config.exclude);
208
+ inputs = availableInputs.filter((k) => !excluded.has(k));
209
+ } else inputs = availableInputs;
210
+ if (inputs.length === 0) throw new Error("walkAll: no inputs available after filtering — check include/exclude config");
211
+ return inputs;
212
+ };
213
+ /**
214
+ * Run property-based walk tests against a live FSM.
215
+ *
216
+ * Throws WalkFailureError on invariant violation. Returns WalkResult on success.
217
+ *
218
+ * @param factory - Called once per walk to create a fresh FSM instance.
219
+ * Each walk gets its own FSM so context mutations don't bleed between walks.
220
+ * @param config - Walk configuration: invariant, walk count, step limit, seed,
221
+ * input filters, payload generators, and optional client factory.
222
+ */
223
+ const walkAll = (factory, config) => {
224
+ const walks = config.walks ?? 100;
225
+ const maxSteps = config.maxSteps ?? 50;
226
+ const probeFsm = factory();
227
+ const inputs = validateConfig(config, extractInputs(probeFsm));
228
+ if (typeof probeFsm.dispose === "function") probeFsm.dispose();
229
+ const seed = config.seed ?? Math.floor(Math.random() * 2 ** 32);
230
+ const prng = createPrng(seed);
231
+ for (let walkIndex = 0; walkIndex < walks; walkIndex++) runSingleWalk(factory, config, inputs, prng, seed, maxSteps);
232
+ return {
233
+ seed,
234
+ walksCompleted: walks
235
+ };
236
+ };
237
+ /**
238
+ * Run one walk: create a fresh FSM, subscribe to events, fire random inputs,
239
+ * check invariant after every transition. Throws WalkFailureError on violation.
240
+ */
241
+ const runSingleWalk = (factory, config, inputs, prng, seed, maxSteps) => {
242
+ const fsm = factory();
243
+ const isBehavioral = fsm[machina.MACHINA_TYPE] === "BehavioralFsm";
244
+ const client = isBehavioral ? config.client ? config.client() : {} : void 0;
245
+ if (isBehavioral && client !== void 0) fsm.transition(client, fsm.initialState);
246
+ const inputSequence = [];
247
+ let currentStep = 0;
248
+ let currentInput = "";
249
+ let violation = null;
250
+ let violationArgs = null;
251
+ const handlingSub = fsm.on("handling", (data) => {
252
+ currentInput = data.inputName;
253
+ });
254
+ const transitionedSub = isBehavioral ? fsm.on("transitioned", (data) => {
255
+ if (violation !== null) return;
256
+ const ctx = data.client ?? client;
257
+ const composite = fsm.compositeState(data.client ?? client);
258
+ const record = inputSequence[inputSequence.length - 1];
259
+ const args = {
260
+ state: data.toState,
261
+ previousState: data.fromState,
262
+ compositeState: composite,
263
+ ctx,
264
+ input: currentInput,
265
+ payload: record?.payload,
266
+ step: currentStep
267
+ };
268
+ try {
269
+ if (config.invariant(args) === false) {
270
+ violation = /* @__PURE__ */ new Error("invariant returned false");
271
+ violationArgs = args;
272
+ }
273
+ } catch (err) {
274
+ violation = err;
275
+ violationArgs = args;
276
+ }
277
+ }) : fsm.on("transitioned", (data) => {
278
+ if (violation !== null) return;
279
+ const ctx = fsm.context;
280
+ const composite = fsm.compositeState();
281
+ const record = inputSequence[inputSequence.length - 1];
282
+ const args = {
283
+ state: data.toState,
284
+ previousState: data.fromState,
285
+ compositeState: composite,
286
+ ctx,
287
+ input: currentInput,
288
+ payload: record?.payload,
289
+ step: currentStep
290
+ };
291
+ try {
292
+ if (config.invariant(args) === false) {
293
+ violation = /* @__PURE__ */ new Error("invariant returned false");
294
+ violationArgs = args;
295
+ }
296
+ } catch (err) {
297
+ violation = err;
298
+ violationArgs = args;
299
+ }
300
+ });
301
+ try {
302
+ for (let step = 1; step <= maxSteps; step++) {
303
+ currentStep = step;
304
+ const inputName = inputs[randomInt(prng, inputs.length)];
305
+ const payload = config.inputs?.[inputName]?.();
306
+ inputSequence.push({
307
+ input: inputName,
308
+ payload
309
+ });
310
+ const extraArgs = payload !== void 0 ? [payload] : [];
311
+ if (isBehavioral && client !== void 0) fsm.handle(client, inputName, ...extraArgs);
312
+ else fsm.handle(inputName, ...extraArgs);
313
+ if (violation !== null) throw new WalkFailureError({
314
+ seed,
315
+ step,
316
+ inputSequence: [...inputSequence],
317
+ state: violationArgs.state,
318
+ previousState: violationArgs.previousState,
319
+ compositeState: violationArgs.compositeState,
320
+ ctx: violationArgs.ctx,
321
+ cause: violation
322
+ });
323
+ }
324
+ } finally {
325
+ handlingSub.off();
326
+ transitionedSub.off();
327
+ if (typeof fsm.dispose === "function") fsm.dispose();
328
+ }
329
+ };
330
+
119
331
  //#endregion
120
332
  //#region src/index.ts
121
333
  expect.extend({
@@ -125,4 +337,6 @@ expect.extend({
125
337
  });
126
338
 
127
339
  //#endregion
340
+ exports.WalkFailureError = WalkFailureError;
341
+ exports.walkAll = walkAll;
128
342
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../src/reachability.ts","../src/matchers.ts","../src/index.ts"],"sourcesContent":["// =============================================================================\n// reachability.ts — BFS reachability check for StateGraph\n//\n// Top-level graph only. Child graphs are not traversed here — users who want\n// to assert reachability inside a child FSM pass the child instance directly\n// to expect().\n//\n// Both \"definite\" and \"possible\" edges are followed. The question is whether\n// the plumbing exists, not whether runtime conditions will always cause it to\n// execute. See design decisions in the build plan for rationale.\n// =============================================================================\n\nimport type { StateGraph } from \"machina-inspect\";\n\n/**\n * Returns true if `to` is reachable from `from` via BFS over the\n * top-level graph's edges. Returns false if either state is missing\n * from the graph, or if no path exists.\n *\n * Treats `from === to` as trivially reachable (every state reaches itself).\n */\nconst canReach = (graph: StateGraph, from: string, to: string): boolean => {\n // Trivially reachable — a state always reaches itself\n if (from === to) {\n return true;\n }\n\n // If the source state isn't in the graph, there's nothing to walk\n if (!(from in graph.nodes)) {\n return false;\n }\n\n const visited = new Set<string>();\n const queue: string[] = [from];\n visited.add(from);\n\n while (queue.length > 0) {\n const current = queue.shift()!;\n const node = graph.nodes[current];\n\n // Missing node is a safety check — shouldn't happen in a well-formed graph\n if (!node) {\n continue;\n }\n\n for (const edge of node.edges) {\n if (edge.to === to) {\n return true;\n }\n if (!visited.has(edge.to) && edge.to in graph.nodes) {\n visited.add(edge.to);\n queue.push(edge.to);\n }\n }\n }\n\n return false;\n};\n\nexport { canReach };\n","// =============================================================================\n// matchers.ts — Custom matcher implementations for machina FSMs\n//\n// Each matcher follows the expect.extend() contract:\n// - Receives the subject as `received`\n// - Returns { pass: boolean, message: () => string }\n// - `message` is always the FAILURE message — jest/vitest call it only\n// when the assertion fails (accounting for .not inversion)\n//\n// Invalid state names produce matcher failures, not throws, so they appear\n// as clean test failures in the runner rather than unhandled exceptions.\n// =============================================================================\n\nimport { buildStateGraph, inspectGraph, type StateGraph } from \"machina-inspect\";\nimport type { MachinaInstance } from \"machina\";\nimport { canReach } from \"./reachability\";\nimport type { ReachabilityOptions } from \"./types\";\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build the StateGraph for the received FSM instance.\n * `buildStateGraph` accepts both config objects and live instances;\n * here we always receive live instances (users pass `expect(fsm)`).\n */\nconst getGraph = (received: MachinaInstance): StateGraph => {\n return buildStateGraph(received);\n};\n\n/**\n * Produce the \"invalid state\" failure message. Used when a state name\n * passed to a matcher does not exist in the graph.\n */\nconst invalidStateMessage = (stateName: string, fsmId: string, graph: StateGraph): string => {\n const available = Object.keys(graph.nodes).join(\", \");\n return `State '${stateName}' does not exist in FSM '${fsmId}'. Available states: ${available}.`;\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toHaveNoUnreachableStates\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when the FSM has no states that are unreachable from initialState.\n * Uses inspectGraph() to run all checks and filters for unreachable-state\n * findings, which includes recursive child graph analysis.\n */\nconst toHaveNoUnreachableStates = (received: MachinaInstance): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const findings = inspectGraph(graph).filter(f => f.type === \"unreachable-state\");\n\n const pass = findings.length === 0;\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toHaveNoUnreachableStates() but all states reachable\n return `Expected FSM '${graph.fsmId}' to have unreachable states, but all states are reachable.`;\n }\n const stateList = findings.map(f => `'${f.states.join(\", \")}'`).join(\", \");\n return `Expected FSM '${graph.fsmId}' to have no unreachable states, but found: ${stateList}.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toAlwaysReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when a path exists from `options.from` to `targetState` in the\n * FSM's top-level graph (BFS over all edges, definite and possible).\n */\nconst toAlwaysReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n const pass = canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toAlwaysReach but path exists\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toNeverReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when NO path exists from `options.from` to `targetState`.\n * The logical inverse of toAlwaysReach.\n */\nconst toNeverReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n // pass: true means \"no path exists\" (the assertion that toNeverReach checks)\n const pass = !canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toNeverReach but no path exists\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n };\n\n return { pass, message };\n};\n\nexport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach };\n","// =============================================================================\n// index.ts — Side-effect entry point for machina-test\n//\n// Importing this module registers all three custom matchers with the global\n// `expect` provided by Jest or Vitest. This is the same pattern used by\n// @testing-library/jest-dom — the import IS the side effect.\n//\n// `expect` must be available as a global at the time this module is evaluated.\n// Put the import in a test file or in a setup file that runs after the test\n// framework's globals are injected.\n// =============================================================================\n\nimport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach } from \"./matchers\";\n\n// Register with the host test runner's global expect\nexpect.extend({ toHaveNoUnreachableStates, toAlwaysReach, toNeverReach });\n\n// Re-export the options type so consumers can import it alongside the package\nexport type { ReachabilityOptions } from \"./types\";\n"],"mappings":";;;;;;;;;;AAqBA,MAAM,YAAY,OAAmB,MAAc,OAAwB;AAEvE,KAAI,SAAS,GACT,QAAO;AAIX,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;CAGX,MAAM,0BAAU,IAAI,KAAa;CACjC,MAAM,QAAkB,CAAC,KAAK;AAC9B,SAAQ,IAAI,KAAK;AAEjB,QAAO,MAAM,SAAS,GAAG;EACrB,MAAM,UAAU,MAAM,OAAO;EAC7B,MAAM,OAAO,MAAM,MAAM;AAGzB,MAAI,CAAC,KACD;AAGJ,OAAK,MAAM,QAAQ,KAAK,OAAO;AAC3B,OAAI,KAAK,OAAO,GACZ,QAAO;AAEX,OAAI,CAAC,QAAQ,IAAI,KAAK,GAAG,IAAI,KAAK,MAAM,MAAM,OAAO;AACjD,YAAQ,IAAI,KAAK,GAAG;AACpB,UAAM,KAAK,KAAK,GAAG;;;;AAK/B,QAAO;;;;;;;;;;AC7BX,MAAM,YAAY,aAA0C;AACxD,6CAAuB,SAAS;;;;;;AAOpC,MAAM,uBAAuB,WAAmB,OAAe,UAA8B;AAEzF,QAAO,UAAU,UAAU,2BAA2B,MAAM,uBAD1C,OAAO,KAAK,MAAM,MAAM,CAAC,KAAK,KAAK,CACwC;;;;;;;AAYjG,MAAM,6BAA6B,aAAwD;CACvF,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,6CAAwB,MAAM,CAAC,QAAO,MAAK,EAAE,SAAS,oBAAoB;CAEhF,MAAM,OAAO,SAAS,WAAW;CAEjC,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM;EAExC,MAAM,YAAY,SAAS,KAAI,MAAK,IAAI,EAAE,OAAO,KAAK,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK;AAC1E,SAAO,iBAAiB,MAAM,MAAM,8CAA8C,UAAU;;AAGhG,QAAO;EAAE;EAAM;EAAS;;;;;;AAW5B,MAAM,iBACF,UACA,aACA,YAC2B;CAC3B,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,EAAE,SAAS;AAGjB,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,MAAM,MAAM,OAAO,MAAM;EAC/D;AAGL,KAAI,EAAE,eAAe,MAAM,OACvB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,aAAa,MAAM,OAAO,MAAM;EACtE;CAGL,MAAM,OAAO,SAAS,OAAO,MAAM,YAAY;CAE/C,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM,0BAA0B,KAAK,QAAQ,YAAY;AAE3F,SAAO,iBAAiB,MAAM,MAAM,yBAAyB,KAAK,QAAQ,YAAY;;AAG1F,QAAO;EAAE;EAAM;EAAS;;;;;;AAW5B,MAAM,gBACF,UACA,aACA,YAC2B;CAC3B,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,EAAE,SAAS;AAGjB,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,MAAM,MAAM,OAAO,MAAM;EAC/D;AAGL,KAAI,EAAE,eAAe,MAAM,OACvB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,aAAa,MAAM,OAAO,MAAM;EACtE;CAIL,MAAM,OAAO,CAAC,SAAS,OAAO,MAAM,YAAY;CAEhD,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM,yBAAyB,KAAK,QAAQ,YAAY;AAE1F,SAAO,iBAAiB,MAAM,MAAM,0BAA0B,KAAK,QAAQ,YAAY;;AAG3F,QAAO;EAAE;EAAM;EAAS;;;;;AC1I5B,OAAO,OAAO;CAAE;CAA2B;CAAe;CAAc,CAAC"}
1
+ {"version":3,"file":"index.cjs","names":["MACHINA_TYPE"],"sources":["../src/reachability.ts","../src/matchers.ts","../src/walk.ts","../src/index.ts"],"sourcesContent":["// =============================================================================\n// reachability.ts — BFS reachability check for StateGraph\n//\n// Top-level graph only. Child graphs are not traversed here — users who want\n// to assert reachability inside a child FSM pass the child instance directly\n// to expect().\n//\n// Both \"definite\" and \"possible\" edges are followed. The question is whether\n// the plumbing exists, not whether runtime conditions will always cause it to\n// execute. See design decisions in the build plan for rationale.\n// =============================================================================\n\nimport type { StateGraph } from \"machina-inspect\";\n\n/**\n * Returns true if `to` is reachable from `from` via BFS over the\n * top-level graph's edges. Returns false if either state is missing\n * from the graph, or if no path exists.\n *\n * Treats `from === to` as trivially reachable (every state reaches itself).\n */\nconst canReach = (graph: StateGraph, from: string, to: string): boolean => {\n // Trivially reachable — a state always reaches itself\n if (from === to) {\n return true;\n }\n\n // If the source state isn't in the graph, there's nothing to walk\n if (!(from in graph.nodes)) {\n return false;\n }\n\n const visited = new Set<string>();\n const queue: string[] = [from];\n visited.add(from);\n\n while (queue.length > 0) {\n const current = queue.shift()!;\n const node = graph.nodes[current];\n\n // Missing node is a safety check — shouldn't happen in a well-formed graph\n if (!node) {\n continue;\n }\n\n for (const edge of node.edges) {\n if (edge.to === to) {\n return true;\n }\n if (!visited.has(edge.to) && edge.to in graph.nodes) {\n visited.add(edge.to);\n queue.push(edge.to);\n }\n }\n }\n\n return false;\n};\n\nexport { canReach };\n","// =============================================================================\n// matchers.ts — Custom matcher implementations for machina FSMs\n//\n// Each matcher follows the expect.extend() contract:\n// - Receives the subject as `received`\n// - Returns { pass: boolean, message: () => string }\n// - `message` is always the FAILURE message — jest/vitest call it only\n// when the assertion fails (accounting for .not inversion)\n//\n// Invalid state names produce matcher failures, not throws, so they appear\n// as clean test failures in the runner rather than unhandled exceptions.\n// =============================================================================\n\nimport { buildStateGraph, inspectGraph, type StateGraph } from \"machina-inspect\";\nimport type { MachinaInstance } from \"machina\";\nimport { canReach } from \"./reachability\";\nimport type { ReachabilityOptions } from \"./types\";\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build the StateGraph for the received FSM instance.\n * `buildStateGraph` accepts both config objects and live instances;\n * here we always receive live instances (users pass `expect(fsm)`).\n */\nconst getGraph = (received: MachinaInstance): StateGraph => {\n return buildStateGraph(received);\n};\n\n/**\n * Produce the \"invalid state\" failure message. Used when a state name\n * passed to a matcher does not exist in the graph.\n */\nconst invalidStateMessage = (stateName: string, fsmId: string, graph: StateGraph): string => {\n const available = Object.keys(graph.nodes).join(\", \");\n return `State '${stateName}' does not exist in FSM '${fsmId}'. Available states: ${available}.`;\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toHaveNoUnreachableStates\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when the FSM has no states that are unreachable from initialState.\n * Uses inspectGraph() to run all checks and filters for unreachable-state\n * findings, which includes recursive child graph analysis.\n */\nconst toHaveNoUnreachableStates = (received: MachinaInstance): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const findings = inspectGraph(graph).filter(f => f.type === \"unreachable-state\");\n\n const pass = findings.length === 0;\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toHaveNoUnreachableStates() but all states reachable\n return `Expected FSM '${graph.fsmId}' to have unreachable states, but all states are reachable.`;\n }\n const stateList = findings.map(f => `'${f.states.join(\", \")}'`).join(\", \");\n return `Expected FSM '${graph.fsmId}' to have no unreachable states, but found: ${stateList}.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toAlwaysReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when a path exists from `options.from` to `targetState` in the\n * FSM's top-level graph (BFS over all edges, definite and possible).\n */\nconst toAlwaysReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n const pass = canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toAlwaysReach but path exists\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toNeverReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when NO path exists from `options.from` to `targetState`.\n * The logical inverse of toAlwaysReach.\n */\nconst toNeverReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n // pass: true means \"no path exists\" (the assertion that toNeverReach checks)\n const pass = !canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toNeverReach but no path exists\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n };\n\n return { pass, message };\n};\n\nexport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach };\n","/* eslint-disable @typescript-eslint/no-explicit-any */\n// =============================================================================\n// walk.ts — Property-based walk testing for machina FSMs\n//\n// `walkAll` runs multiple randomized input sequences against a live FSM,\n// asserting a user-supplied invariant after every transition. Deterministic\n// replay is guaranteed via a seeded PRNG — on failure, the seed and full input\n// sequence are included in the thrown WalkFailureError so you can reproduce\n// the exact walk that uncovered the bug.\n//\n// Design choices:\n// - Factory function (not FSM instance) as first arg: each walk starts fresh\n// - Throws on failure, returns on success: natural test code, no result-type gymnastics\n// - step counts handle() calls, not transitions: maps 1:1 to input sequence index\n// - Inline mulberry32 PRNG: 5 lines, no dependency, deterministic across platforms\n// - Event-driven invariant: handles _onEnter bounces + deferred replays without\n// monkey-patching handle()\n// =============================================================================\n\nimport { MACHINA_TYPE } from \"machina\";\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\n/**\n * Arguments passed to the invariant callback after every transition.\n * Use these to assert that the FSM is in a valid state after each step.\n */\nexport interface InvariantArgs {\n /** State the FSM just transitioned TO */\n state: string;\n /** State the FSM transitioned FROM */\n previousState: string;\n /** Dot-delimited composite state (includes child states if any) */\n compositeState: string;\n /** The FSM context object (Fsm) or client object (BehavioralFsm) */\n ctx: unknown;\n /** The input name that triggered this transition */\n input: string;\n /** The payload passed to handle(), or undefined if no generator was configured */\n payload: unknown;\n /** Which handle() call produced this transition (1-indexed) */\n step: number;\n}\n\n/**\n * Returned by walkAll on success.\n */\nexport interface WalkResult {\n /** The seed used for this run — pass back to walkAll to replay */\n seed: number;\n /** How many walks completed without an invariant violation */\n walksCompleted: number;\n}\n\n/**\n * Configuration for a walkAll run.\n *\n * @typeParam TClient - The client object type for BehavioralFsm walks.\n * Not relevant for Fsm walks — omit `client` in that case.\n */\nexport interface WalkConfig<TClient extends object = object> {\n /**\n * How many independent walks to run. Each walk creates a fresh FSM\n * via the factory and runs up to `maxSteps` random inputs.\n * Must be >= 1. Default: 100.\n */\n walks?: number;\n\n /**\n * Maximum number of handle() calls per walk.\n * Must be >= 1. Default: 50.\n */\n maxSteps?: number;\n\n /**\n * Seed for the PRNG. Providing the same seed guarantees the identical\n * input sequence — use the seed from WalkResult or WalkFailureError to\n * replay a specific walk. Defaults to a random integer.\n */\n seed?: number;\n\n /**\n * Whitelist of input names to use. Mutually exclusive with `exclude`.\n * If provided, only these inputs will be fired during walks.\n */\n include?: string[];\n\n /**\n * Blacklist of input names to exclude. Mutually exclusive with `include`.\n * These inputs are removed from the auto-extracted set before walking.\n */\n exclude?: string[];\n\n /**\n * Payload generators keyed by input name. When walkAll fires input \"X\"\n * and `inputs[\"X\"]` is defined, it calls that function and passes the\n * return value as the payload to handle(). Inputs without generators\n * are fired with no payload.\n */\n inputs?: Record<string, () => unknown>;\n\n /**\n * BehavioralFsm client factory. Required when the FSM produced by the\n * factory is a BehavioralFsm — each walk calls this to get a fresh client.\n * Omit for Fsm walks.\n */\n client?: () => TClient;\n\n /**\n * Called after every transition. Return `false` OR throw to signal a\n * violation — WalkFailureError is constructed automatically in both cases.\n * Return `true` or `undefined` (void) to indicate the invariant passed.\n */\n invariant: (args: InvariantArgs) => boolean | void;\n}\n\n/** A recorded step in the input sequence — used for replay and error reporting */\ninterface InputRecord {\n input: string;\n payload: unknown;\n}\n\n/**\n * Thrown when walkAll detects an invariant violation. Carries the seed and\n * full input sequence so you can replay the exact walk that failed.\n */\nexport class WalkFailureError extends Error {\n /** Seed that produced this walk — pass to walkAll({ seed }) to replay */\n readonly seed: number;\n /** Which step (1-indexed handle() call) caused the violation */\n readonly step: number;\n /** The inputs fired up to and including the failing step */\n readonly inputSequence: InputRecord[];\n /** State at the time of violation */\n readonly state: string;\n /** State before the violating transition */\n readonly previousState: string;\n /** Composite state at the time of violation */\n readonly compositeState: string;\n /** The FSM context or client at the time of violation */\n readonly ctx: unknown;\n\n constructor(args: {\n seed: number;\n step: number;\n inputSequence: InputRecord[];\n state: string;\n previousState: string;\n compositeState: string;\n ctx: unknown;\n cause: unknown;\n }) {\n const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause);\n const sequenceSummary = args.inputSequence\n .map((r, i) => ` ${i + 1}. ${r.input}`)\n .join(\"\\n\");\n\n super(\n `walkAll invariant violation at step ${args.step} (seed: ${args.seed})\\n` +\n ` State: ${args.previousState} → ${args.state}\\n` +\n ` Composite: ${args.compositeState}\\n` +\n ` Cause: ${causeMessage}\\n` +\n `Input sequence:\\n${sequenceSummary}`\n );\n this.name = \"WalkFailureError\";\n this.seed = args.seed;\n this.step = args.step;\n this.inputSequence = args.inputSequence;\n this.state = args.state;\n this.previousState = args.previousState;\n this.compositeState = args.compositeState;\n this.ctx = args.ctx;\n }\n}\n\n// -----------------------------------------------------------------------------\n// Seeded PRNG — mulberry32\n//\n// Passes BigCrush and PractRand. 5 lines, no external dependency.\n// Returns floats in [0, 1) just like Math.random(), enabling drop-in\n// deterministic replacement wherever we'd otherwise use Math.random().\n// -----------------------------------------------------------------------------\n\n/**\n * Creates a seeded PRNG using mulberry32.\n * Returns a function that yields deterministic floats in [0, 1).\n * Two PRNGs with the same seed produce identical sequences.\n */\nexport const createPrng = (seed: number): (() => number) => {\n let s = seed;\n return () => {\n s += 0x6d2b79f5;\n let t = s;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n};\n\n/**\n * Pick a random integer in [0, max) using the given PRNG.\n * Used to select a random index from the inputs array each step.\n */\nexport const randomInt = (prng: () => number, max: number): number => {\n return Math.floor(prng() * max);\n};\n\n// -----------------------------------------------------------------------------\n// Keys that are lifecycle hooks or special directives, not named inputs.\n// Mirrors SpecialStateKeys from machina core types.\n// -----------------------------------------------------------------------------\n\nconst RESERVED_KEYS = new Set([\"_onEnter\", \"_onExit\", \"_child\", \"*\"]);\n\n/**\n * Extract all distinct input names from an FSM's states object.\n * Skips reserved keys (_onEnter, _onExit, _child, *) — these are lifecycle\n * hooks and catch-alls, not named inputs you'd fire via handle().\n */\nexport const extractInputs = (fsm: {\n states: Record<string, Record<string, unknown>>;\n}): string[] => {\n const inputs = new Set<string>();\n for (const stateObj of Object.values(fsm.states)) {\n for (const key of Object.keys(stateObj)) {\n if (!RESERVED_KEYS.has(key)) {\n inputs.add(key);\n }\n }\n }\n return Array.from(inputs);\n};\n\n// -----------------------------------------------------------------------------\n// Config validation\n// -----------------------------------------------------------------------------\n\nconst validateConfig = (config: WalkConfig, availableInputs: string[]): string[] => {\n const walks = config.walks ?? 100;\n const maxSteps = config.maxSteps ?? 50;\n\n if (walks < 1) {\n throw new Error(`walkAll: walks must be >= 1, got ${walks}`);\n }\n\n if (maxSteps < 1) {\n throw new Error(`walkAll: maxSteps must be >= 1, got ${maxSteps}`);\n }\n\n if (config.include && config.exclude) {\n throw new Error(\n \"walkAll: include and exclude are mutually exclusive — use one or the other\"\n );\n }\n\n let inputs: string[];\n\n if (config.include) {\n // Catch typos and misconfigurations before walking — an include entry\n // that doesn't exist in the FSM is almost certainly a bug at the call site.\n const knownInputs = new Set(availableInputs);\n const unknown = config.include.filter(name => !knownInputs.has(name));\n if (unknown.length > 0) {\n throw new Error(\n `walkAll: include contains inputs not found in the FSM: ${unknown.join(\", \")}`\n );\n }\n inputs = config.include;\n } else if (config.exclude) {\n const excluded = new Set(config.exclude);\n inputs = availableInputs.filter(k => !excluded.has(k));\n } else {\n inputs = availableInputs;\n }\n\n if (inputs.length === 0) {\n throw new Error(\n \"walkAll: no inputs available after filtering — check include/exclude config\"\n );\n }\n\n return inputs;\n};\n\n// -----------------------------------------------------------------------------\n// Walk engine\n// -----------------------------------------------------------------------------\n\n/**\n * Run property-based walk tests against a live FSM.\n *\n * Throws WalkFailureError on invariant violation. Returns WalkResult on success.\n *\n * @param factory - Called once per walk to create a fresh FSM instance.\n * Each walk gets its own FSM so context mutations don't bleed between walks.\n * @param config - Walk configuration: invariant, walk count, step limit, seed,\n * input filters, payload generators, and optional client factory.\n */\nexport const walkAll = <TClient extends object = object>(\n factory: () => any,\n config: WalkConfig<TClient>\n): WalkResult => {\n const walks = config.walks ?? 100;\n const maxSteps = config.maxSteps ?? 50;\n\n // Build the input list from the first FSM instance — we only need this\n // for validation, not for actual walking (each walk creates its own FSM).\n const probeFsm = factory();\n const allInputs = extractInputs(probeFsm);\n const inputs = validateConfig(config, allInputs);\n\n // Dispose the probe FSM if possible — it was only needed for input extraction.\n if (typeof probeFsm.dispose === \"function\") {\n probeFsm.dispose();\n }\n\n const seed = config.seed ?? Math.floor(Math.random() * 2 ** 32);\n const prng = createPrng(seed);\n\n for (let walkIndex = 0; walkIndex < walks; walkIndex++) {\n runSingleWalk(factory, config, inputs, prng, seed, maxSteps);\n }\n\n return { seed, walksCompleted: walks };\n};\n\n/**\n * Run one walk: create a fresh FSM, subscribe to events, fire random inputs,\n * check invariant after every transition. Throws WalkFailureError on violation.\n */\nconst runSingleWalk = <TClient extends object>(\n factory: () => any,\n config: WalkConfig<TClient>,\n inputs: string[],\n prng: () => number,\n seed: number,\n maxSteps: number\n): void => {\n const fsm = factory();\n const isBehavioral = fsm[MACHINA_TYPE] === \"BehavioralFsm\";\n\n // For BehavioralFsm: create a client and track it separately.\n // For Fsm: the client IS the fsm (context lives on the fsm).\n const client: TClient | undefined = isBehavioral\n ? config.client\n ? config.client()\n : ({} as TClient)\n : undefined;\n\n // Initialize BehavioralFsm client by transitioning into initialState.\n // Fsm eagerly initializes in its constructor — no action needed.\n if (isBehavioral && client !== undefined) {\n fsm.transition(client, fsm.initialState);\n }\n\n const inputSequence: InputRecord[] = [];\n let currentStep = 0;\n let currentInput = \"\";\n let violation: unknown = null;\n let violationArgs: InvariantArgs | null = null;\n\n // Track \"current input\" via the handling event, which fires before dispatch.\n // This is the input that caused any subsequent transitioned events — including\n // those from _onEnter bounces, where handling does NOT re-fire.\n // Both Fsm and BehavioralFsm expose inputName on this event, so no branching needed.\n const handlingSub = fsm.on(\"handling\", (data: any) => {\n currentInput = data.inputName;\n });\n\n // The transitioned event fires after every state change, including _onEnter\n // bounces. We run the invariant here so we catch all transition types.\n const transitionedSub = isBehavioral\n ? fsm.on(\"transitioned\", (data: any) => {\n if (violation !== null) {\n return;\n }\n const ctx = data.client ?? client;\n const composite = fsm.compositeState(data.client ?? client);\n const record = inputSequence[inputSequence.length - 1];\n const args: InvariantArgs = {\n state: data.toState,\n previousState: data.fromState,\n compositeState: composite,\n ctx,\n input: currentInput,\n payload: record?.payload,\n step: currentStep,\n };\n try {\n const result = config.invariant(args);\n // Returning false is an explicit violation signal (same as throwing).\n // undefined/void and true both mean \"invariant holds\".\n if (result === false) {\n violation = new Error(\"invariant returned false\");\n violationArgs = args;\n }\n } catch (err) {\n violation = err;\n violationArgs = args;\n }\n })\n : fsm.on(\"transitioned\", (data: any) => {\n if (violation !== null) {\n return;\n }\n // For Fsm, read context from fsm.context (Task 1 made this public)\n const ctx = fsm.context;\n const composite = fsm.compositeState();\n const record = inputSequence[inputSequence.length - 1];\n const args: InvariantArgs = {\n state: data.toState,\n previousState: data.fromState,\n compositeState: composite,\n ctx,\n input: currentInput,\n payload: record?.payload,\n step: currentStep,\n };\n try {\n const result = config.invariant(args);\n // Returning false is an explicit violation signal (same as throwing).\n // undefined/void and true both mean \"invariant holds\".\n if (result === false) {\n violation = new Error(\"invariant returned false\");\n violationArgs = args;\n }\n } catch (err) {\n violation = err;\n violationArgs = args;\n }\n });\n\n try {\n for (let step = 1; step <= maxSteps; step++) {\n currentStep = step;\n const inputName = inputs[randomInt(prng, inputs.length)];\n const payload = config.inputs?.[inputName]?.();\n inputSequence.push({ input: inputName, payload });\n\n // Only spread the payload arg when a generator produced one — handlers\n // with no generator shouldn't receive a spurious undefined argument,\n // which could mask bugs in handlers that check arguments.length.\n const extraArgs = payload !== undefined ? [payload] : [];\n if (isBehavioral && client !== undefined) {\n fsm.handle(client, inputName, ...extraArgs);\n } else {\n fsm.handle(inputName, ...extraArgs);\n }\n\n if (violation !== null) {\n throw new WalkFailureError({\n seed,\n step,\n inputSequence: [...inputSequence],\n state: violationArgs!.state,\n previousState: violationArgs!.previousState,\n compositeState: violationArgs!.compositeState,\n ctx: violationArgs!.ctx,\n cause: violation,\n });\n }\n }\n } finally {\n // Always unsubscribe, even on failure — prevents listener leaks between walks\n handlingSub.off();\n transitionedSub.off();\n\n if (typeof fsm.dispose === \"function\") {\n fsm.dispose();\n }\n }\n};\n","// =============================================================================\n// index.ts — Side-effect entry point for machina-test\n//\n// Importing this module registers all three custom matchers with the global\n// `expect` provided by Jest or Vitest. This is the same pattern used by\n// @testing-library/jest-dom — the import IS the side effect.\n//\n// `expect` must be available as a global at the time this module is evaluated.\n// Put the import in a test file or in a setup file that runs after the test\n// framework's globals are injected.\n// =============================================================================\n\nimport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach } from \"./matchers\";\n\n// Register with the host test runner's global expect\nexpect.extend({ toHaveNoUnreachableStates, toAlwaysReach, toNeverReach });\n\n// Re-export the options type so consumers can import it alongside the package\nexport type { ReachabilityOptions } from \"./types\";\n\n// walkAll is a standalone function — no side-effect registration needed.\n// Import it directly: import { walkAll } from \"machina-test\"\nexport { walkAll } from \"./walk\";\nexport type { WalkConfig, InvariantArgs, WalkResult } from \"./walk\";\nexport { WalkFailureError } from \"./walk\";\n"],"mappings":"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"}
package/dist/index.d.cts CHANGED
@@ -46,5 +46,136 @@ declare module "vitest" {
46
46
  }
47
47
  } //# sourceMappingURL=types.d.ts.map
48
48
  //#endregion
49
- export { type ReachabilityOptions };
49
+ //#region src/walk.d.ts
50
+ /**
51
+ * Arguments passed to the invariant callback after every transition.
52
+ * Use these to assert that the FSM is in a valid state after each step.
53
+ */
54
+ interface InvariantArgs {
55
+ /** State the FSM just transitioned TO */
56
+ state: string;
57
+ /** State the FSM transitioned FROM */
58
+ previousState: string;
59
+ /** Dot-delimited composite state (includes child states if any) */
60
+ compositeState: string;
61
+ /** The FSM context object (Fsm) or client object (BehavioralFsm) */
62
+ ctx: unknown;
63
+ /** The input name that triggered this transition */
64
+ input: string;
65
+ /** The payload passed to handle(), or undefined if no generator was configured */
66
+ payload: unknown;
67
+ /** Which handle() call produced this transition (1-indexed) */
68
+ step: number;
69
+ }
70
+ /**
71
+ * Returned by walkAll on success.
72
+ */
73
+ interface WalkResult {
74
+ /** The seed used for this run — pass back to walkAll to replay */
75
+ seed: number;
76
+ /** How many walks completed without an invariant violation */
77
+ walksCompleted: number;
78
+ }
79
+ /**
80
+ * Configuration for a walkAll run.
81
+ *
82
+ * @typeParam TClient - The client object type for BehavioralFsm walks.
83
+ * Not relevant for Fsm walks — omit `client` in that case.
84
+ */
85
+ interface WalkConfig<TClient extends object = object> {
86
+ /**
87
+ * How many independent walks to run. Each walk creates a fresh FSM
88
+ * via the factory and runs up to `maxSteps` random inputs.
89
+ * Must be >= 1. Default: 100.
90
+ */
91
+ walks?: number;
92
+ /**
93
+ * Maximum number of handle() calls per walk.
94
+ * Must be >= 1. Default: 50.
95
+ */
96
+ maxSteps?: number;
97
+ /**
98
+ * Seed for the PRNG. Providing the same seed guarantees the identical
99
+ * input sequence — use the seed from WalkResult or WalkFailureError to
100
+ * replay a specific walk. Defaults to a random integer.
101
+ */
102
+ seed?: number;
103
+ /**
104
+ * Whitelist of input names to use. Mutually exclusive with `exclude`.
105
+ * If provided, only these inputs will be fired during walks.
106
+ */
107
+ include?: string[];
108
+ /**
109
+ * Blacklist of input names to exclude. Mutually exclusive with `include`.
110
+ * These inputs are removed from the auto-extracted set before walking.
111
+ */
112
+ exclude?: string[];
113
+ /**
114
+ * Payload generators keyed by input name. When walkAll fires input "X"
115
+ * and `inputs["X"]` is defined, it calls that function and passes the
116
+ * return value as the payload to handle(). Inputs without generators
117
+ * are fired with no payload.
118
+ */
119
+ inputs?: Record<string, () => unknown>;
120
+ /**
121
+ * BehavioralFsm client factory. Required when the FSM produced by the
122
+ * factory is a BehavioralFsm — each walk calls this to get a fresh client.
123
+ * Omit for Fsm walks.
124
+ */
125
+ client?: () => TClient;
126
+ /**
127
+ * Called after every transition. Return `false` OR throw to signal a
128
+ * violation — WalkFailureError is constructed automatically in both cases.
129
+ * Return `true` or `undefined` (void) to indicate the invariant passed.
130
+ */
131
+ invariant: (args: InvariantArgs) => boolean | void;
132
+ }
133
+ /** A recorded step in the input sequence — used for replay and error reporting */
134
+ interface InputRecord {
135
+ input: string;
136
+ payload: unknown;
137
+ }
138
+ /**
139
+ * Thrown when walkAll detects an invariant violation. Carries the seed and
140
+ * full input sequence so you can replay the exact walk that failed.
141
+ */
142
+ declare class WalkFailureError extends Error {
143
+ /** Seed that produced this walk — pass to walkAll({ seed }) to replay */
144
+ readonly seed: number;
145
+ /** Which step (1-indexed handle() call) caused the violation */
146
+ readonly step: number;
147
+ /** The inputs fired up to and including the failing step */
148
+ readonly inputSequence: InputRecord[];
149
+ /** State at the time of violation */
150
+ readonly state: string;
151
+ /** State before the violating transition */
152
+ readonly previousState: string;
153
+ /** Composite state at the time of violation */
154
+ readonly compositeState: string;
155
+ /** The FSM context or client at the time of violation */
156
+ readonly ctx: unknown;
157
+ constructor(args: {
158
+ seed: number;
159
+ step: number;
160
+ inputSequence: InputRecord[];
161
+ state: string;
162
+ previousState: string;
163
+ compositeState: string;
164
+ ctx: unknown;
165
+ cause: unknown;
166
+ });
167
+ }
168
+ /**
169
+ * Run property-based walk tests against a live FSM.
170
+ *
171
+ * Throws WalkFailureError on invariant violation. Returns WalkResult on success.
172
+ *
173
+ * @param factory - Called once per walk to create a fresh FSM instance.
174
+ * Each walk gets its own FSM so context mutations don't bleed between walks.
175
+ * @param config - Walk configuration: invariant, walk count, step limit, seed,
176
+ * input filters, payload generators, and optional client factory.
177
+ */
178
+ declare const walkAll: <TClient extends object = object>(factory: () => any, config: WalkConfig<TClient>) => WalkResult;
179
+ //#endregion
180
+ export { type InvariantArgs, type ReachabilityOptions, type WalkConfig, WalkFailureError, type WalkResult, walkAll };
50
181
  //# sourceMappingURL=index.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts"],"mappings":";UAWiB,mBAAA;EAAA;EAEb,IAAA;AAAA;AAAA,QAII,MAAA;EAAA,UAEM,IAAA;IAAA,UACI,QAAA;MANjB;;;;;MAYW,yBAAA,IAA6B,CAAA;MAYoC;;;;MANjE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;MAZ5D;;;;MAkBN,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAA;EAAA;AAAA;AAAA;EAAA,UAQ/D,SAAA;IARW;;;;;IAcjB,yBAAA,IAA6B,CAAA;IAdyC;AAAA;;;IAoBtE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAtB;;;;IAM5C,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;EAAA;AAAA"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/walk.ts"],"mappings":";UAWiB,mBAAA;EAAA;EAEb,IAAA;AAAA;AAAA,QAII,MAAA;EAAA,UAEM,IAAA;IAAA,UACI,QAAA;MANjB;;;;;MAYW,yBAAA,IAA6B,CAAA;MAYoC;;;;MANjE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;MAZ5D;;;;MAkBN,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAA;EAAA;AAAA;AAAA;EAAA,UAQ/D,SAAA;IARW;;;;;IAcjB,yBAAA,IAA6B,CAAA;IAdyC;AAAA;;;IAoBtE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAtB;;;;IAM5C,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;EAAA;AAAA;;;;AArDzE;;;UCkBiB,aAAA;EDhBT;ECkBJ,KAAA;EDjBH;ECmBG,aAAA;EDPqC;ECSrC,cAAA;EDH0E;ECK1E,GAAA;EDCyE;ECCzE,KAAA;EDD0E;ECG1E,OAAA;EDtBU;ECwBV,IAAA;AAAA;;;;UAMa,UAAA;EDjBuC;ECmBpD,IAAA;EDnB0E;ECqB1E,cAAA;AAAA;;;;;;;UASa,UAAA;EDxB6D;;;;;EC8B1E,KAAA;EDJqE;;;;ECUrE,QAAA;EDtBI;;;;;EC6BJ,IAAA;EDvBsE;;;;EC6BtE,OAAA;EDvBqE;;;;EC6BrE,OAAA;;;AAhEJ;;;;EAwEI,MAAA,GAAS,MAAA;EApET;;;;;EA2EA,MAAA,SAAe,OAAA;EAjEX;;AAMR;;;EAkEI,SAAA,GAAY,IAAA,EAAM,aAAA;AAAA;AArDtB;AAAA,UAyDU,WAAA;EACN,KAAA;EACA,OAAA;AAAA;;;;;cAOS,gBAAA,SAAyB,KAAA;EAtDlC;EAAA,SAwDS,IAAA;EA3CT;EAAA,SA6CS,IAAA;EA/BT;EAAA,SAiCS,aAAA,EAAe,WAAA;EA1BxB;EAAA,SA4BS,KAAA;EArBT;EAAA,SAuBS,aAAA;EAvBG;EAAA,SAyBH,cAAA;EAzBsB;EAAA,SA2BtB,GAAA;cAEG,IAAA;IACR,IAAA;IACA,IAAA;IACA,aAAA,EAAe,WAAA;IACf,KAAA;IACA,aAAA;IACA,cAAA;IACA,GAAA;IACA,KAAA;EAAA;AAAA;;;;;AAoJR;;;;;;cAAa,OAAA,oCACT,OAAA,aACA,MAAA,EAAQ,UAAA,CAAW,OAAA,MACpB,UAAA"}
package/dist/index.d.mts CHANGED
@@ -46,5 +46,136 @@ declare module "vitest" {
46
46
  }
47
47
  } //# sourceMappingURL=types.d.ts.map
48
48
  //#endregion
49
- export { type ReachabilityOptions };
49
+ //#region src/walk.d.ts
50
+ /**
51
+ * Arguments passed to the invariant callback after every transition.
52
+ * Use these to assert that the FSM is in a valid state after each step.
53
+ */
54
+ interface InvariantArgs {
55
+ /** State the FSM just transitioned TO */
56
+ state: string;
57
+ /** State the FSM transitioned FROM */
58
+ previousState: string;
59
+ /** Dot-delimited composite state (includes child states if any) */
60
+ compositeState: string;
61
+ /** The FSM context object (Fsm) or client object (BehavioralFsm) */
62
+ ctx: unknown;
63
+ /** The input name that triggered this transition */
64
+ input: string;
65
+ /** The payload passed to handle(), or undefined if no generator was configured */
66
+ payload: unknown;
67
+ /** Which handle() call produced this transition (1-indexed) */
68
+ step: number;
69
+ }
70
+ /**
71
+ * Returned by walkAll on success.
72
+ */
73
+ interface WalkResult {
74
+ /** The seed used for this run — pass back to walkAll to replay */
75
+ seed: number;
76
+ /** How many walks completed without an invariant violation */
77
+ walksCompleted: number;
78
+ }
79
+ /**
80
+ * Configuration for a walkAll run.
81
+ *
82
+ * @typeParam TClient - The client object type for BehavioralFsm walks.
83
+ * Not relevant for Fsm walks — omit `client` in that case.
84
+ */
85
+ interface WalkConfig<TClient extends object = object> {
86
+ /**
87
+ * How many independent walks to run. Each walk creates a fresh FSM
88
+ * via the factory and runs up to `maxSteps` random inputs.
89
+ * Must be >= 1. Default: 100.
90
+ */
91
+ walks?: number;
92
+ /**
93
+ * Maximum number of handle() calls per walk.
94
+ * Must be >= 1. Default: 50.
95
+ */
96
+ maxSteps?: number;
97
+ /**
98
+ * Seed for the PRNG. Providing the same seed guarantees the identical
99
+ * input sequence — use the seed from WalkResult or WalkFailureError to
100
+ * replay a specific walk. Defaults to a random integer.
101
+ */
102
+ seed?: number;
103
+ /**
104
+ * Whitelist of input names to use. Mutually exclusive with `exclude`.
105
+ * If provided, only these inputs will be fired during walks.
106
+ */
107
+ include?: string[];
108
+ /**
109
+ * Blacklist of input names to exclude. Mutually exclusive with `include`.
110
+ * These inputs are removed from the auto-extracted set before walking.
111
+ */
112
+ exclude?: string[];
113
+ /**
114
+ * Payload generators keyed by input name. When walkAll fires input "X"
115
+ * and `inputs["X"]` is defined, it calls that function and passes the
116
+ * return value as the payload to handle(). Inputs without generators
117
+ * are fired with no payload.
118
+ */
119
+ inputs?: Record<string, () => unknown>;
120
+ /**
121
+ * BehavioralFsm client factory. Required when the FSM produced by the
122
+ * factory is a BehavioralFsm — each walk calls this to get a fresh client.
123
+ * Omit for Fsm walks.
124
+ */
125
+ client?: () => TClient;
126
+ /**
127
+ * Called after every transition. Return `false` OR throw to signal a
128
+ * violation — WalkFailureError is constructed automatically in both cases.
129
+ * Return `true` or `undefined` (void) to indicate the invariant passed.
130
+ */
131
+ invariant: (args: InvariantArgs) => boolean | void;
132
+ }
133
+ /** A recorded step in the input sequence — used for replay and error reporting */
134
+ interface InputRecord {
135
+ input: string;
136
+ payload: unknown;
137
+ }
138
+ /**
139
+ * Thrown when walkAll detects an invariant violation. Carries the seed and
140
+ * full input sequence so you can replay the exact walk that failed.
141
+ */
142
+ declare class WalkFailureError extends Error {
143
+ /** Seed that produced this walk — pass to walkAll({ seed }) to replay */
144
+ readonly seed: number;
145
+ /** Which step (1-indexed handle() call) caused the violation */
146
+ readonly step: number;
147
+ /** The inputs fired up to and including the failing step */
148
+ readonly inputSequence: InputRecord[];
149
+ /** State at the time of violation */
150
+ readonly state: string;
151
+ /** State before the violating transition */
152
+ readonly previousState: string;
153
+ /** Composite state at the time of violation */
154
+ readonly compositeState: string;
155
+ /** The FSM context or client at the time of violation */
156
+ readonly ctx: unknown;
157
+ constructor(args: {
158
+ seed: number;
159
+ step: number;
160
+ inputSequence: InputRecord[];
161
+ state: string;
162
+ previousState: string;
163
+ compositeState: string;
164
+ ctx: unknown;
165
+ cause: unknown;
166
+ });
167
+ }
168
+ /**
169
+ * Run property-based walk tests against a live FSM.
170
+ *
171
+ * Throws WalkFailureError on invariant violation. Returns WalkResult on success.
172
+ *
173
+ * @param factory - Called once per walk to create a fresh FSM instance.
174
+ * Each walk gets its own FSM so context mutations don't bleed between walks.
175
+ * @param config - Walk configuration: invariant, walk count, step limit, seed,
176
+ * input filters, payload generators, and optional client factory.
177
+ */
178
+ declare const walkAll: <TClient extends object = object>(factory: () => any, config: WalkConfig<TClient>) => WalkResult;
179
+ //#endregion
180
+ export { type InvariantArgs, type ReachabilityOptions, type WalkConfig, WalkFailureError, type WalkResult, walkAll };
50
181
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";UAWiB,mBAAA;EAAA;EAEb,IAAA;AAAA;AAAA,QAII,MAAA;EAAA,UAEM,IAAA;IAAA,UACI,QAAA;MANjB;;;;;MAYW,yBAAA,IAA6B,CAAA;MAYoC;;;;MANjE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;MAZ5D;;;;MAkBN,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAA;EAAA;AAAA;AAAA;EAAA,UAQ/D,SAAA;IARW;;;;;IAcjB,yBAAA,IAA6B,CAAA;IAdyC;AAAA;;;IAoBtE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAtB;;;;IAM5C,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;EAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/walk.ts"],"mappings":";UAWiB,mBAAA;EAAA;EAEb,IAAA;AAAA;AAAA,QAII,MAAA;EAAA,UAEM,IAAA;IAAA,UACI,QAAA;MANjB;;;;;MAYW,yBAAA,IAA6B,CAAA;MAYoC;;;;MANjE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;MAZ5D;;;;MAkBN,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAA;EAAA;AAAA;AAAA;EAAA,UAQ/D,SAAA;IARW;;;;;IAcjB,yBAAA,IAA6B,CAAA;IAdyC;AAAA;;;IAoBtE,aAAA,CAAc,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;IAAtB;;;;IAM5C,YAAA,CAAa,WAAA,UAAqB,OAAA,EAAS,mBAAA,GAAsB,CAAA;EAAA;AAAA;;;;AArDzE;;;UCkBiB,aAAA;EDhBT;ECkBJ,KAAA;EDjBH;ECmBG,aAAA;EDPqC;ECSrC,cAAA;EDH0E;ECK1E,GAAA;EDCyE;ECCzE,KAAA;EDD0E;ECG1E,OAAA;EDtBU;ECwBV,IAAA;AAAA;;;;UAMa,UAAA;EDjBuC;ECmBpD,IAAA;EDnB0E;ECqB1E,cAAA;AAAA;;;;;;;UASa,UAAA;EDxB6D;;;;;EC8B1E,KAAA;EDJqE;;;;ECUrE,QAAA;EDtBI;;;;;EC6BJ,IAAA;EDvBsE;;;;EC6BtE,OAAA;EDvBqE;;;;EC6BrE,OAAA;;;AAhEJ;;;;EAwEI,MAAA,GAAS,MAAA;EApET;;;;;EA2EA,MAAA,SAAe,OAAA;EAjEX;;AAMR;;;EAkEI,SAAA,GAAY,IAAA,EAAM,aAAA;AAAA;AArDtB;AAAA,UAyDU,WAAA;EACN,KAAA;EACA,OAAA;AAAA;;;;;cAOS,gBAAA,SAAyB,KAAA;EAtDlC;EAAA,SAwDS,IAAA;EA3CT;EAAA,SA6CS,IAAA;EA/BT;EAAA,SAiCS,aAAA,EAAe,WAAA;EA1BxB;EAAA,SA4BS,KAAA;EArBT;EAAA,SAuBS,aAAA;EAvBG;EAAA,SAyBH,cAAA;EAzBsB;EAAA,SA2BtB,GAAA;cAEG,IAAA;IACR,IAAA;IACA,IAAA;IACA,aAAA,EAAe,WAAA;IACf,KAAA;IACA,aAAA;IACA,cAAA;IACA,GAAA;IACA,KAAA;EAAA;AAAA;;;;;AAoJR;;;;;;cAAa,OAAA,oCACT,OAAA,aACA,MAAA,EAAQ,UAAA,CAAW,OAAA,MACpB,UAAA"}
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { buildStateGraph, inspectGraph } from "machina-inspect";
2
+ import { MACHINA_TYPE } from "machina";
2
3
 
3
4
  //#region src/reachability.ts
4
5
  /**
@@ -116,6 +117,216 @@ const toNeverReach = (received, targetState, options) => {
116
117
  };
117
118
  };
118
119
 
120
+ //#endregion
121
+ //#region src/walk.ts
122
+ /**
123
+ * Thrown when walkAll detects an invariant violation. Carries the seed and
124
+ * full input sequence so you can replay the exact walk that failed.
125
+ */
126
+ var WalkFailureError = class extends Error {
127
+ /** Seed that produced this walk — pass to walkAll({ seed }) to replay */
128
+ seed;
129
+ /** Which step (1-indexed handle() call) caused the violation */
130
+ step;
131
+ /** The inputs fired up to and including the failing step */
132
+ inputSequence;
133
+ /** State at the time of violation */
134
+ state;
135
+ /** State before the violating transition */
136
+ previousState;
137
+ /** Composite state at the time of violation */
138
+ compositeState;
139
+ /** The FSM context or client at the time of violation */
140
+ ctx;
141
+ constructor(args) {
142
+ const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause);
143
+ const sequenceSummary = args.inputSequence.map((r, i) => ` ${i + 1}. ${r.input}`).join("\n");
144
+ super(`walkAll invariant violation at step ${args.step} (seed: ${args.seed})\n State: ${args.previousState} → ${args.state}\n Composite: ${args.compositeState}\n Cause: ${causeMessage}\nInput sequence:\n${sequenceSummary}`);
145
+ this.name = "WalkFailureError";
146
+ this.seed = args.seed;
147
+ this.step = args.step;
148
+ this.inputSequence = args.inputSequence;
149
+ this.state = args.state;
150
+ this.previousState = args.previousState;
151
+ this.compositeState = args.compositeState;
152
+ this.ctx = args.ctx;
153
+ }
154
+ };
155
+ /**
156
+ * Creates a seeded PRNG using mulberry32.
157
+ * Returns a function that yields deterministic floats in [0, 1).
158
+ * Two PRNGs with the same seed produce identical sequences.
159
+ */
160
+ const createPrng = (seed) => {
161
+ let s = seed;
162
+ return () => {
163
+ s += 1831565813;
164
+ let t = s;
165
+ t = Math.imul(t ^ t >>> 15, t | 1);
166
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
167
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
168
+ };
169
+ };
170
+ /**
171
+ * Pick a random integer in [0, max) using the given PRNG.
172
+ * Used to select a random index from the inputs array each step.
173
+ */
174
+ const randomInt = (prng, max) => {
175
+ return Math.floor(prng() * max);
176
+ };
177
+ const RESERVED_KEYS = new Set([
178
+ "_onEnter",
179
+ "_onExit",
180
+ "_child",
181
+ "*"
182
+ ]);
183
+ /**
184
+ * Extract all distinct input names from an FSM's states object.
185
+ * Skips reserved keys (_onEnter, _onExit, _child, *) — these are lifecycle
186
+ * hooks and catch-alls, not named inputs you'd fire via handle().
187
+ */
188
+ const extractInputs = (fsm) => {
189
+ const inputs = /* @__PURE__ */ new Set();
190
+ for (const stateObj of Object.values(fsm.states)) for (const key of Object.keys(stateObj)) if (!RESERVED_KEYS.has(key)) inputs.add(key);
191
+ return Array.from(inputs);
192
+ };
193
+ const validateConfig = (config, availableInputs) => {
194
+ const walks = config.walks ?? 100;
195
+ const maxSteps = config.maxSteps ?? 50;
196
+ if (walks < 1) throw new Error(`walkAll: walks must be >= 1, got ${walks}`);
197
+ if (maxSteps < 1) throw new Error(`walkAll: maxSteps must be >= 1, got ${maxSteps}`);
198
+ if (config.include && config.exclude) throw new Error("walkAll: include and exclude are mutually exclusive — use one or the other");
199
+ let inputs;
200
+ if (config.include) {
201
+ const knownInputs = new Set(availableInputs);
202
+ const unknown = config.include.filter((name) => !knownInputs.has(name));
203
+ if (unknown.length > 0) throw new Error(`walkAll: include contains inputs not found in the FSM: ${unknown.join(", ")}`);
204
+ inputs = config.include;
205
+ } else if (config.exclude) {
206
+ const excluded = new Set(config.exclude);
207
+ inputs = availableInputs.filter((k) => !excluded.has(k));
208
+ } else inputs = availableInputs;
209
+ if (inputs.length === 0) throw new Error("walkAll: no inputs available after filtering — check include/exclude config");
210
+ return inputs;
211
+ };
212
+ /**
213
+ * Run property-based walk tests against a live FSM.
214
+ *
215
+ * Throws WalkFailureError on invariant violation. Returns WalkResult on success.
216
+ *
217
+ * @param factory - Called once per walk to create a fresh FSM instance.
218
+ * Each walk gets its own FSM so context mutations don't bleed between walks.
219
+ * @param config - Walk configuration: invariant, walk count, step limit, seed,
220
+ * input filters, payload generators, and optional client factory.
221
+ */
222
+ const walkAll = (factory, config) => {
223
+ const walks = config.walks ?? 100;
224
+ const maxSteps = config.maxSteps ?? 50;
225
+ const probeFsm = factory();
226
+ const inputs = validateConfig(config, extractInputs(probeFsm));
227
+ if (typeof probeFsm.dispose === "function") probeFsm.dispose();
228
+ const seed = config.seed ?? Math.floor(Math.random() * 2 ** 32);
229
+ const prng = createPrng(seed);
230
+ for (let walkIndex = 0; walkIndex < walks; walkIndex++) runSingleWalk(factory, config, inputs, prng, seed, maxSteps);
231
+ return {
232
+ seed,
233
+ walksCompleted: walks
234
+ };
235
+ };
236
+ /**
237
+ * Run one walk: create a fresh FSM, subscribe to events, fire random inputs,
238
+ * check invariant after every transition. Throws WalkFailureError on violation.
239
+ */
240
+ const runSingleWalk = (factory, config, inputs, prng, seed, maxSteps) => {
241
+ const fsm = factory();
242
+ const isBehavioral = fsm[MACHINA_TYPE] === "BehavioralFsm";
243
+ const client = isBehavioral ? config.client ? config.client() : {} : void 0;
244
+ if (isBehavioral && client !== void 0) fsm.transition(client, fsm.initialState);
245
+ const inputSequence = [];
246
+ let currentStep = 0;
247
+ let currentInput = "";
248
+ let violation = null;
249
+ let violationArgs = null;
250
+ const handlingSub = fsm.on("handling", (data) => {
251
+ currentInput = data.inputName;
252
+ });
253
+ const transitionedSub = isBehavioral ? fsm.on("transitioned", (data) => {
254
+ if (violation !== null) return;
255
+ const ctx = data.client ?? client;
256
+ const composite = fsm.compositeState(data.client ?? client);
257
+ const record = inputSequence[inputSequence.length - 1];
258
+ const args = {
259
+ state: data.toState,
260
+ previousState: data.fromState,
261
+ compositeState: composite,
262
+ ctx,
263
+ input: currentInput,
264
+ payload: record?.payload,
265
+ step: currentStep
266
+ };
267
+ try {
268
+ if (config.invariant(args) === false) {
269
+ violation = /* @__PURE__ */ new Error("invariant returned false");
270
+ violationArgs = args;
271
+ }
272
+ } catch (err) {
273
+ violation = err;
274
+ violationArgs = args;
275
+ }
276
+ }) : fsm.on("transitioned", (data) => {
277
+ if (violation !== null) return;
278
+ const ctx = fsm.context;
279
+ const composite = fsm.compositeState();
280
+ const record = inputSequence[inputSequence.length - 1];
281
+ const args = {
282
+ state: data.toState,
283
+ previousState: data.fromState,
284
+ compositeState: composite,
285
+ ctx,
286
+ input: currentInput,
287
+ payload: record?.payload,
288
+ step: currentStep
289
+ };
290
+ try {
291
+ if (config.invariant(args) === false) {
292
+ violation = /* @__PURE__ */ new Error("invariant returned false");
293
+ violationArgs = args;
294
+ }
295
+ } catch (err) {
296
+ violation = err;
297
+ violationArgs = args;
298
+ }
299
+ });
300
+ try {
301
+ for (let step = 1; step <= maxSteps; step++) {
302
+ currentStep = step;
303
+ const inputName = inputs[randomInt(prng, inputs.length)];
304
+ const payload = config.inputs?.[inputName]?.();
305
+ inputSequence.push({
306
+ input: inputName,
307
+ payload
308
+ });
309
+ const extraArgs = payload !== void 0 ? [payload] : [];
310
+ if (isBehavioral && client !== void 0) fsm.handle(client, inputName, ...extraArgs);
311
+ else fsm.handle(inputName, ...extraArgs);
312
+ if (violation !== null) throw new WalkFailureError({
313
+ seed,
314
+ step,
315
+ inputSequence: [...inputSequence],
316
+ state: violationArgs.state,
317
+ previousState: violationArgs.previousState,
318
+ compositeState: violationArgs.compositeState,
319
+ ctx: violationArgs.ctx,
320
+ cause: violation
321
+ });
322
+ }
323
+ } finally {
324
+ handlingSub.off();
325
+ transitionedSub.off();
326
+ if (typeof fsm.dispose === "function") fsm.dispose();
327
+ }
328
+ };
329
+
119
330
  //#endregion
120
331
  //#region src/index.ts
121
332
  expect.extend({
@@ -125,5 +336,5 @@ expect.extend({
125
336
  });
126
337
 
127
338
  //#endregion
128
- export { };
339
+ export { WalkFailureError, walkAll };
129
340
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/reachability.ts","../src/matchers.ts","../src/index.ts"],"sourcesContent":["// =============================================================================\n// reachability.ts — BFS reachability check for StateGraph\n//\n// Top-level graph only. Child graphs are not traversed here — users who want\n// to assert reachability inside a child FSM pass the child instance directly\n// to expect().\n//\n// Both \"definite\" and \"possible\" edges are followed. The question is whether\n// the plumbing exists, not whether runtime conditions will always cause it to\n// execute. See design decisions in the build plan for rationale.\n// =============================================================================\n\nimport type { StateGraph } from \"machina-inspect\";\n\n/**\n * Returns true if `to` is reachable from `from` via BFS over the\n * top-level graph's edges. Returns false if either state is missing\n * from the graph, or if no path exists.\n *\n * Treats `from === to` as trivially reachable (every state reaches itself).\n */\nconst canReach = (graph: StateGraph, from: string, to: string): boolean => {\n // Trivially reachable — a state always reaches itself\n if (from === to) {\n return true;\n }\n\n // If the source state isn't in the graph, there's nothing to walk\n if (!(from in graph.nodes)) {\n return false;\n }\n\n const visited = new Set<string>();\n const queue: string[] = [from];\n visited.add(from);\n\n while (queue.length > 0) {\n const current = queue.shift()!;\n const node = graph.nodes[current];\n\n // Missing node is a safety check — shouldn't happen in a well-formed graph\n if (!node) {\n continue;\n }\n\n for (const edge of node.edges) {\n if (edge.to === to) {\n return true;\n }\n if (!visited.has(edge.to) && edge.to in graph.nodes) {\n visited.add(edge.to);\n queue.push(edge.to);\n }\n }\n }\n\n return false;\n};\n\nexport { canReach };\n","// =============================================================================\n// matchers.ts — Custom matcher implementations for machina FSMs\n//\n// Each matcher follows the expect.extend() contract:\n// - Receives the subject as `received`\n// - Returns { pass: boolean, message: () => string }\n// - `message` is always the FAILURE message — jest/vitest call it only\n// when the assertion fails (accounting for .not inversion)\n//\n// Invalid state names produce matcher failures, not throws, so they appear\n// as clean test failures in the runner rather than unhandled exceptions.\n// =============================================================================\n\nimport { buildStateGraph, inspectGraph, type StateGraph } from \"machina-inspect\";\nimport type { MachinaInstance } from \"machina\";\nimport { canReach } from \"./reachability\";\nimport type { ReachabilityOptions } from \"./types\";\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build the StateGraph for the received FSM instance.\n * `buildStateGraph` accepts both config objects and live instances;\n * here we always receive live instances (users pass `expect(fsm)`).\n */\nconst getGraph = (received: MachinaInstance): StateGraph => {\n return buildStateGraph(received);\n};\n\n/**\n * Produce the \"invalid state\" failure message. Used when a state name\n * passed to a matcher does not exist in the graph.\n */\nconst invalidStateMessage = (stateName: string, fsmId: string, graph: StateGraph): string => {\n const available = Object.keys(graph.nodes).join(\", \");\n return `State '${stateName}' does not exist in FSM '${fsmId}'. Available states: ${available}.`;\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toHaveNoUnreachableStates\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when the FSM has no states that are unreachable from initialState.\n * Uses inspectGraph() to run all checks and filters for unreachable-state\n * findings, which includes recursive child graph analysis.\n */\nconst toHaveNoUnreachableStates = (received: MachinaInstance): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const findings = inspectGraph(graph).filter(f => f.type === \"unreachable-state\");\n\n const pass = findings.length === 0;\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toHaveNoUnreachableStates() but all states reachable\n return `Expected FSM '${graph.fsmId}' to have unreachable states, but all states are reachable.`;\n }\n const stateList = findings.map(f => `'${f.states.join(\", \")}'`).join(\", \");\n return `Expected FSM '${graph.fsmId}' to have no unreachable states, but found: ${stateList}.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toAlwaysReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when a path exists from `options.from` to `targetState` in the\n * FSM's top-level graph (BFS over all edges, definite and possible).\n */\nconst toAlwaysReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n const pass = canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toAlwaysReach but path exists\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toNeverReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when NO path exists from `options.from` to `targetState`.\n * The logical inverse of toAlwaysReach.\n */\nconst toNeverReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n // pass: true means \"no path exists\" (the assertion that toNeverReach checks)\n const pass = !canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toNeverReach but no path exists\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n };\n\n return { pass, message };\n};\n\nexport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach };\n","// =============================================================================\n// index.ts — Side-effect entry point for machina-test\n//\n// Importing this module registers all three custom matchers with the global\n// `expect` provided by Jest or Vitest. This is the same pattern used by\n// @testing-library/jest-dom — the import IS the side effect.\n//\n// `expect` must be available as a global at the time this module is evaluated.\n// Put the import in a test file or in a setup file that runs after the test\n// framework's globals are injected.\n// =============================================================================\n\nimport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach } from \"./matchers\";\n\n// Register with the host test runner's global expect\nexpect.extend({ toHaveNoUnreachableStates, toAlwaysReach, toNeverReach });\n\n// Re-export the options type so consumers can import it alongside the package\nexport type { ReachabilityOptions } from \"./types\";\n"],"mappings":";;;;;;;;;;AAqBA,MAAM,YAAY,OAAmB,MAAc,OAAwB;AAEvE,KAAI,SAAS,GACT,QAAO;AAIX,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;CAGX,MAAM,0BAAU,IAAI,KAAa;CACjC,MAAM,QAAkB,CAAC,KAAK;AAC9B,SAAQ,IAAI,KAAK;AAEjB,QAAO,MAAM,SAAS,GAAG;EACrB,MAAM,UAAU,MAAM,OAAO;EAC7B,MAAM,OAAO,MAAM,MAAM;AAGzB,MAAI,CAAC,KACD;AAGJ,OAAK,MAAM,QAAQ,KAAK,OAAO;AAC3B,OAAI,KAAK,OAAO,GACZ,QAAO;AAEX,OAAI,CAAC,QAAQ,IAAI,KAAK,GAAG,IAAI,KAAK,MAAM,MAAM,OAAO;AACjD,YAAQ,IAAI,KAAK,GAAG;AACpB,UAAM,KAAK,KAAK,GAAG;;;;AAK/B,QAAO;;;;;;;;;;AC7BX,MAAM,YAAY,aAA0C;AACxD,QAAO,gBAAgB,SAAS;;;;;;AAOpC,MAAM,uBAAuB,WAAmB,OAAe,UAA8B;AAEzF,QAAO,UAAU,UAAU,2BAA2B,MAAM,uBAD1C,OAAO,KAAK,MAAM,MAAM,CAAC,KAAK,KAAK,CACwC;;;;;;;AAYjG,MAAM,6BAA6B,aAAwD;CACvF,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,WAAW,aAAa,MAAM,CAAC,QAAO,MAAK,EAAE,SAAS,oBAAoB;CAEhF,MAAM,OAAO,SAAS,WAAW;CAEjC,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM;EAExC,MAAM,YAAY,SAAS,KAAI,MAAK,IAAI,EAAE,OAAO,KAAK,KAAK,CAAC,GAAG,CAAC,KAAK,KAAK;AAC1E,SAAO,iBAAiB,MAAM,MAAM,8CAA8C,UAAU;;AAGhG,QAAO;EAAE;EAAM;EAAS;;;;;;AAW5B,MAAM,iBACF,UACA,aACA,YAC2B;CAC3B,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,EAAE,SAAS;AAGjB,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,MAAM,MAAM,OAAO,MAAM;EAC/D;AAGL,KAAI,EAAE,eAAe,MAAM,OACvB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,aAAa,MAAM,OAAO,MAAM;EACtE;CAGL,MAAM,OAAO,SAAS,OAAO,MAAM,YAAY;CAE/C,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM,0BAA0B,KAAK,QAAQ,YAAY;AAE3F,SAAO,iBAAiB,MAAM,MAAM,yBAAyB,KAAK,QAAQ,YAAY;;AAG1F,QAAO;EAAE;EAAM;EAAS;;;;;;AAW5B,MAAM,gBACF,UACA,aACA,YAC2B;CAC3B,MAAM,QAAQ,SAAS,SAAS;CAChC,MAAM,EAAE,SAAS;AAGjB,KAAI,EAAE,QAAQ,MAAM,OAChB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,MAAM,MAAM,OAAO,MAAM;EAC/D;AAGL,KAAI,EAAE,eAAe,MAAM,OACvB,QAAO;EACH,MAAM;EACN,eAAe,oBAAoB,aAAa,MAAM,OAAO,MAAM;EACtE;CAIL,MAAM,OAAO,CAAC,SAAS,OAAO,MAAM,YAAY;CAEhD,MAAM,gBAAwB;AAC1B,MAAI,KAEA,QAAO,iBAAiB,MAAM,MAAM,yBAAyB,KAAK,QAAQ,YAAY;AAE1F,SAAO,iBAAiB,MAAM,MAAM,0BAA0B,KAAK,QAAQ,YAAY;;AAG3F,QAAO;EAAE;EAAM;EAAS;;;;;AC1I5B,OAAO,OAAO;CAAE;CAA2B;CAAe;CAAc,CAAC"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/reachability.ts","../src/matchers.ts","../src/walk.ts","../src/index.ts"],"sourcesContent":["// =============================================================================\n// reachability.ts — BFS reachability check for StateGraph\n//\n// Top-level graph only. Child graphs are not traversed here — users who want\n// to assert reachability inside a child FSM pass the child instance directly\n// to expect().\n//\n// Both \"definite\" and \"possible\" edges are followed. The question is whether\n// the plumbing exists, not whether runtime conditions will always cause it to\n// execute. See design decisions in the build plan for rationale.\n// =============================================================================\n\nimport type { StateGraph } from \"machina-inspect\";\n\n/**\n * Returns true if `to` is reachable from `from` via BFS over the\n * top-level graph's edges. Returns false if either state is missing\n * from the graph, or if no path exists.\n *\n * Treats `from === to` as trivially reachable (every state reaches itself).\n */\nconst canReach = (graph: StateGraph, from: string, to: string): boolean => {\n // Trivially reachable — a state always reaches itself\n if (from === to) {\n return true;\n }\n\n // If the source state isn't in the graph, there's nothing to walk\n if (!(from in graph.nodes)) {\n return false;\n }\n\n const visited = new Set<string>();\n const queue: string[] = [from];\n visited.add(from);\n\n while (queue.length > 0) {\n const current = queue.shift()!;\n const node = graph.nodes[current];\n\n // Missing node is a safety check — shouldn't happen in a well-formed graph\n if (!node) {\n continue;\n }\n\n for (const edge of node.edges) {\n if (edge.to === to) {\n return true;\n }\n if (!visited.has(edge.to) && edge.to in graph.nodes) {\n visited.add(edge.to);\n queue.push(edge.to);\n }\n }\n }\n\n return false;\n};\n\nexport { canReach };\n","// =============================================================================\n// matchers.ts — Custom matcher implementations for machina FSMs\n//\n// Each matcher follows the expect.extend() contract:\n// - Receives the subject as `received`\n// - Returns { pass: boolean, message: () => string }\n// - `message` is always the FAILURE message — jest/vitest call it only\n// when the assertion fails (accounting for .not inversion)\n//\n// Invalid state names produce matcher failures, not throws, so they appear\n// as clean test failures in the runner rather than unhandled exceptions.\n// =============================================================================\n\nimport { buildStateGraph, inspectGraph, type StateGraph } from \"machina-inspect\";\nimport type { MachinaInstance } from \"machina\";\nimport { canReach } from \"./reachability\";\nimport type { ReachabilityOptions } from \"./types\";\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build the StateGraph for the received FSM instance.\n * `buildStateGraph` accepts both config objects and live instances;\n * here we always receive live instances (users pass `expect(fsm)`).\n */\nconst getGraph = (received: MachinaInstance): StateGraph => {\n return buildStateGraph(received);\n};\n\n/**\n * Produce the \"invalid state\" failure message. Used when a state name\n * passed to a matcher does not exist in the graph.\n */\nconst invalidStateMessage = (stateName: string, fsmId: string, graph: StateGraph): string => {\n const available = Object.keys(graph.nodes).join(\", \");\n return `State '${stateName}' does not exist in FSM '${fsmId}'. Available states: ${available}.`;\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toHaveNoUnreachableStates\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when the FSM has no states that are unreachable from initialState.\n * Uses inspectGraph() to run all checks and filters for unreachable-state\n * findings, which includes recursive child graph analysis.\n */\nconst toHaveNoUnreachableStates = (received: MachinaInstance): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const findings = inspectGraph(graph).filter(f => f.type === \"unreachable-state\");\n\n const pass = findings.length === 0;\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toHaveNoUnreachableStates() but all states reachable\n return `Expected FSM '${graph.fsmId}' to have unreachable states, but all states are reachable.`;\n }\n const stateList = findings.map(f => `'${f.states.join(\", \")}'`).join(\", \");\n return `Expected FSM '${graph.fsmId}' to have no unreachable states, but found: ${stateList}.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toAlwaysReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when a path exists from `options.from` to `targetState` in the\n * FSM's top-level graph (BFS over all edges, definite and possible).\n */\nconst toAlwaysReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n const pass = canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toAlwaysReach but path exists\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n };\n\n return { pass, message };\n};\n\n// ---------------------------------------------------------------------------\n// Matcher: toNeverReach\n// ---------------------------------------------------------------------------\n\n/**\n * Passes when NO path exists from `options.from` to `targetState`.\n * The logical inverse of toAlwaysReach.\n */\nconst toNeverReach = (\n received: MachinaInstance,\n targetState: string,\n options: ReachabilityOptions\n): jest.CustomMatcherResult => {\n const graph = getGraph(received);\n const { from } = options;\n\n // Invalid state names are reported as matcher failures for clean output\n if (!(from in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(from, graph.fsmId, graph),\n };\n }\n\n if (!(targetState in graph.nodes)) {\n return {\n pass: false,\n message: () => invalidStateMessage(targetState, graph.fsmId, graph),\n };\n }\n\n // pass: true means \"no path exists\" (the assertion that toNeverReach checks)\n const pass = !canReach(graph, from, targetState);\n\n const message = (): string => {\n if (pass) {\n // Negated assertion failed: .not.toNeverReach but no path exists\n return `Expected FSM '${graph.fsmId}' to have a path from '${from}' to '${targetState}', but no path exists.`;\n }\n return `Expected FSM '${graph.fsmId}' to have no path from '${from}' to '${targetState}', but a path exists.`;\n };\n\n return { pass, message };\n};\n\nexport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach };\n","/* eslint-disable @typescript-eslint/no-explicit-any */\n// =============================================================================\n// walk.ts — Property-based walk testing for machina FSMs\n//\n// `walkAll` runs multiple randomized input sequences against a live FSM,\n// asserting a user-supplied invariant after every transition. Deterministic\n// replay is guaranteed via a seeded PRNG — on failure, the seed and full input\n// sequence are included in the thrown WalkFailureError so you can reproduce\n// the exact walk that uncovered the bug.\n//\n// Design choices:\n// - Factory function (not FSM instance) as first arg: each walk starts fresh\n// - Throws on failure, returns on success: natural test code, no result-type gymnastics\n// - step counts handle() calls, not transitions: maps 1:1 to input sequence index\n// - Inline mulberry32 PRNG: 5 lines, no dependency, deterministic across platforms\n// - Event-driven invariant: handles _onEnter bounces + deferred replays without\n// monkey-patching handle()\n// =============================================================================\n\nimport { MACHINA_TYPE } from \"machina\";\n\n// -----------------------------------------------------------------------------\n// Types\n// -----------------------------------------------------------------------------\n\n/**\n * Arguments passed to the invariant callback after every transition.\n * Use these to assert that the FSM is in a valid state after each step.\n */\nexport interface InvariantArgs {\n /** State the FSM just transitioned TO */\n state: string;\n /** State the FSM transitioned FROM */\n previousState: string;\n /** Dot-delimited composite state (includes child states if any) */\n compositeState: string;\n /** The FSM context object (Fsm) or client object (BehavioralFsm) */\n ctx: unknown;\n /** The input name that triggered this transition */\n input: string;\n /** The payload passed to handle(), or undefined if no generator was configured */\n payload: unknown;\n /** Which handle() call produced this transition (1-indexed) */\n step: number;\n}\n\n/**\n * Returned by walkAll on success.\n */\nexport interface WalkResult {\n /** The seed used for this run — pass back to walkAll to replay */\n seed: number;\n /** How many walks completed without an invariant violation */\n walksCompleted: number;\n}\n\n/**\n * Configuration for a walkAll run.\n *\n * @typeParam TClient - The client object type for BehavioralFsm walks.\n * Not relevant for Fsm walks — omit `client` in that case.\n */\nexport interface WalkConfig<TClient extends object = object> {\n /**\n * How many independent walks to run. Each walk creates a fresh FSM\n * via the factory and runs up to `maxSteps` random inputs.\n * Must be >= 1. Default: 100.\n */\n walks?: number;\n\n /**\n * Maximum number of handle() calls per walk.\n * Must be >= 1. Default: 50.\n */\n maxSteps?: number;\n\n /**\n * Seed for the PRNG. Providing the same seed guarantees the identical\n * input sequence — use the seed from WalkResult or WalkFailureError to\n * replay a specific walk. Defaults to a random integer.\n */\n seed?: number;\n\n /**\n * Whitelist of input names to use. Mutually exclusive with `exclude`.\n * If provided, only these inputs will be fired during walks.\n */\n include?: string[];\n\n /**\n * Blacklist of input names to exclude. Mutually exclusive with `include`.\n * These inputs are removed from the auto-extracted set before walking.\n */\n exclude?: string[];\n\n /**\n * Payload generators keyed by input name. When walkAll fires input \"X\"\n * and `inputs[\"X\"]` is defined, it calls that function and passes the\n * return value as the payload to handle(). Inputs without generators\n * are fired with no payload.\n */\n inputs?: Record<string, () => unknown>;\n\n /**\n * BehavioralFsm client factory. Required when the FSM produced by the\n * factory is a BehavioralFsm — each walk calls this to get a fresh client.\n * Omit for Fsm walks.\n */\n client?: () => TClient;\n\n /**\n * Called after every transition. Return `false` OR throw to signal a\n * violation — WalkFailureError is constructed automatically in both cases.\n * Return `true` or `undefined` (void) to indicate the invariant passed.\n */\n invariant: (args: InvariantArgs) => boolean | void;\n}\n\n/** A recorded step in the input sequence — used for replay and error reporting */\ninterface InputRecord {\n input: string;\n payload: unknown;\n}\n\n/**\n * Thrown when walkAll detects an invariant violation. Carries the seed and\n * full input sequence so you can replay the exact walk that failed.\n */\nexport class WalkFailureError extends Error {\n /** Seed that produced this walk — pass to walkAll({ seed }) to replay */\n readonly seed: number;\n /** Which step (1-indexed handle() call) caused the violation */\n readonly step: number;\n /** The inputs fired up to and including the failing step */\n readonly inputSequence: InputRecord[];\n /** State at the time of violation */\n readonly state: string;\n /** State before the violating transition */\n readonly previousState: string;\n /** Composite state at the time of violation */\n readonly compositeState: string;\n /** The FSM context or client at the time of violation */\n readonly ctx: unknown;\n\n constructor(args: {\n seed: number;\n step: number;\n inputSequence: InputRecord[];\n state: string;\n previousState: string;\n compositeState: string;\n ctx: unknown;\n cause: unknown;\n }) {\n const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause);\n const sequenceSummary = args.inputSequence\n .map((r, i) => ` ${i + 1}. ${r.input}`)\n .join(\"\\n\");\n\n super(\n `walkAll invariant violation at step ${args.step} (seed: ${args.seed})\\n` +\n ` State: ${args.previousState} → ${args.state}\\n` +\n ` Composite: ${args.compositeState}\\n` +\n ` Cause: ${causeMessage}\\n` +\n `Input sequence:\\n${sequenceSummary}`\n );\n this.name = \"WalkFailureError\";\n this.seed = args.seed;\n this.step = args.step;\n this.inputSequence = args.inputSequence;\n this.state = args.state;\n this.previousState = args.previousState;\n this.compositeState = args.compositeState;\n this.ctx = args.ctx;\n }\n}\n\n// -----------------------------------------------------------------------------\n// Seeded PRNG — mulberry32\n//\n// Passes BigCrush and PractRand. 5 lines, no external dependency.\n// Returns floats in [0, 1) just like Math.random(), enabling drop-in\n// deterministic replacement wherever we'd otherwise use Math.random().\n// -----------------------------------------------------------------------------\n\n/**\n * Creates a seeded PRNG using mulberry32.\n * Returns a function that yields deterministic floats in [0, 1).\n * Two PRNGs with the same seed produce identical sequences.\n */\nexport const createPrng = (seed: number): (() => number) => {\n let s = seed;\n return () => {\n s += 0x6d2b79f5;\n let t = s;\n t = Math.imul(t ^ (t >>> 15), t | 1);\n t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n};\n\n/**\n * Pick a random integer in [0, max) using the given PRNG.\n * Used to select a random index from the inputs array each step.\n */\nexport const randomInt = (prng: () => number, max: number): number => {\n return Math.floor(prng() * max);\n};\n\n// -----------------------------------------------------------------------------\n// Keys that are lifecycle hooks or special directives, not named inputs.\n// Mirrors SpecialStateKeys from machina core types.\n// -----------------------------------------------------------------------------\n\nconst RESERVED_KEYS = new Set([\"_onEnter\", \"_onExit\", \"_child\", \"*\"]);\n\n/**\n * Extract all distinct input names from an FSM's states object.\n * Skips reserved keys (_onEnter, _onExit, _child, *) — these are lifecycle\n * hooks and catch-alls, not named inputs you'd fire via handle().\n */\nexport const extractInputs = (fsm: {\n states: Record<string, Record<string, unknown>>;\n}): string[] => {\n const inputs = new Set<string>();\n for (const stateObj of Object.values(fsm.states)) {\n for (const key of Object.keys(stateObj)) {\n if (!RESERVED_KEYS.has(key)) {\n inputs.add(key);\n }\n }\n }\n return Array.from(inputs);\n};\n\n// -----------------------------------------------------------------------------\n// Config validation\n// -----------------------------------------------------------------------------\n\nconst validateConfig = (config: WalkConfig, availableInputs: string[]): string[] => {\n const walks = config.walks ?? 100;\n const maxSteps = config.maxSteps ?? 50;\n\n if (walks < 1) {\n throw new Error(`walkAll: walks must be >= 1, got ${walks}`);\n }\n\n if (maxSteps < 1) {\n throw new Error(`walkAll: maxSteps must be >= 1, got ${maxSteps}`);\n }\n\n if (config.include && config.exclude) {\n throw new Error(\n \"walkAll: include and exclude are mutually exclusive — use one or the other\"\n );\n }\n\n let inputs: string[];\n\n if (config.include) {\n // Catch typos and misconfigurations before walking — an include entry\n // that doesn't exist in the FSM is almost certainly a bug at the call site.\n const knownInputs = new Set(availableInputs);\n const unknown = config.include.filter(name => !knownInputs.has(name));\n if (unknown.length > 0) {\n throw new Error(\n `walkAll: include contains inputs not found in the FSM: ${unknown.join(\", \")}`\n );\n }\n inputs = config.include;\n } else if (config.exclude) {\n const excluded = new Set(config.exclude);\n inputs = availableInputs.filter(k => !excluded.has(k));\n } else {\n inputs = availableInputs;\n }\n\n if (inputs.length === 0) {\n throw new Error(\n \"walkAll: no inputs available after filtering — check include/exclude config\"\n );\n }\n\n return inputs;\n};\n\n// -----------------------------------------------------------------------------\n// Walk engine\n// -----------------------------------------------------------------------------\n\n/**\n * Run property-based walk tests against a live FSM.\n *\n * Throws WalkFailureError on invariant violation. Returns WalkResult on success.\n *\n * @param factory - Called once per walk to create a fresh FSM instance.\n * Each walk gets its own FSM so context mutations don't bleed between walks.\n * @param config - Walk configuration: invariant, walk count, step limit, seed,\n * input filters, payload generators, and optional client factory.\n */\nexport const walkAll = <TClient extends object = object>(\n factory: () => any,\n config: WalkConfig<TClient>\n): WalkResult => {\n const walks = config.walks ?? 100;\n const maxSteps = config.maxSteps ?? 50;\n\n // Build the input list from the first FSM instance — we only need this\n // for validation, not for actual walking (each walk creates its own FSM).\n const probeFsm = factory();\n const allInputs = extractInputs(probeFsm);\n const inputs = validateConfig(config, allInputs);\n\n // Dispose the probe FSM if possible — it was only needed for input extraction.\n if (typeof probeFsm.dispose === \"function\") {\n probeFsm.dispose();\n }\n\n const seed = config.seed ?? Math.floor(Math.random() * 2 ** 32);\n const prng = createPrng(seed);\n\n for (let walkIndex = 0; walkIndex < walks; walkIndex++) {\n runSingleWalk(factory, config, inputs, prng, seed, maxSteps);\n }\n\n return { seed, walksCompleted: walks };\n};\n\n/**\n * Run one walk: create a fresh FSM, subscribe to events, fire random inputs,\n * check invariant after every transition. Throws WalkFailureError on violation.\n */\nconst runSingleWalk = <TClient extends object>(\n factory: () => any,\n config: WalkConfig<TClient>,\n inputs: string[],\n prng: () => number,\n seed: number,\n maxSteps: number\n): void => {\n const fsm = factory();\n const isBehavioral = fsm[MACHINA_TYPE] === \"BehavioralFsm\";\n\n // For BehavioralFsm: create a client and track it separately.\n // For Fsm: the client IS the fsm (context lives on the fsm).\n const client: TClient | undefined = isBehavioral\n ? config.client\n ? config.client()\n : ({} as TClient)\n : undefined;\n\n // Initialize BehavioralFsm client by transitioning into initialState.\n // Fsm eagerly initializes in its constructor — no action needed.\n if (isBehavioral && client !== undefined) {\n fsm.transition(client, fsm.initialState);\n }\n\n const inputSequence: InputRecord[] = [];\n let currentStep = 0;\n let currentInput = \"\";\n let violation: unknown = null;\n let violationArgs: InvariantArgs | null = null;\n\n // Track \"current input\" via the handling event, which fires before dispatch.\n // This is the input that caused any subsequent transitioned events — including\n // those from _onEnter bounces, where handling does NOT re-fire.\n // Both Fsm and BehavioralFsm expose inputName on this event, so no branching needed.\n const handlingSub = fsm.on(\"handling\", (data: any) => {\n currentInput = data.inputName;\n });\n\n // The transitioned event fires after every state change, including _onEnter\n // bounces. We run the invariant here so we catch all transition types.\n const transitionedSub = isBehavioral\n ? fsm.on(\"transitioned\", (data: any) => {\n if (violation !== null) {\n return;\n }\n const ctx = data.client ?? client;\n const composite = fsm.compositeState(data.client ?? client);\n const record = inputSequence[inputSequence.length - 1];\n const args: InvariantArgs = {\n state: data.toState,\n previousState: data.fromState,\n compositeState: composite,\n ctx,\n input: currentInput,\n payload: record?.payload,\n step: currentStep,\n };\n try {\n const result = config.invariant(args);\n // Returning false is an explicit violation signal (same as throwing).\n // undefined/void and true both mean \"invariant holds\".\n if (result === false) {\n violation = new Error(\"invariant returned false\");\n violationArgs = args;\n }\n } catch (err) {\n violation = err;\n violationArgs = args;\n }\n })\n : fsm.on(\"transitioned\", (data: any) => {\n if (violation !== null) {\n return;\n }\n // For Fsm, read context from fsm.context (Task 1 made this public)\n const ctx = fsm.context;\n const composite = fsm.compositeState();\n const record = inputSequence[inputSequence.length - 1];\n const args: InvariantArgs = {\n state: data.toState,\n previousState: data.fromState,\n compositeState: composite,\n ctx,\n input: currentInput,\n payload: record?.payload,\n step: currentStep,\n };\n try {\n const result = config.invariant(args);\n // Returning false is an explicit violation signal (same as throwing).\n // undefined/void and true both mean \"invariant holds\".\n if (result === false) {\n violation = new Error(\"invariant returned false\");\n violationArgs = args;\n }\n } catch (err) {\n violation = err;\n violationArgs = args;\n }\n });\n\n try {\n for (let step = 1; step <= maxSteps; step++) {\n currentStep = step;\n const inputName = inputs[randomInt(prng, inputs.length)];\n const payload = config.inputs?.[inputName]?.();\n inputSequence.push({ input: inputName, payload });\n\n // Only spread the payload arg when a generator produced one — handlers\n // with no generator shouldn't receive a spurious undefined argument,\n // which could mask bugs in handlers that check arguments.length.\n const extraArgs = payload !== undefined ? [payload] : [];\n if (isBehavioral && client !== undefined) {\n fsm.handle(client, inputName, ...extraArgs);\n } else {\n fsm.handle(inputName, ...extraArgs);\n }\n\n if (violation !== null) {\n throw new WalkFailureError({\n seed,\n step,\n inputSequence: [...inputSequence],\n state: violationArgs!.state,\n previousState: violationArgs!.previousState,\n compositeState: violationArgs!.compositeState,\n ctx: violationArgs!.ctx,\n cause: violation,\n });\n }\n }\n } finally {\n // Always unsubscribe, even on failure — prevents listener leaks between walks\n handlingSub.off();\n transitionedSub.off();\n\n if (typeof fsm.dispose === \"function\") {\n fsm.dispose();\n }\n }\n};\n","// =============================================================================\n// index.ts — Side-effect entry point for machina-test\n//\n// Importing this module registers all three custom matchers with the global\n// `expect` provided by Jest or Vitest. This is the same pattern used by\n// @testing-library/jest-dom — the import IS the side effect.\n//\n// `expect` must be available as a global at the time this module is evaluated.\n// Put the import in a test file or in a setup file that runs after the test\n// framework's globals are injected.\n// =============================================================================\n\nimport { toHaveNoUnreachableStates, toAlwaysReach, toNeverReach } from \"./matchers\";\n\n// Register with the host test runner's global expect\nexpect.extend({ toHaveNoUnreachableStates, toAlwaysReach, toNeverReach });\n\n// Re-export the options type so consumers can import it alongside the package\nexport type { ReachabilityOptions } from \"./types\";\n\n// walkAll is a standalone function — no side-effect registration needed.\n// Import it directly: import { walkAll } from \"machina-test\"\nexport { walkAll } from \"./walk\";\nexport type { WalkConfig, InvariantArgs, WalkResult } from \"./walk\";\nexport { WalkFailureError } from \"./walk\";\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machina-test",
3
- "version": "0.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "Jest/Vitest custom matchers for testing machina FSMs.",
5
5
  "author": "Jim Cowart (http://ifandelse.com)",
6
6
  "license": "MIT",
@@ -44,11 +44,11 @@
44
44
  },
45
45
  "sideEffects": true,
46
46
  "dependencies": {
47
- "machina-inspect": "1.0.0"
47
+ "machina-inspect": "3.0.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "machina": ">=6.1.0",
51
50
  "jest": ">=29",
51
+ "machina": ">=6.3.0",
52
52
  "vitest": ">=1"
53
53
  },
54
54
  "peerDependenciesMeta": {
@@ -61,18 +61,18 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/jest": "^30.0.0",
64
- "eslint": "^9.39.2",
65
- "jest": "^30.2.0",
64
+ "eslint": "^9.39.4",
65
+ "jest": "^30.3.0",
66
66
  "ts-jest": "^29.4.6",
67
67
  "tsdown": "^0.20.3",
68
68
  "typescript": "^5.9.3",
69
- "machina": "6.1.0"
69
+ "machina": "6.3.0"
70
70
  },
71
71
  "scripts": {
72
72
  "dev": "tsdown --watch",
73
73
  "build": "tsdown",
74
74
  "test": "jest --passWithNoTests",
75
75
  "test:watch": "jest --watch",
76
- "lint": "ESLINT_USE_FLAT_CONFIG=false eslint --config ../../.eslintrc.js 'src/**/*.{ts,tsx}'"
76
+ "lint": "eslint 'src/**/*.{ts,tsx}'"
77
77
  }
78
78
  }