diff --git a/.changeset/xstate-adapter.md b/.changeset/xstate-adapter.md new file mode 100644 index 0000000..d38c961 --- /dev/null +++ b/.changeset/xstate-adapter.md @@ -0,0 +1,5 @@ +--- +'@statelyai/graph': minor +--- + +Add `@statelyai/graph/xstate` with `createGraphFromMachine(machine, options?)`: one machine→graph path that enumerates events from the machine's own descriptors, serializes nodes as `{ value, context }` (same ids as `xstate/graph`), and attaches guard text (`hypothesized: true`), actions, and selected transition definitions to edges. `getCoverageTargets` on the result yields stable state / transition / transition-pair targets. `xstate` v5 is an optional peer dependency. diff --git a/AGENTS.md b/AGENTS.md index d0e822c..6ab126a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,7 @@ Generics order: ``, shortened to `< - **`algorithms.ts`** — traversal, components, cycles, paths, ordering, MST - **`transforms.ts`** — `getFlattenedGraph()` (statechart decomposition) - **`formats/`** — DOT, GraphML, adjacency list, edge list +- **`xstate/`** — `createGraphFromMachine()` XState adapter (`@statelyai/graph/xstate`, optional `xstate` peer) - **`indexing.ts`** — transparent WeakMap indexing, auto-rebuilt on access - **`types.ts`** — all type definitions diff --git a/CLAUDE.md b/CLAUDE.md index d46cf60..3e76b7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ Generics order: ``, shortened to `< - **`algorithms.ts`** — traversal, components, cycles, paths, ordering, MST - **`transforms.ts`** — `flatten()` (statechart decomposition) - **`formats/`** — DOT, GraphML, adjacency list, edge list +- **`xstate/`** — `createGraphFromMachine()` XState adapter (`@statelyai/graph/xstate`, optional `xstate` peer) - **`indexing.ts`** — transparent WeakMap indexing; auto-rebuilt when arrays are replaced or lengths change (O(1) check per access). In-place *field* mutations require `invalidateIndex()` - **`types.ts`** — all type definitions diff --git a/README.md b/README.md index 46e6f56..444064c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Optional peers are only needed for specific adapters: | `d3-hierarchy` | `@statelyai/graph/layout/d3-hierarchy` | | `webcola` | `@statelyai/graph/layout/webcola` | | `cytoscape` | `@statelyai/graph/layout/cytoscape`, Cytoscape format typing | +| `xstate` | `@statelyai/graph/xstate` | ## Highlights @@ -36,6 +37,7 @@ Optional peers are only needed for specific adapters: - Ports for node-editor and dataflow-style graphs - Algorithms for traversal, paths, centrality, communities, connectivity, flow/cuts, matching, cores, isomorphism, ordering, MST, and walks - Pluggable layout over eight external engines (ELK, Graphviz, dagre, d3-force, ForceAtlas2, tidy tree, WebCola, cytoscape) — pure functions, optional peers +- XState adapter: `createGraphFromMachine` turns a machine into a graph with stable ids for paths and coverage - Diff/patch utilities for graph state changes - Multi-format conversion via package subpaths, with fidelity claims tested against fixtures - Small, fast test suite with broad format coverage diff --git a/docs/from-xstate-machine.md b/docs/from-xstate-machine.md new file mode 100644 index 0000000..79283d2 --- /dev/null +++ b/docs/from-xstate-machine.md @@ -0,0 +1,148 @@ +# From an XState machine + +`@statelyai/graph/xstate` turns an XState machine into a plain `Graph`: every reachable snapshot becomes a node, every `(snapshot, event)` step becomes an edge. Use it to compute test paths, coverage targets, or visualizations from a machine. + +## Install + +`xstate` is an optional peer dependency (v5): + +```bash +npm install @statelyai/graph xstate +``` + +## Create the graph + +```ts +import { createMachine } from 'xstate'; +import { createGraphFromMachine } from '@statelyai/graph/xstate'; + +const machine = createMachine({ + id: 'toggle', + initial: 'inactive', + states: { + inactive: { on: { TOGGLE: 'active' } }, + active: { on: { TOGGLE: 'inactive' } }, + }, +}); + +const graph = createGraphFromMachine(machine); + +graph.nodes.length; // 2 +graph.edges.map((edge) => edge.data.eventType); // ['TOGGLE', 'TOGGLE'] +``` + +Event types are enumerated from the machine's own event descriptors at each state and sent as bare `{ type }` objects. Provide the `events` option when a transition needs payload fields (see [Options](#options)). Transitions are taken by `xstate` itself — the graph contains only real behavior. Completed (`done`) snapshots are kept as nodes but not expanded, since a finished actor accepts no events. The resulting graph is `directed`, with `initialNodeId` set to the initial snapshot's id. + +## What nodes and edges carry + +Node `data` is a `MachineNodeData`: + +| Field | Type | Meaning | +|---|---|---| +| `value` | `StateValue` | Snapshot state value (`'a'`, `{ a: 'b' }`, …) | +| `context` | `MachineContext \| undefined` | Snapshot context; `undefined` when empty | +| `stateIds` | `string[]` | Resolved ids of every active state node, document order | +| `statePaths` | `string[]` | Dotted paths of active atomic/final states, e.g. `['a.b', 'c']` | +| `tags` | `string[]` | Snapshot tags | +| `status` | `'active' \| 'done' \| 'error' \| 'stopped'` | Snapshot status | + +Edge `data` is a `MachineEdgeData`: + +| Field | Type | Meaning | +|---|---|---| +| `event` | `TEvent` | The event object sent to the machine | +| `eventType` | `string` | `event.type` | +| `sourceNodeId` | `string` | Graph node id of the source state (duplicate of `edge.sourceId`, for path consumers) | +| `guard` | `MachineGuardData \| undefined` | Guard text of the first selected transition, when `includeGuards` is on | +| `actions` | `string[]` | Action labels from every selected transition, in order | +| `transitions` | `MachineTransitionData[]` | The transition definitions XState selected for this `(state, event)` pair; empty for unhandled events | + +Each `MachineTransitionData` carries `source`, `targets`, `reenter`, `guard`, and `actions`. Node `label` is the joined `statePaths`; edge `label` is the event type. + +## Ids and stability + +- Node id: JSON of `{ value, context }` (`context` omitted when empty) — exported as `getSerializedSnapshot`. +- Edge id: `` `${sourceId}|${serializedEvent}|${targetId}` ``, where the event is serialized with `JSON.stringify` by default. + +These are the same defaults `xstate/graph` uses, so ids match between the two and stay stable across runs. Coverage and path reports keyed by these ids are diffable between commits. + +## Find paths + +The graph is an ordinary `Graph`, so every algorithm from `@statelyai/graph` applies: + +```ts +import { getShortestPaths, getSimplePaths } from '@statelyai/graph'; + +const shortest = getShortestPaths(graph); +shortest.map((path) => path.steps.map((step) => step.edge.data.eventType)); +// [['TOGGLE']] — one path, to the `active` state + +const routes = getSimplePaths(graph, { + from: graph.initialNodeId!, + to: someNodeId, +}); +``` + +Note the difference from `xstate/graph`: these path functions omit the zero-step path to the initial node. Every returned path has at least one step. Add the initial state explicitly if your runner needs it. + +`getShortestPaths` also returns every tied shortest path to a node, where `xstate/graph` keeps one per state. Expect equal or higher path counts; use `getShortestPath` with `to` for one path per target. + +## Coverage targets + +`getCoverageTargets` maps directly onto model-based testing criteria: + +```ts +import { getCoverageTargets } from '@statelyai/graph'; + +getCoverageTargets(graph, { kind: 'nodes' }); // state coverage +getCoverageTargets(graph, { kind: 'edges' }); // transition coverage +getCoverageTargets(graph, { kind: 'edge-pairs' }); // transition-pair coverage +``` + +| Kind | Target | MBT criterion | +|---|---|---| +| `'nodes'` | each node id | state coverage | +| `'edges'` | each edge id | transition coverage | +| `'edge-pairs'` | each adjacent edge pair | transition-pair coverage | + +Because node and edge ids are stable, a run report listing covered targets can be diffed across runs to show what a change added or dropped. + +## Options + +`createGraphFromMachine(machine, options)`: + +| Option | Default | Effect | +|---|---|---| +| `events` | `[]` | Event objects with payloads (array, or a function of the snapshot). An entry replaces the bare `{ type }` for its type. | +| `serializeState` | JSON of `{ value, context }` | Node id for a snapshot | +| `serializeEvent` | `JSON.stringify(event)` | Edge id component for an event | +| `includeGuards` | `true` | Attach guard text to edges | +| `filterEvents` | — | `(snapshot, event) => boolean`; skip an event at a state | +| `stopWhen` | — | `(snapshot) => boolean`; keep the node, do not explore its outgoing events | +| `limit` | `Infinity` | Max states to expand before throwing | +| `input` | — | Machine input for the initial snapshot | +| `id` | `machine.id` | Graph id | + +```ts +const graph = createGraphFromMachine(machine, { + events: [{ type: 'SET', value: 42 }], + stopWhen: (snapshot) => snapshot.status === 'done', + limit: 10_000, +}); +``` + +## Data boundary + +Node `context` and edge `event` values are stored as-is and the default ids `JSON.stringify` them, so machine context and events must be JSON-serializable (no `bigint`, cycles, functions, or class instances). Provide `serializeState` / `serializeEvent` and project the data yourself if your machine holds non-JSON values. + +`transitions` and `actions` on an edge describe the transitions XState selected for the event itself. `always` transitions taken afterwards, and entry/exit actions, are reflected in the target node but not listed on the edge. + +## Guards + +Guards are evaluated by `xstate` for real. Only the branch the real guard selected appears in the graph — unlike tools that stub guards out to explore every branch. + +The `guard` on an edge is therefore a **label**, not a proof: `{ text, hypothesized: true }`. Use it to annotate reports; do not treat it as a verified predicate. To explore a branch the current context does not reach, change the context — supply `input`, or send events that move the machine there. + +## Relationship to `xstate/graph` + +`xstate/graph` is a subset of this adapter, kept for in-process use where pulling in a second package is not worth it. It shares the default serialization, so ids match. For path generation, coverage, and anything downstream of a graph, prefer `@statelyai/graph/xstate`: you get the full algorithm surface, layout adapters, and format exports on the same object. diff --git a/docs/meta.json b/docs/meta.json index b093024..91680ac 100644 --- a/docs/meta.json +++ b/docs/meta.json @@ -2,6 +2,7 @@ "title": "Graph", "pages": [ "index", + "from-xstate-machine", "algorithms", "layout", "react-flow-elk-pipeline", diff --git a/package.json b/package.json index f5dd301..efdd8c7 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "./layout/webcola": "./dist/layout/webcola.mjs", "./queries": "./dist/queries.mjs", "./schemas": "./dist/schemas.mjs", + "./xstate": "./dist/xstate/index.mjs", "./package.json": "./package.json" }, "main": "./dist/index.mjs", @@ -133,6 +134,7 @@ "graphology": "^0.26.0", "graphology-layout-forceatlas2": "^0.10.0", "webcola": "^3.4.0", + "xstate": "^5.19.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { @@ -169,6 +171,9 @@ "webcola": { "optional": true }, + "xstate": { + "optional": true + }, "zod": { "optional": true } diff --git a/scripts/check-conventions.ts b/scripts/check-conventions.ts index 861af8d..45ad03b 100644 --- a/scripts/check-conventions.ts +++ b/scripts/check-conventions.ts @@ -60,6 +60,7 @@ const publicFiles = [ 'src/algorithms.ts', 'src/schemas.ts', 'src/layout/index.ts', + 'src/xstate/index.ts', ]; function isPrefixed(name: string): boolean { diff --git a/scripts/smoke-package.ts b/scripts/smoke-package.ts index f81eb24..ef7a553 100644 --- a/scripts/smoke-package.ts +++ b/scripts/smoke-package.ts @@ -445,6 +445,19 @@ async function main(): Promise { `flow.nodes[0]?.id;`, ], }, + './xstate': { + phase: 'optional', + runtime: [ + `const { createMachine } = await import('xstate');`, + `const graph = $MOD.createGraphFromMachine(createMachine({ initial: 'a', states: { a: { on: { NEXT: 'b' } }, b: {} } }));`, + `assert.equal(graph.nodes.length, 2);`, + `assert.equal(graph.edges[0].data.eventType, 'NEXT');`, + ], + types: [ + `const graph: $MOD.MachineGraph = $MOD.createGraphFromMachine({} as any);`, + `graph.nodes[0]?.data.stateIds;`, + ], + }, './queries': { phase: 'core', runtime: [ @@ -615,6 +628,8 @@ async function main(): Promise { 'd3-hierarchy', 'webcola', 'cytoscape', + // XState adapter optional peer + 'xstate', ], { cwd: consumerDir, diff --git a/src/xstate/index.ts b/src/xstate/index.ts new file mode 100644 index 0000000..dee2de8 --- /dev/null +++ b/src/xstate/index.ts @@ -0,0 +1,346 @@ +/** + * XState adapter — one machine → graph path shared by mbt, viz, and PBT. + * + * Requires the optional `xstate` peer dependency (v5). + * + * @module @statelyai/graph/xstate + */ +import { + createEmptyActor, + __unsafe_getAllOwnEventDescriptors, + type AnyMachineSnapshot, + type AnyStateMachine, + type AnyTransitionDefinition, + type EventFromLogic, + type EventObject, + type InputFrom, + type MachineContext, + type SnapshotFrom, + type StateValue, + type UnknownAction, +} from 'xstate'; +import { createGraph } from '../graph'; +import type { EdgeConfig, Graph, NodeConfig } from '../types'; + +/** Serializable projection of a machine snapshot, stored as node `data`. */ +export interface MachineNodeData { + /** Snapshot state value (`'a'`, `{ a: 'b' }`, ...). */ + value: StateValue; + /** Snapshot context, or `undefined` when empty. */ + context: MachineContext | undefined; + /** Resolved ids of every active state node, in document order. */ + stateIds: string[]; + /** Dotted paths of the active atomic/final state nodes, e.g. `['a.b', 'c']`. */ + statePaths: string[]; + /** Snapshot tags. */ + tags: string[]; + /** Snapshot status. */ + status: 'active' | 'done' | 'error' | 'stopped'; +} + +/** A guard rendered as text. `hypothesized` marks that the text is a label, not a verified predicate. */ +export interface MachineGuardData { + text: string; + hypothesized: true; +} + +/** One XState transition definition selected for an edge. */ +export interface MachineTransitionData { + /** Resolved id of the state node that owns the transition. */ + source: string; + /** Resolved ids of the transition targets (`undefined` for targetless). */ + targets: string[] | undefined; + /** Whether the transition re-enters its source. */ + reenter: boolean; + guard?: MachineGuardData; + actions: string[]; +} + +/** Serializable description of a machine step, stored as edge `data`. */ +export interface MachineEdgeData { + /** The event object sent to the machine. */ + event: TEvent; + /** `event.type`. */ + eventType: string; + /** Graph node id of the source state (same as `edge.sourceId`; duplicated for path consumers). */ + sourceNodeId: string; + /** Guard of the first guarded selected transition, when `includeGuards` is on. */ + guard?: MachineGuardData; + /** + * Action labels from every selected transition, in order. Entry/exit actions + * and actions of `always` transitions taken after the event are not listed. + */ + actions: string[]; + /** + * Transition definitions XState selected for the event itself. Eventless + * (`always`) transitions taken afterwards are folded into `targetId` but not + * listed here. Empty for unhandled events. + */ + transitions: MachineTransitionData[]; +} + +export interface MachineGraphOptions { + /** + * Event objects (with payloads) to try. Event types are enumerated from the + * machine's own descriptors at each state and sent as bare `{ type }`; an + * entry here whose type matches a descriptor (exact, `*`, or `prefix.*`) + * replaces that bare event. Wildcard descriptors need a supplied event to be + * explored with a concrete type. + */ + events?: + | readonly EventFromLogic[] + | ((snapshot: SnapshotFrom) => readonly EventFromLogic[]); + /** Node id for a snapshot. Default: JSON of `{ value, context }` (matches `xstate/graph`). */ + serializeState?: (snapshot: SnapshotFrom) => string; + /** Edge id component for an event. Default: `JSON.stringify(event)` (matches `xstate/graph`). */ + serializeEvent?: (event: EventFromLogic) => string; + /** Attach guard text to edges. Default: `true`. */ + includeGuards?: boolean; + /** Skip an event at a state. */ + filterEvents?: ( + snapshot: SnapshotFrom, + event: EventFromLogic, + ) => boolean; + /** Keep the node but do not explore its outgoing events. */ + stopWhen?: (snapshot: SnapshotFrom) => boolean; + /** Max states to expand before throwing. Default: `Infinity`. */ + limit?: number; + /** Machine input for the initial snapshot. */ + input?: InputFrom; + /** Graph id. Default: `machine.id`. */ + id?: string; +} + +export type MachineGraph = + Graph>>; + +function getParameterizedText( + value: string | { type: string; params?: unknown } | ((...args: any[]) => unknown), +): string { + if (typeof value === 'string') return value; + // Inline `guard: () => ...` / `actions: () => ...` get the property name; treat as anonymous. + if (typeof value === 'function') { + return value.name && !['guard', 'actions'].includes(value.name) + ? value.name + : '[inline]'; + } + if (value.params === undefined) return value.type; + return `${value.type}(${JSON.stringify(value.params)})`; +} + +function getGuardData(guard: AnyTransitionDefinition['guard']): MachineGuardData | undefined { + if (guard === undefined) return undefined; + return { text: getParameterizedText(guard as any), hypothesized: true }; +} + +function getActionTexts(actions: readonly UnknownAction[]): string[] { + return actions.map((action) => getParameterizedText(action as any)); +} + +function getTransitionData( + transition: AnyTransitionDefinition, + includeGuards: boolean, +): MachineTransitionData { + const guard = includeGuards ? getGuardData(transition.guard) : undefined; + return { + source: transition.source.id, + targets: transition.target?.map((target) => target.id), + reenter: transition.reenter, + ...(guard ? { guard } : {}), + actions: getActionTexts(transition.actions), + }; +} + +function isMatchingDescriptor(descriptor: string, eventType: string): boolean { + if (descriptor === eventType || descriptor === '*') return true; + if (!descriptor.endsWith('.*')) return false; + const prefix = descriptor.slice(0, -1); + return eventType.startsWith(prefix) && eventType !== descriptor; +} + +/** + * One inert actor scope for the whole traversal, so `self`, `sessionId`, and + * the system stay stable across steps (as they would in a running actor). + */ +function createInertActorScope() { + const self = createEmptyActor(); + return { + self, + logger: () => {}, + id: '', + sessionId: self.sessionId, + defer: () => {}, + system: self.system, + stopChild: () => {}, + emit: () => {}, + actionExecutor: () => {}, + }; +} + +/** Default node id — identical to `serializeSnapshot` from `xstate/graph`. */ +export function getSerializedSnapshot(snapshot: { + value: StateValue; + context?: MachineContext; +}): string { + const { value, context } = snapshot; + return JSON.stringify({ + value, + context: Object.keys(context ?? {}).length ? context : undefined, + }); +} + +function getNodeData(snapshot: AnyMachineSnapshot): MachineNodeData { + const nodes: Array<{ id: string; path: string[]; type: string }> = + (snapshot as any)._nodes ?? []; + const context = snapshot.context as MachineContext | undefined; + return { + value: snapshot.value, + context: Object.keys(context ?? {}).length ? context : undefined, + stateIds: nodes.map((node) => node.id), + statePaths: nodes + .filter((node) => node.type === 'atomic' || node.type === 'final') + .map((node) => node.path.join('.')), + tags: [...snapshot.tags], + status: snapshot.status, + }; +} + +/** + * Create a graph by exhaustively exploring an XState machine: every reachable + * snapshot becomes a node; every `(snapshot, event)` step becomes an edge. + * + * - Events come from the machine's own event descriptors at each state, so + * callers do not enumerate them. Use `events` to supply payloads. + * - Node ids default to the JSON of `{ value, context }` and edge ids to + * `sourceId|serializedEvent|targetId`, so ids are stable across runs and + * `getCoverageTargets` yields diffable state / transition / transition-pair + * targets. + * - Guards are evaluated for real by `xstate`; the guard text on an edge is a + * label (`hypothesized: true`), not a proof that the predicate held. + * + * @example + * ```ts + * import { createMachine } from 'xstate'; + * import { createGraphFromMachine } from '@statelyai/graph/xstate'; + * import { getShortestPaths } from '@statelyai/graph'; + * + * const graph = createGraphFromMachine( + * createMachine({ initial: 'a', states: { a: { on: { NEXT: 'b' } }, b: {} } }), + * ); + * getShortestPaths(graph).map((p) => p.steps.map((s) => s.edge.data.eventType)); + * // [['NEXT']] + * ``` + */ +export function createGraphFromMachine( + machine: TMachine, + options: MachineGraphOptions = {}, +): MachineGraph { + type TSnapshot = SnapshotFrom; + type TEvent = EventFromLogic; + + const serializeState = + options.serializeState ?? (getSerializedSnapshot as (s: TSnapshot) => string); + const serializeEvent = options.serializeEvent ?? ((e: TEvent) => JSON.stringify(e)); + const includeGuards = options.includeGuards ?? true; + const limit = options.limit ?? Infinity; + const extraEvents = options.events; + + const getEvents = (snapshot: TSnapshot): TEvent[] => { + const supplied = + typeof extraEvents === 'function' + ? extraEvents(snapshot) + : (extraEvents ?? []); + const seen = new Set(); + return __unsafe_getAllOwnEventDescriptors(snapshot) + .flatMap((descriptor) => { + const matching = supplied.filter((event) => + isMatchingDescriptor(descriptor, event.type), + ); + return matching.length ? matching : [{ type: descriptor } as TEvent]; + }) + .filter((event) => { + const key = serializeEvent(event); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }; + + const actorScope = createInertActorScope(); + const initial = machine.getInitialSnapshot( + actorScope as any, + options.input as any, + ) as TSnapshot; + const initialId = serializeState(initial); + + const nodes: NodeConfig[] = []; + const edges: EdgeConfig>[] = []; + const visited = new Set(); + const edgeIds = new Set(); + const queue: TSnapshot[] = [initial]; + + const addNode = (id: string, snapshot: TSnapshot) => { + visited.add(id); + const data = getNodeData(snapshot as AnyMachineSnapshot); + nodes.push({ id, label: data.statePaths.join(', ') || String(id), data }); + }; + addNode(initialId, initial); + + let iterations = 0; + while (queue.length > 0) { + const snapshot = queue.shift()!; + const sourceId = serializeState(snapshot); + // A stopped/done actor accepts no events; keep the node, do not expand. + if ((snapshot as AnyMachineSnapshot).status !== 'active') continue; + if (options.stopWhen?.(snapshot)) continue; + if (++iterations > limit) throw new Error('Traversal limit exceeded'); + + for (const event of getEvents(snapshot)) { + if (options.filterEvents && !options.filterEvents(snapshot, event)) continue; + + const selected: AnyTransitionDefinition[] = machine.getTransitionData( + snapshot as any, + event, + ); + const next = machine.transition( + snapshot as any, + event, + actorScope as any, + ) as TSnapshot; + const targetId = serializeState(next); + if (!visited.has(targetId)) { + addNode(targetId, next); + queue.push(next); + } + + const edgeId = `${sourceId}|${serializeEvent(event)}|${targetId}`; + if (edgeIds.has(edgeId)) continue; + edgeIds.add(edgeId); + + const transitions = selected.map((t) => getTransitionData(t, includeGuards)); + const guard = transitions.find((t) => t.guard)?.guard; + edges.push({ + id: edgeId, + sourceId, + targetId, + label: event.type, + data: { + event, + eventType: event.type, + sourceNodeId: sourceId, + ...(guard ? { guard } : {}), + actions: transitions.flatMap((t) => t.actions), + transitions, + }, + }); + } + } + + return createGraph({ + id: options.id ?? machine.id, + mode: 'directed', + initialNodeId: initialId, + nodes, + edges, + }); +} diff --git a/tests/xstate-adapter.test.ts b/tests/xstate-adapter.test.ts new file mode 100644 index 0000000..d14e781 --- /dev/null +++ b/tests/xstate-adapter.test.ts @@ -0,0 +1,486 @@ +import { describe, it, expect } from 'vitest'; +import { assign, createMachine, setup } from 'xstate'; +import { + getShortestPaths as getCoreShortestPaths, + getSimplePaths as getCoreSimplePaths, +} from 'xstate/graph'; +import { createGraphFromMachine } from '../src/xstate'; +import { getShortestPaths, getSimplePaths } from '../src/algorithms'; +import { getOutEdges } from '../src/queries'; +import { getCoverageTargets } from '../src/coverage'; + +const trafficLight = createMachine({ + id: 'light', + initial: 'green', + states: { + green: { on: { TIMER: 'yellow' } }, + yellow: { on: { TIMER: 'red' } }, + red: { on: { TIMER: 'green' } }, + }, +}); + +describe('createGraphFromMachine', () => { + describe('simple machine', () => { + const graph = createGraphFromMachine(trafficLight); + + it('has one node per state and one edge per transition', () => { + expect(graph.nodes.length).toBe(3); + expect(graph.edges.length).toBe(3); + }); + + it('uses the serialized initial snapshot as initialNodeId', () => { + expect(graph.initialNodeId).toBe( + JSON.stringify({ value: 'green', context: undefined }), + ); + }); + + it('uses JSON of { value, context } as node ids', () => { + expect(graph.nodes.map((node) => node.id).sort()).toEqual( + ['green', 'red', 'yellow'] + .map((value) => JSON.stringify({ value, context: undefined })) + .sort(), + ); + }); + + it('records stateIds, statePaths and status on node data', () => { + const green = graph.nodes.find( + (node) => node.id === graph.initialNodeId, + )!; + expect(green.data.stateIds).toEqual(['light', 'light.green']); + expect(green.data.statePaths).toEqual(['green']); + expect(green.data.status).toBe('active'); + expect(green.data.value).toBe('green'); + expect(green.data.context).toBeUndefined(); + expect(green.data.tags).toEqual([]); + expect(green.label).toBe('green'); + }); + + it('labels edges with the event type and mirrors the source id', () => { + for (const edge of graph.edges) { + expect(edge.label).toBe('TIMER'); + expect(edge.data.eventType).toBe('TIMER'); + expect(edge.data.event).toEqual({ type: 'TIMER' }); + expect(edge.data.sourceNodeId).toBe(edge.sourceId); + } + }); + + it('uses sourceId|event|targetId as the edge id', () => { + for (const edge of graph.edges) { + expect(edge.id).toBe( + `${edge.sourceId}|${JSON.stringify(edge.data.event)}|${edge.targetId}`, + ); + } + }); + }); + + describe('hierarchical + parallel machine', () => { + const machine = createMachine({ + id: 'par', + type: 'parallel', + states: { + a: { initial: 'b', states: { b: { on: { NEXT_A: 'b2' } }, b2: {} } }, + c: { initial: 'd', states: { d: { on: { NEXT_C: 'd2' } }, d2: {} } }, + }, + }); + const graph = createGraphFromMachine(machine); + + it('renders dotted state paths for every active atomic region', () => { + const initial = graph.nodes.find( + (node) => node.id === graph.initialNodeId, + )!; + expect(initial.data.statePaths).toEqual(['a.b', 'c.d']); + }); + + it('joins state paths with ", " for the node label', () => { + const initial = graph.nodes.find( + (node) => node.id === graph.initialNodeId, + )!; + expect(initial.label).toBe('a.b, c.d'); + }); + + it('explores the full product of both regions', () => { + expect(graph.nodes.length).toBe(4); + }); + }); + + describe('guards and actions', () => { + const machine = setup({ + types: {} as { events: { type: 'GO' } | { type: 'PARAM' } }, + guards: { + isReady: () => true, + gt: () => true, + }, + actions: { + notify: () => {}, + }, + }).createMachine({ + id: 'guarded', + initial: 'idle', + states: { + idle: { + on: { + GO: { + target: 'active', + guard: 'isReady', + actions: 'notify', + }, + PARAM: { + target: 'active', + guard: { type: 'gt', params: { n: 1 } }, + }, + }, + }, + active: {}, + }, + }); + + it('attaches hypothesized guard text and actions to the edge', () => { + const graph = createGraphFromMachine(machine); + const go = graph.edges.find((edge) => edge.data.eventType === 'GO')!; + expect(go.data.guard).toEqual({ text: 'isReady', hypothesized: true }); + expect(go.data.actions).toEqual(['notify']); + }); + + it('records the selected transition definitions', () => { + const graph = createGraphFromMachine(machine); + const go = graph.edges.find((edge) => edge.data.eventType === 'GO')!; + expect(go.data.transitions).toHaveLength(1); + expect(go.data.transitions[0]).toMatchObject({ + source: 'guarded.idle', + targets: ['guarded.active'], + reenter: false, + guard: { text: 'isReady', hypothesized: true }, + actions: ['notify'], + }); + }); + + it('renders parameterized guards as type(params)', () => { + const graph = createGraphFromMachine(machine); + const param = graph.edges.find( + (edge) => edge.data.eventType === 'PARAM', + )!; + expect(param.data.guard).toEqual({ + text: 'gt({"n":1})', + hypothesized: true, + }); + }); + + it('omits guards entirely when includeGuards is false', () => { + const graph = createGraphFromMachine(machine, { includeGuards: false }); + for (const edge of graph.edges) { + expect('guard' in edge.data).toBe(false); + for (const transition of edge.data.transitions) { + expect('guard' in transition).toBe(false); + } + } + }); + }); + + describe('context and payload events', () => { + const counter = createMachine({ + id: 'counter', + types: {} as { + context: { count: number }; + events: { type: 'ADD'; value: number }; + }, + context: { count: 0 }, + initial: 'idle', + states: { + idle: { + on: { + ADD: { + guard: ({ context }) => context.count < 4, + actions: assign({ + count: ({ context, event }) => context.count + (event.value ?? 1), + }), + }, + }, + }, + }, + }); + + it('replaces the bare event with the supplied payload event', () => { + const graph = createGraphFromMachine(counter, { + events: [{ type: 'ADD', value: 2 }], + }); + for (const edge of graph.edges) { + expect(edge.data.event).toEqual({ type: 'ADD', value: 2 }); + } + // 0 -> 2 -> 4 -> (guard false, self loop) + expect(graph.nodes.map((node) => node.data.context)).toEqual([ + { count: 0 }, + { count: 2 }, + { count: 4 }, + ]); + }); + + it('accepts a function form of events', () => { + const graph = createGraphFromMachine(counter, { + events: (snapshot) => [ + { type: 'ADD' as const, value: snapshot.context.count === 0 ? 3 : 1 }, + ], + }); + const first = graph.edges.find( + (edge) => edge.sourceId === graph.initialNodeId, + )!; + expect(first.data.event).toEqual({ type: 'ADD', value: 3 }); + }); + + it('honors a custom serializeEvent for edge ids', () => { + const graph = createGraphFromMachine(counter, { + events: [{ type: 'ADD', value: 2 }], + serializeEvent: (event) => event.type, + }); + for (const edge of graph.edges) { + expect(edge.id).toBe(`${edge.sourceId}|ADD|${edge.targetId}`); + } + }); + + it('skips events rejected by filterEvents', () => { + const graph = createGraphFromMachine(counter, { + events: [{ type: 'ADD', value: 2 }], + filterEvents: (_snapshot, event) => event.value !== 2, + }); + expect(graph.edges).toHaveLength(0); + expect(graph.nodes).toHaveLength(1); + }); + + it('keeps but does not expand nodes matched by stopWhen', () => { + const graph = createGraphFromMachine(counter, { + events: [{ type: 'ADD', value: 2 }], + stopWhen: (snapshot) => snapshot.context.count >= 2, + }); + expect(graph.nodes).toHaveLength(2); + expect(graph.edges).toHaveLength(1); + const stopped = graph.nodes.find( + (node) => node.id !== graph.initialNodeId, + )!; + expect(stopped.data.context).toEqual({ count: 2 }); + expect(getOutEdges(graph, stopped.id)).toEqual([]); + }); + + it('throws when the traversal limit is exceeded', () => { + expect(() => + createGraphFromMachine(counter, { + events: [{ type: 'ADD', value: 1 }], + limit: 2, + }), + ).toThrow('Traversal limit exceeded'); + }); + }); + + describe('final states', () => { + const machine = createMachine({ + id: 'final', + initial: 'running', + states: { + running: { on: { FINISH: 'done' } }, + done: { type: 'final' }, + }, + }); + const graph = createGraphFromMachine(machine); + + it('marks the final snapshot as done', () => { + const final = graph.nodes.find( + (node) => node.id !== graph.initialNodeId, + )!; + expect(final.data.status).toBe('done'); + }); + + it('has no outgoing edges from the final state', () => { + const final = graph.nodes.find( + (node) => node.id !== graph.initialNodeId, + )!; + expect(getOutEdges(graph, final.id)).toEqual([]); + }); + }); + + describe('stability and coverage targets', () => { + it('produces deep-equal graphs across calls', () => { + const a = createGraphFromMachine(trafficLight); + const b = createGraphFromMachine(trafficLight); + expect(JSON.parse(JSON.stringify(b))).toEqual( + JSON.parse(JSON.stringify(a)), + ); + }); + + it('yields deterministic node and edge coverage targets', () => { + const graph = createGraphFromMachine(trafficLight); + const nodes = getCoverageTargets(graph, { kind: 'nodes' }); + const edges = getCoverageTargets(graph, { kind: 'edges' }); + expect(nodes).toHaveLength(3); + expect(edges).toHaveLength(3); + expect(getCoverageTargets(graph, { kind: 'nodes' })).toEqual(nodes); + expect(getCoverageTargets(graph, { kind: 'edges' })).toEqual(edges); + expect(edges.map((target: any) => target.edgeId).sort()).toEqual( + graph.edges.map((edge) => edge.id).sort(), + ); + }); + + it('yields edge-pair targets whose ids are graph edge ids', () => { + const graph = createGraphFromMachine(trafficLight); + const pairs = getCoverageTargets(graph, { kind: 'edge-pairs' }); + expect(pairs.length).toBeGreaterThan(0); + expect(getCoverageTargets(graph, { kind: 'edge-pairs' })).toEqual(pairs); + const edgeIds = new Set(graph.edges.map((edge) => edge.id)); + for (const target of pairs as any[]) { + expect(target.type).toBe('subpath'); + expect(target.edgeIds).toHaveLength(2); + for (const id of target.edgeIds) expect(edgeIds.has(id)).toBe(true); + } + }); + }); + + describe('parity with xstate/graph', () => { + const bookshelf = createMachine({ + id: 'bookshelf', + types: {} as { + context: { books: string[] }; + events: + | { type: 'BROWSE' } + | { type: 'ADD' } + | { type: 'CHECKOUT' } + | { type: 'RESET' }; + }, + context: { books: [] }, + initial: 'idle', + states: { + idle: { on: { BROWSE: 'browsing' } }, + browsing: { + on: { + ADD: { + guard: ({ context }) => context.books.length < 2, + actions: assign({ + books: ({ context }) => [...context.books, 'Dune'], + }), + }, + CHECKOUT: { + guard: ({ context }) => context.books.length > 0, + target: 'checkout', + }, + RESET: { + target: 'idle', + actions: assign({ books: () => [] }), + }, + }, + }, + checkout: { on: { RESET: { target: 'done' } } }, + done: { type: 'final' }, + }, + }); + + // `xstate/graph` models the initial snapshot as a synthetic + // `xstate.init` step, and emits a path consisting of only that step. + // Our graph starts at the initial node instead, so strip the synthetic + // step and drop the resulting empty sequences from both sides. + const normalize = (sequences: string[][]): string[] => + sequences + .map((sequence) => + sequence[0] === 'xstate.init' ? sequence.slice(1) : sequence, + ) + .filter((sequence) => sequence.length > 0) + .map((sequence) => JSON.stringify(sequence)) + .sort(); + + const graph = createGraphFromMachine(bookshelf); + + it('matches xstate/graph shortest path event sequences', () => { + const core = normalize( + getCoreShortestPaths(bookshelf).map((path) => + path.steps.map((step) => step.event.type), + ), + ); + const ours = normalize( + getShortestPaths(graph).map((path) => + path.steps.map((step) => step.edge.data.eventType), + ), + ); + expect(ours).toEqual(core); + }); + + it('matches xstate/graph simple path event sequences', () => { + const core = normalize( + getCoreSimplePaths(bookshelf).map((path) => + path.steps.map((step) => step.event.type), + ), + ); + const ours = normalize( + getSimplePaths(graph).map((path) => + path.steps.map((step) => step.edge.data.eventType), + ), + ); + expect(ours).toEqual(core); + }); + }); + + describe('review regressions', () => { + it('keeps one actor identity across the traversal', () => { + const machine = createMachine({ + id: 'ident', + context: { actorId: '' }, + initial: 'a', + states: { + a: { + on: { + PING: { + actions: assign({ actorId: ({ self }) => self.sessionId }), + }, + }, + }, + }, + }); + const graph = createGraphFromMachine(machine); + // initial (actorId '') → one PING sets a stable id → further PINGs self-loop + expect(graph.nodes).toHaveLength(2); + expect(graph.edges).toHaveLength(2); + }); + + it('does not expand completed snapshots even if ancestors handle events', () => { + const machine = createMachine({ + id: 'done', + initial: 'a', + on: { RESET: '.a' }, + states: { + a: { on: { FINISH: 'end' } }, + end: { type: 'final' }, + }, + }); + const graph = createGraphFromMachine(machine); + const done = graph.nodes.find((n) => n.data.status === 'done')!; + expect(done).toBeDefined(); + expect(getOutEdges(graph, done.id)).toEqual([]); + }); + + it('matches supplied events against wildcard descriptors', () => { + const machine = createMachine({ + id: 'wild', + types: {} as { events: { type: 'user.login'; user: string } | { type: 'user.logout' } }, + initial: 'out', + states: { + out: { + on: { + 'user.*': [ + { guard: ({ event }) => event.type === 'user.login' && event.user === 'alice', target: 'in' }, + ], + }, + }, + in: { on: { 'user.logout': 'out' } }, + }, + }); + const graph = createGraphFromMachine(machine, { + events: [{ type: 'user.login', user: 'alice' }], + }); + expect(graph.nodes.map((n) => n.data.value).sort()).toEqual(['in', 'out']); + const login = graph.edges.find((e) => e.data.eventType === 'user.login')!; + expect(login.data.event).toEqual({ type: 'user.login', user: 'alice' }); + }); + + it('does not count stopped snapshots against limit', () => { + const graph = createGraphFromMachine(trafficLight, { + limit: 1, + stopWhen: (s) => s.value !== 'green', + }); + expect(graph.nodes).toHaveLength(2); + }); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index ae5c783..8415024 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -35,6 +35,8 @@ export default defineConfig({ 'src/layout/d3-hierarchy.ts', 'src/layout/webcola.ts', 'src/layout/cytoscape.ts', + // XState adapter (optional peer) + 'src/xstate/index.ts', ], exports: { customExports(exports) {