SpectreSkills
← All skills

flow-simulation-and-testing

v1.0.0

Give a flow builder a trustworthy preview — an in-browser simulator that shares the engine's own evaluators, labels every assumption it cannot compute, feeds the field pickers, and sits beside a real journaled test run. Trigger when adding path preview, a test panel, dry runs, or a `{{field}}` picker to a flow builder.

Flow Buildersflow-buildersimulationtestingdry-run
Install
npx @spectre-apps/skills add flow-simulation-and-testing

Writes .claude/skills/flow-simulation-and-testing/SKILL.md. Add --user to install globally.

What it does

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.* and payload.*.

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.