Some code for generating graphs from unit tests runs or other contexts where you want to visualize the dynamic behavior of an XState machine.
node src/address-book.js config |
unflatten |
dot -Gsize=15,15\! -Gdpi=100 -Tpng > graph.png
export class Graph { | |
constructor({ | |
edgeSimplifier, | |
nodeFormatter, | |
edgeFormatter, | |
makeGraphContext, | |
}) { | |
this.edges = []; | |
this.edgeSimplifier = edgeSimplifier; | |
this.edgeFormatter = edgeFormatter; | |
this.nodeFormatter = nodeFormatter; | |
this.makeGraphContext = makeGraphContext; | |
this.nodeMap = []; | |
this.nodes = new Set(); | |
} | |
defineNode(node, config) { | |
this.nodeMap[node] = config; | |
return this; | |
} | |
recordEdge(start, edge, stop) { | |
const simplifiedEdge = this.edgeSimplifier(start, edge, stop); | |
this.edges.push(simplifiedEdge); | |
this.nodes.add(start); | |
this.nodes.add(stop); | |
return this; | |
} | |
dump() { | |
return this.makeGraphContext({ | |
nodes: Array.from(this.nodes) | |
.map((v) => this.nodeFormatter(v, this.nodeMap[v])) | |
.filter((v) => !!v), | |
edges: this.edges.map((v) => this.edgeFormatter(...v)), | |
}); | |
} | |
} |
import { interpret, Machine } from "xstate"; | |
import { Graph } from "./graph.js"; | |
// basic machine: one state, invokes a service. | |
const basicMachine = Machine({ | |
id: "root", | |
states: { | |
"Search Bar": { | |
id: "Search Bar", | |
states: { | |
Inactive: { | |
id: "Inactive", | |
states: {}, | |
on: { | |
focused: "#Active", | |
}, | |
}, | |
Active: { | |
id: "Active", | |
states: { | |
Empty: { id: "Empty", states: {} }, | |
"Text Entry": { | |
id: "Text Entry", | |
states: {}, | |
on: { submitted: "#Results" }, | |
}, | |
Results: { id: "Results", states: {} }, | |
}, | |
initial: "Empty", | |
on: { canceled: "#Inactive", typed: "#Text Entry" }, | |
}, | |
}, | |
initial: "Inactive", | |
on: {}, | |
}, | |
}, | |
initial: "Search Bar", | |
on: {}, | |
}); | |
const graph = new Graph({ | |
nodeFormatter(node, config) { | |
if (config) { | |
return `"${node}"[${config}]`; | |
} | |
}, | |
edgeSimplifier(start, label, end) { | |
return [start, label, end]; | |
}, | |
edgeFormatter(a, b, c) { | |
if (b) { | |
return `"${a}" -> "${c}" [label="${b}"]`; | |
} else { | |
return `"${a}" -> "${c}"`; | |
} | |
}, | |
makeGraphContext({ nodes, edges }) { | |
const nodeBody = nodes.length > 0 ? `${nodes.join(";\n ")};` : ""; | |
return `digraph { | |
${nodeBody} | |
${edges.join(";\n ")}; | |
}`; | |
}, | |
}) | |
.defineNode("<start>", "shape=box,color=green") | |
.defineNode("<stop>", "shape=box,color=red"); | |
export const service = interpret(basicMachine); | |
let transitions = 0; | |
service.onTransition((state, e) => { | |
transitions++; | |
const { length: l1, [l1 - 1]: target } = state.toStrings() ?? ["<start>"]; | |
const { length: l2, [l2 - 1]: source } = state.history?.toStrings?.() ?? [ | |
"<start>", | |
]; | |
graph.defineNode(e.type, "shape=diamond,rank=0"); | |
graph.recordEdge(source, e.type, target); | |
}); | |
service.start(); | |
service.send("focused"); | |
service.send("typed"); | |
service.send("typed"); | |
service.send("canceled"); | |
service.stop(); | |
transitions++; | |
const finalState = service.state.toStrings(); | |
graph.recordEdge(finalState[finalState.length - 1], `${transitions}`, "<stop>"); | |
console.log(graph.dump()); |
{ | |
"name": "demos", | |
"version": "1.0.0", | |
"main": "src/index.js", | |
"type": "module", | |
"license": "MIT", | |
"dependencies": { | |
"xstate": "^4.23.4" | |
} | |
} |