@directive-run/timeline
v0.3.0
Published
Time-travel test REPL for Directive. Auto-renders the causal-graph timeline of any failing test.
Downloads
246
Maintainers
Readme
@directive-run/timeline
Time-travel test REPL for Directive. When a test fails, it auto-prints the full causal chain that got the system into the failing state.
npm install --save-dev @directive-run/timelineWhat it solves
When expect(sys.facts.status).toBe('ready') fails, vitest tells you
"expected 'loading' to be 'ready'." That's not a debugging tool — it's
a riddle.
This package leans on Directive's already-shipped
system.observe(observer) lifecycle stream and renders the recorded
trace inline with the failure. Now you see:
──────── Directive timeline for FAIL ────────
load completes → ready
Timeline 'load completes → ready' — 8 frames over 23ms
[+0.1ms] system.start
[+0.1ms] reconcile.start
[+0.2ms] fact.change status: "idle" → "loading"
[+0.3ms] constraint.evaluate load active=true
[+0.4ms] requirement.created FETCH_INITIAL (req-1)
[+0.5ms] resolver.start initialLoader (req-1)
[+12.3ms] resolver.error initialLoader: backend exploded
[+12.4ms] reconcile.end (0 completed)Now the failure isn't a riddle. The resolver threw, the status fact never advanced, the test correctly observed status="loading."
Frame-capture note.
system.initfires synchronously insidecreateSystem(...)— before you callrecordTimeline(sys, ...), so it is missed by any subscriber registered later. To include it, callrecordTimeline()first against a stub-observable, or accept that captured frames begin at the next observable event (typicallysystem.start). This is a Directive engine ordering, not a timeline bug.
Quick start
1. Wire the reporter (vitest config)
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { TimelineReporter } from '@directive-run/timeline/reporter';
export default defineConfig({
test: {
reporters: ['default', new TimelineReporter()],
},
});2. Record in your test
import { expect, it } from 'vitest';
import { createSystem } from '@directive-run/core';
import { recordTimeline } from '@directive-run/timeline';
it('completes the load chain', async () => {
const sys = createSystem({ module: createMyModule(deps) });
recordTimeline(sys, { id: expect.getState().currentTestName! });
sys.start();
sys.events.LOAD();
await flushAsync();
expect(sys.facts.status).toBe('ready'); // ← if this fails, timeline prints
sys.destroy();
});That's it. The reporter looks up the timeline by the test's full name and renders it on failure.
Why this works
Every Directive System exposes system.observe(observer), a typed
event stream of:
fact.change— every fact write, with prev / next valuesconstraint.evaluate— every constraint predicate runrequirement.created/requirement.met/requirement.canceledresolver.start/resolver.complete/resolver.error— with duration on completioneffect.run/effect.errorderivation.computereconcile.start/reconcile.endsystem.init/start/stop/destroy
This package subscribes to that stream and stamps each event with a monotonic ms offset. The result is a complete causal trace of the system's entire lifetime during the test.
No other state library has this for free. XState has the inspector but it's a separate dev-tools surface, not a test-failure adjunct. RTK has no equivalent. This is Directive's compounding advantage made visible.
Manual / programmatic use
The recorder works without the reporter — useful if you want to inspect a timeline mid-test or attach it to a custom error message:
import { recordTimeline, getTimeline, formatTimeline } from '@directive-run/timeline';
const sys = createSystem({ ... });
recordTimeline(sys, { id: 'load' });
sys.start();
sys.events.LOAD();
await flushAsync();
const out = formatTimeline(getTimeline('load'), { color: false, maxFrames: 30 });
console.log(out);withTimeline(id, sys, fn) is a convenience wrapper that auto-stops
recording when the inner block resolves (or throws):
import { withTimeline } from '@directive-run/timeline';
await withTimeline('my-test', sys, async () => {
sys.start();
sys.events.START();
await flushAsync();
expect(sys.facts.status).toBe('done');
});API
recordTimeline(system, { id })
Subscribe to system.observe(), push every event into a named timeline.
Returns a Timeline with a stop() method. Calling with the same id
twice replaces the previous recording.
getTimeline(id) → Timeline | undefined
Look up a recorded timeline by ID.
clearTimeline(id)
Drop a single timeline from the registry.
clearAllTimelines()
Drop all recorded timelines. Useful in test global setup.
withTimeline(id, system, fn)
Convenience wrapper — records around an async block; auto-stops on resolve / throw.
formatTimeline(timeline, opts?) → string
Render a recorded timeline as a multi-line trace. Options:
| Option | Default | Notes |
|---|---|---|
| color | TTY auto-detect | ANSI color escapes |
| maxFrames | 200 | Truncates long timelines, prints "… N more frames elided" |
| include | all | Filter by event kind: ['fact.change', 'resolver.start'] |
| valuePreviewLen | 80 | Truncate fact-change value strings |
TimelineReporter (from @directive-run/timeline/reporter)
Vitest reporter. On test failure, looks up the timeline by the test's
full name and prints. Constructor accepts the same FormatOptions plus
alwaysPrint: true to print on pass too (useful when a test "passes" but
you suspect it's not exercising what you expect).
Performance notes
- No production cost. The recorder only fires when you call
recordTimeline(). Don't import this in your app code; only test files and devtools. - Bounded memory. Each frame is a small object (timestamp + event).
500 frames per test ≈ 50 KB. The registry holds completed timelines
until you call
clearTimeline/clearAllTimelines. For long test runs, addafterEach(() => clearAllTimelines()). - No fact deep-cloning by default. Fact-change frames hold the
references the engine emits. If your test mutates a fact's nested
contents after the change, the timeline will show the mutated state,
not the at-event state. For the strict at-event view, use
JSON.parse(JSON.stringify(value))snapshots in your handlers.
Causal-graph vitest matchers (R1.B)
Five matchers for asserting against the causal chain a Directive system produced — not just final state. Subpath import:
// vitest.setup.ts
import '@directive-run/timeline/matchers';Or explicit registration:
import { expect } from 'vitest';
import { registerMatchers } from '@directive-run/timeline/matchers';
registerMatchers(expect);Then in tests:
import { recordTimeline } from '@directive-run/timeline';
it('completes in under 50ms with no cascade', async () => {
const t = recordTimeline(sys, { id: 'fast' });
sys.start();
sys.events.LOAD();
await flushAsync();
expect(t).toReachInMs('status', 'ready', 50); // fact reached value
expect(t).toFireConstraint('load'); // fired ≥1 time
expect(t).toFireConstraint('load', { times: 1 }); // exactly N
expect(t).toResolveWithinMs('initialLoader', 50); // resolver budget
expect(t).toMutate('submit'); // mutator dispatch
expect(t).not.toCascade(); // ≥2 constraints same cycle
});Each matcher operates on the recorded ObservationEvent stream — the
same data the formatter renders and replayTimeline re-dispatches. No
other state library has assertion-against-causal-graph for free.
Serialize, replay, bisect, diff
Recorded timelines are JSON-serializable. The package ships four operational entry points that all consume that same JSON:
serializeTimeline() + replayTimeline() — re-dispatch a recorded run
import {
serializeTimeline,
deserializeTimeline,
replayTimeline,
} from '@directive-run/timeline';
// Production: dump the last N seconds of timeline alongside the error.
const json = JSON.stringify(serializeTimeline(timeline));
await fetch('/bug-reports', { method: 'POST', body: json });
// Local repro: parse the JSON, build a fresh system with the SAME
// module shape, replay the recorded events.
const incoming = deserializeTimeline(JSON.parse(prodErrorJson));
const sys = createSystem({ module: createSameModuleAsProd() });
sys.start();
const result = await replayTimeline(incoming, sys);
// result is { dispatched, skipped, truncated } — verify the replay
// actually re-fired what you expected.CLI equivalent: directive replay bug.json --system test/system.ts.
Replay walks frames in order and re-dispatches anything that maps to a
known dispatchable surface (today: @directive-run/mutator-shaped
pendingMutation fact.change frames). Non-dispatchable frames
(system.start, reconcile.start, derivation.compute, ...) are
skipped by default — opt out with { dispatchableOnly: false } for
diagnostic walks.
bisectTimeline() — git-bisect for timelines (R2.A)
Binary-search a recorded timeline for the first frame whose inclusion flips a user-supplied assertion from passing to failing.
import { bisectTimeline, deserializeTimeline } from '@directive-run/timeline';
const bad = deserializeTimeline(JSON.parse(prodCrashJson));
const result = await bisectTimeline(
bad,
// Factory: bisect calls this once per midpoint to get a fresh system.
() => {
const sys = createSystem({ module: counterModule });
sys.start();
return sys;
},
// Oracle: true = good prefix, false = bad prefix.
(sys) => sys.facts.score >= 0,
);
if (result.firstFailingFrameIndex !== undefined) {
console.log(`first failing frame: #${result.firstFailingFrameIndex}`);
} else if (result.noFailureFound) {
console.log('assertion never fails — wrong oracle?');
} else if (result.failsOnEmptyReplay) {
console.log('bug is in initialization — bisect cannot narrow further');
} else if (result.nonDeterministic) {
console.log('two full replays disagreed — fix determinism first');
}CLI equivalent: directive bisect bug.json --system factory.ts --assert 'facts.score >= 0'.
Cost: O(log N) replays of up to N frames each, plus two
full-timeline replays for the determinism gate. The dominant cost in
practice is the factory — your createSystem + start + initial reconcile
runs ~log₂(N) times. Keep the factory cheap (lazy DB/network init,
no real I/O in module factories) or expect bisect of large timelines
to take seconds.
diffTimelines() — semantic causal-graph diff (R2.C)
Compare two serialized timelines as a structured causal-graph report. Not a textual JSON diff — a per-category delta.
import { diffTimelines, deserializeTimeline } from '@directive-run/timeline';
const a = deserializeTimeline(JSON.parse(goodJson));
const b = deserializeTimeline(JSON.parse(badJson));
const diff = diffTimelines(a, b);
if (diff.identical) {
console.log('semantically identical');
} else {
for (const c of diff.constraintFires) {
console.log(`'${c.id}': ${c.aCount} → ${c.bCount} (${c.delta > 0 ? '+' : ''}${c.delta})`);
}
for (const m of diff.mutations) {
console.log(`mutation '${m.id}': ${m.aCount} → ${m.bCount}`);
}
for (const r of diff.resolverRuns) {
console.log(`resolver '${r.resolver}': errors ${r.aErrors}→${r.bErrors}`);
}
}CLI equivalent: directive timeline diff a.json b.json (exit 0 = identical, 2 = differences, 1 = error).
The diff vocabulary mirrors the matcher vocabulary inverted into
reporters: toFireConstraint(id, count) ↔ diff.constraintFires,
toMutate(kind) ↔ diff.mutations, toResolveWithinMs(resolver) ↔
diff.resolverRuns. Same buckets, opposite direction.
Roadmap
v0.2 ships the recorder + formatter + vitest reporter + serialize + replay + bisect + diff + matchers. Shipped surfaces compose: one JSON spec, four operational entry points.
Future versions explore:
- v0.3 — interactive scrubbing: pipe failures into a CLI prompt
with
n/pto step forward/back through frames, showing the facts snapshot at each step. - v0.4 — web UI: a small static page that renders the timeline as a swim-lane diagram. Same data; richer rendering.
- v0.5 — Mermaid sequence-diagram emitter for
diffTimelines(PR-comment-friendly causal diff visualization). - v0.5 — first-class
event.dispatchObservationEvent support (today replay/diff coupling is mutator-shape-based; core will land the canonical wire format).
These all rest on the recorder + JSON. If the data model is right, the frontends compose.
See also
@directive-run/coresystem.observe()— the substrate@directive-run/devtools-plugin— runtime inspector (orthogonal: that's for live apps; this is for test failures)- Testing chained pipelines
License
MIT OR Apache-2.0
