Simulating and testing a flow
Two panels, clearly labelled: the simulated path (instant, assumptions noted) and the real run (slower, authoritative). People need both — the first to build with, the second to believe.
Share the engine's evaluators, or don't claim exactness
Put the deterministic node evaluators in a package both sides import. Then the simulator does not approximate a rule branch — it calls the same function the engine will.
conditional.rule, transform.remap, split.experiment, trigger → EXACTLY (shared code)
fetch, ai.*, agent → labelled placeholder
conditional.ai → assumes default_branch
wait_until, wait.approval, guard.frequency → assumes the declared outlet
wait, wait.window → suspends at runtime; skipped
Prove it with a parity property test: generate a few hundred random rule graphs, run both the engine's walk and the simulator, and assert identical routing. That test is what lets the UI say "exactly".
Be honest about everything else
Every step in the trace carries what it assumed and whether the value shown is real:
export interface SimStep {
nodeId: string;
nodeType: NodeType;
branch: string | null;
added: string[]; // paths this step wrote
note: string | null; // why this outlet — shown verbatim in the panel
approximate: boolean; // the value is a placeholder, not a result
vars: Vars;
}
Read the assumption from the catalog, not from a switch in the simulator, so renaming an outlet cannot leave the preview pointing at an edge that no longer exists:
function assumed(type: NodeType) {
const declared = BLOCK_BY_TYPE[type]?.assume; // { outlet, note }
return declared
? { branch: declared.outlet, note: declared.note, approximate: true }
: { branch: null, note: "resolved at runtime", approximate: true };
}
Write unresolvable values as a visible sentinel — ⟨ resolved at runtime ⟩ http
— never as a plausible fake. A made-up value that flows into three downstream
templates produces three convincing lies.
Keep the switch exhaustive with node satisfies never, so a new node type
must be taught how to simulate rather than silently taking the first outlet
while the engine picks a real branch. And guard the walk (a fixed step ceiling):
a cycle in a half-built graph must render an incomplete trace, not hang the tab.
The simulator is also the field picker
Every template input needs to know which vars paths exist at this node.
That is the same walk, stopped early:
export interface PathInfo {
path: string;
kind: "event" | "runtime" | …; // 'runtime' renders in the warning colour
fromLabel: string; // the step that contributes it
upstream: boolean; // from a branch this node cannot be reached by
}
pathsAt(graph, sampleEvent, nodeId): PathInfo[]
Offer upstream paths too — someone building a branch often references a field a sibling branch writes, and hiding it is more confusing than marking it. Attribute every path to the step that wrote it; a bare list of dotted strings does not tell you which step to fix.
Each node type declares what it writes via the catalog's contributes(config),
so the picker lists enrichment.summary the moment someone types it into
output_key.
Path preview on the canvas
Reuse the same SimResult: highlight pathNodeIds / pathEdgeIds, dim
everything else to ~25%, and make the traced path the canvas's one accent
colour. It is a toggle in the canvas foot — Preview path / Hide path — and
it computes nothing when off.
const sim = React.useMemo(() => (preview ? simulateVars(graph, SAMPLE_EVENT, undefined, flowId) : null),
[preview, graph, flowId]);
Memoise on a graph value that excludes positions (see
flow-builder-canvas), or every frame of a drag re-runs a whole-flow
simulation, and every keystroke in a config field re-runs it too.
The real run
The second tab runs the actual engine against the saved graph, so save first:
// A server test runs the SAVED graph, so save first if there are edits.
if (dirty) await onSave();
const { runId } = await testRun({ id: flowId, event: sampleEvent });
// then poll the run until its status is terminal
Say what a test run does and does not do, on the panel:
Runs the real engine on the saved flow — real journal, real waits (shortened), real settlement. Nothing is sent outside; deliveries are echoed.
Render the journal as it is: one row per journaled step with its outlet, status, latency and error, then the delivery attempts with their status codes. And explain the empty case, because it is not a bug:
No steps were journaled — pure steps are replayed rather than recorded, so a flow of only rules and mappings writes nothing here.
Label the button for what it will do — Run on server or Save and run on server — so an unsaved edit never disappears into a test.
The sample event
Keep one editable sample event in a third tab, as JSON, and say what it is:
This whole object becomes the event's starting fields — which is why the field picker offers
customer.*andpayload.*.
Parse it on every change; a broken sample disables the run button and says "The sample event is not valid JSON" rather than failing at the server.
What makes the whole thing trustworthy
- The simulated panel never shows a value it did not compute.
- The real panel never shows a value it did not read from the journal.
- Both are labelled, side by side, and never merged into one "result" view.
- Anything assumed says why it was assumed, in the words the catalog declared.