All notable changes are recorded here. This project follows
Keep a Changelog and semantic
versioning. Until 1.0, the public API and the report schema may change
between minor versions; each change will be listed here with a migration note.
A patch: eventlab report now refuses a malformed report by naming the wrong
fields, where it used to crash with an engine message or render NaN into the
timeline. No API change.
eventlab reportcrashed on, or rendered garbage from, a malformed report. It checked the schema version and thatattemptswas an array, then handed everything else to the HTML renderer, which trusts every field. A report whosescenario,attemptIdor outcomekindwas not a string - or whose attempt or outcome wasnull- exited2with a JavaScript engine message such as "Cannot read properties of undefined (reading 'replace')", naming nothing the reader could find in their file. Worse, a missing duration, seed or status rendered the timeline with exit0andNaN,undefined,Infinityor[object Object]in it: a success, reporting values that were never in the file. Reports are exactly the files that travel - saved by CI, attached to issues, edited by hand - so they are untrusted input like saved plans. The command now checks every field the timeline reads and refuses a malformed report with exit2and the fields named:attempts[0].durationMs must be a non-negative number. The check is internal to the CLI; the public API is unchanged.
reportSweep.test.ts, which found the above: a real report with every section populated - a barrier, a failing assertion, a failed delivery, a harness error and a failed teardown - put through every single-field change, fourteen edge values and a deletion each, about 1,450 cases. Before the fix it reported 118 distinct findings. Falsified three ways: skipping the check brings back all 118; dropping only thedurationMsrule fails exactly the sixdurationMscases; dropping only the outcomekindrule fails exactly the six outcome cases.
A patch: parsePlan now refuses malformed saved plans it used to accept, one of
which crashed replay. No API change, and every plan a release ever saved still
loads.
-
A saved plan without
barrierspassedparsePlanand then crashed replay.parsePlantreated the field as optional;runPlanreads it unconditionally, so a hand-edited or truncated plan was accepted and the replay ended in anUnknownharness error - a crash, reported as one - instead of a refusal naming the field. Every planner-2 plan has it, so it is now required. -
parsePlanaccepted planscreatePlancould never have produced. Its documentation says it "checks it is safe to execute", but it did not check several fields the runner relies on:concurrencyonly had to be>= 1, so1.5passed and so did1e999- valid JSON for Infinity, whichJSON.stringifycan never write and a hand-edited file easily can - and ran unbounded; an attempt'sphase,orderandcopyIndexwere not checked at all, though the scheduler groups and sorts on them;transformscould be anything; and a barrier'scheckpointdid not have to be a hook name. Each is now held to whatcreatePlanguarantees. The golden plans from all four releases still parse, so no plan a release ever saved is refused.
savedPlanFuzz.test.ts, which found both of the above. It mutates a valid plan - phases, a barrier with a checkpoint, several transforms - and requires that anythingparsePlanrefuses is refused with aHarnessError, that anything it accepts satisfiescreatePlan's invariants, and that replaying an accepted plan never ends in anUnknownharness error and, when it runs cleanly, delivers every attempt and passes every barrier. Two parts: a deterministic sweep of every single mutation - each path in the plan, each edge value, each deletion - and a random search over combinations. The sweep exists because the random search alone missed a concurrency of1.5or Infinity in 2,000 runs: finding it needs one field and one value, and CI runs 100. Each of the five new checks was falsified by reverting it: the sweep fails for every one at CI's budget, the random search for only two.
The roadmap's 0.4 milestone - framework recipes for Express, Fastify, NestJS and
Next.js, each a runnable test - and the machinery 1.0 will rest on: a committed
API report, golden plans from every release, and a check that a report or plan
shape cannot change without its version moving. The packages themselves change
only in one declaration: the CLI's USAGE is typed string instead of its
literal text.
-
Framework recipes, each a runnable test.
examples/frameworkspoints EventLab at a webhook served by Express 5, Fastify 5 and NestJS 12, andexamples/frameworks/nextjsat a Next.js 16 route handler through a real Next server. One scenario runs unchanged against all four, and every recipe must fulfil each order once under duplicate, concurrent deliveries; accept a signature over a pretty-printed body, which only works if the route reads the raw bytes; refuse the same deliveries signed with the wrong key; and listen on loopback, finishteardowninside its budget, and release its port. Each of those was confirmed to fail when the recipe gets it wrong — JSON parsing instead of raw, no signature check, no deduplication, a close that does not close, Nest withoutrawBody, Fastify without its buffer parser, and a Next route that callsrequest.json()first.pitfalls.test.tsmakes the two raw-body mistakes on purpose and shows how EventLab reports them;scoping.test.tschecks that the raw-body handling stays on the webhook route and the rest of the app still gets parsed JSON. docs/recipes.md walks through all four.Next.js is kept out of the workspace: it is a ~300 MB install plus a build, so it has its own package and lockfile, its own CI job on Ubuntu, and
npm run test:nextjs. The other three run innpm teston every platform.
-
A compatibility policy, docs/compatibility.md, covering the three things people keep - saved plans, JSON reports and code written against the API - plus the CLI, with the check behind every promise.
-
A shape change without a version bump now fails the build. In 0.3 the report gained
limits.teardownTimeoutMsand atimed-outcleanup status whileREPORT_SCHEMA_VERSIONstayed at 2, and only a careful reading of the diff caught it.versionedShapes.test.tsreads the report's types and the saved plan's types out ofetc/eventlab.api.md- whichapi:checkkeeps identical to the built declarations - and compares them with the shape recorded for the currentREPORT_SCHEMA_VERSIONandPLANNER_VERSION. Changing a shape therefore means bumping the version and recording a new snapshot underpackages/core/test/versioned-shapes/; the old snapshots stay, as a history of every version's shape. Replaying the 0.3 mistake - a field added toRunReport, the API report regenerated, the schema version left alone - fails with "the report shape changed but REPORT_SCHEMA_VERSION is still 3. Bump it". -
Golden plans from the released packages. The README's first guarantee - the same seed and inputs produce the same plan - was tested only within a build: the planner's property tests prove determinism and valid shuffles, and would all pass if a refactor changed which valid shuffle a seed produces. PLANNER_VERSION would stay put, and every seed in every report ("rebuild with seed X and planner version Y") would quietly mean a different plan.
tools/golden-plansinstalls a released@masterplaycoding/eventlabfrom npm and writes five plans - every transform, both orders of the order-sensitive ones, a seed near the top of the range, and phases with a barrier - intopackages/core/test/golden-plans/<version>/;goldenPlans.test.tsrequires this build to serialise the same inputs to the same text while the planner version matches, and requiresparsePlanto refuse them once it does not. 0.2.0, 0.2.1 and 0.3.0 planned identically. Confirmed: running Fisher-Yates forwards instead of backwards - still a correct shuffle - fails the three golden tests and passes the other 125. -
The public API is a committed file.
packages/core/etc/eventlab.api.mdandpackages/cli/etc/eventlab-cli.api.mdare API Extractor reports of every exported signature, andnpm run api:check- in CI and before every release - fails when the built declarations no longer match them. Changing the API now means runningnpm run apiand committing the diff, so it is reviewed rather than a side effect; the check was confirmed to fail on a single strayexport const. Two small corrections came with it: a{@link phases}inCreatePlanOptionsthat pointed nowhere, and the CLI'sUSAGE, which was exported as a string literal type - making every wording change to--helpa change to the public API. It is typedstring; the exit codes stay literal, because those are the contract. -
Vitest 5 (from 3), which clears the one advisory
npm auditreported (GHSA-82fw-gwwq-j7x9, in@vitest/mocker; development only - neither published package depends on Vitest). It also exposed that fourteen CLI tests had been passing on a resolution path no user has: they wrote scenario modules to the OS temp directory, whereimport "@masterplaycoding/eventlab"cannot resolve, and Vitest 3 resolved it for them. The CLI loads scenarios with Node's ownimport(), which resolves from the scenario's directory like any user's project would. The tests now write scenarios inside the repository (under the gitignored.eventlab/), so they exercise that path. -
Benchmarks are off the roadmap, replaced by the ceiling tests already in the suite — the response-body and teardown bounds — which fail the build instead of reporting a number. The README's
0.4row says so.
- The quickstart's loopback section could not work as written. It showed
an app started in
hooks.setupalongside abaseUrlknown in advance — but EventLab resolvesbaseUrlbeforesetup, so an app on an ephemeral port has no address to give at that point. It also said to shut down inteardownwhile its snippet shut down inafterEach, and bound every interface rather than loopback. It now starts the app beforerunPlan, closes it inteardown, listens on127.0.0.1, and links the recipes, which are where that shape is tested.
Restarting the application under test mid-scenario, bounded teardown, and
report schema 3 — the one change here that needs a consumer's attention; see
Changed for the migration line.
-
redaction.test.ts: the report's redaction promise, tested by delivering secrets rather than by constructing a fixture that already has none. A real delivery carrying a provider signature, a bearer token and a card number, and none of them appear in the report's JSON or informatReportoutput — which matters more, since that is whatassertRunPassedthrows into a CI log. Header names and body size survive, because a missing signature header and a truncated payload are both real bugs and neither needs the value.It also pins the limit: the request URL is recorded in full, query string included. That is a deliberate trade — a redacted URL would make a delivery unidentifiable — but a provider that authenticates by query parameter puts a credential in the report, and someone attaching one to a public issue should know that before they do. The README's guarantees row now says so.
-
packages/cli/test/html.test.ts: the timeline is a file people forward, and everything in it — scenario names, attempt ids, URLs, assertion messages — comes from outside the process. Nothing tested the escaping that keeps that from being an injection surface. Covers each of those fields against a script payload and an attribute-breaking quote, that&is escaped before the entities escaping introduces, that response bodies stay omitted even though the report holds them, that the document references nothing outside itself, and that bar geometry cannot emitNaNwhen every attempt is instantaneous. -
packages/cli/test/scenario.test.ts: which export is missing, from which file, for each of the six ways a scenario module can be incomplete — plus a file that does not exist, one that does not parse, and one that throws a string. -
Restarting the application under test mid-scenario.
target.baseUrlmay now be a function, resolved once per phase instead of once per run. That one change is what makes a restart testable: a barrier already provides the quiet moment and a checkpoint already runs arbitrary code in it, but a process that came back on a fresh ephemeral port could not be reached afterwards, because the address had been captured before the first delivery.Once per phase rather than once per attempt, because deliveries within a phase run concurrently and must all reach the same process — a per-attempt resolution would let one phase straddle a restart and report deliveries against something that had already gone.
Every resolution is validated, not just the first, so a restart cannot move the target to a host the run was never allowed to touch. An address that is unusable afterwards is a harness error rather than a failed barrier: the application never got the chance to fail anything, and reporting it as a checkpoint failure would blame it for something it did not do.
-
examples/restart: a handler that deduplicates in aSet. It keys on the order rather than the event, so it is not the README's bug — within one process it is correct, and every single-process test passes. What it gets wrong is the lifetime. Restart it and the set is empty while the database still holds every fulfilment it made, so the next redelivery notifies the customer a second time. Every request returns 200 throughout. -
A guarantee-and-limit row for target resolution, and a concepts section on restarts.
-
A
teardownthat never returned maderunPlannever return. Teardown runs afterscenarioTimeoutMshas been cleared — it has to, because a run that timed out still needs cleaning up — so nothing was left to bound it. A hook awaiting something that never settles produced no report at all, which is a worse outcome than any failure a report could have described, and the runner reported it as "test timed out" with no hint of the cause.teardownTimeoutMs(default 5,000) now bounds it, andcleanup.statusgainstimed-out. The message says plainly that EventLab stopped waiting rather than stopped the hook — a promise that never settles cannot be cancelled from outside, so whatever it held it still holds, which is why the process may not exit afterwards.docs/api.mddescribedscenarioTimeoutMsas bounding the "whole run". It never bounded teardown; it now says so.A timed-out teardown fails the run, as a teardown that throws always has, and
formatReportprints it asteardown timed out:. As first written,passedonly excludedfailed, so an overrun left the run green and the formatted report said nothing — CI would print ✓ and then hang on exit, which is the exact "no hint of the cause" this fix set out to remove.passednow lists the acceptable statuses (ok,skipped) instead of the unacceptable one, so a status added later fails until someone decides otherwise. The teardown line is printed after a harness error too, because it is what explains the hang that follows one. -
A large response body cost the whole request timeout. Once the response reader reached
maxResponseBodyBytesit marked the preview truncated and then carried on reading to the end of the body, discarding everything it read. Memory stayed bounded, which is what the report promised; the work did not. Measured against a target streaming 64 KiB chunks, keeping a 1 KiB preview drained 32 MiB — and against a body that never ends, the delivery ran until the request timeout and was reported as a timeout, which is a statement about the application that was simply untrue. It had responded, and responded at once.The reader now stops at the limit and cancels the body. Cancellation is not awaited: it settles when the transport has finished tearing the connection down, and against a target still writing at full speed that takes about as long as the thing being cancelled — which would make the delivery wait for exactly the work it had just decided to stop doing.
The same scenario now completes in about 50 ms rather than 10 seconds, and the target writes under 4 MiB rather than 32. The connection is no longer reusable afterwards, which is the intended trade and is stated in the README.
-
The HTML timeline showed "failed" without a reason whenever the reason was not an assertion. It never rendered
harnessErrororcleanup, so a blocked target, a failed setup or a teardown that threw produced a red verdict above an empty or entirely green assertion list — a timeline that sent its reader to the JSON, which is the one thing it exists to spare them. Both now get a section, escaped like everything else, since a harness message quotes the offending input and a teardown message is whatever the hook threw. -
The library's own tests were never typechecked.
packages/coreandpackages/clicompilesrc/only, and Vitest strips types without checking them, so twenty type errors sat in the tests unseen. Most were stale fixtures, but three made tests weaker than they read: two fixtures built a timeout withelapsedMs(the field isafterMs), so the timeline's "every outcome kind" test renderedtimeout after undefined msand passed because it checked only ids; andsummariseStatuseswas tested against anetwork-erroroutcome that does not exist, never against the realtransport-error.npm run typechecknow also checkstsconfig.test.json, and that timeline test asserts what each row says. -
A scenario module that throws a non-Error reported
undefined. The loader read.messageoff whatever the import rejected with, sothrow "the database was unreachable"in a scenario producedcould not import scenario.mjs: undefined— the one message guaranteed to help nobody. It now falls back to the value itself.
-
REPORT_SCHEMA_VERSIONis3. Reports gainedlimits.teardownTimeoutMs, andcleanup.statuscan now be"timed-out". That is the same kind of change that moved it to2in0.2.0, and the constant's own documentation tells consumers to branch on it — a promise that means nothing if the shape can change underneath an unchanged number.Migration: a consumer that checks
reportSchemaVersionshould accept"3". One that switches exhaustively oncleanup.statusneeds a"timed-out"arm, and should treat it as a failure, aspasseddoes. Nothing was removed or renamed, so a schema-2 reader that ignores unknown fields and values keeps working.PLANNER_VERSIONstays2: the plan did not change shape.baseUrlbecoming a function lives inRunOptions, not in the plan, so every saved0.2.xplan replays unchanged. -
eventlab reportchecks the schema before rendering. It never looked atreportSchemaVersion; it checked only thatattemptswas an array. It now renders schemas 2 and 3 and refuses anything else with exit 2 and a message naming both versions. Newer schemas are refused rather than rendered in part, because what a renderer skips is what it does not recognise — the timeline itself spent0.2.xdroppingharnessErrorandcleanupthat way (see Fixed). A report file containingnullnow says it is not a report instead of surfacing aTypeError. -
resolveBaseUrlis now async and returns aPromise<URL>. Internal; the exported surface is unchanged apart frombaseUrlaccepting a function, which is additive — every existing scenario passing a string is unaffected.
isHarnessErrorwas not safe across realm boundaries, which is the one thing its documentation promised. It testedvalue instanceof Error, and that compares against this realm'sError, so a genuineHarnessErrorcrossing out of anode:vmcontext or a worker was reported as not being one. It now identifies an error byObject.prototype.toString, which is defined on the value's own realm, and requires acodeso the name alone cannot let something else through.- Failure messages from another realm printed the class name. The three
places that turned a thrown value into text all used the same
instanceof Errortest and all fell through toString(cause), rendering an assertion'sexpected 2, found 3asError: expected 2, found 3. The values arriving there are assertion failures and request-builder failures - user code, which is the code most likely to be running somewhere other than this realm.
- The three copies of "get the message off this thrown thing" are now one
describeCause, with the best behaviour of the three: the HTTP client unwrapped a nestedcauseand the other two did not, so a network failure read asfetch failedwhere it now readsfetch failed: ECONNREFUSED 127.0.0.1:3000.
packages/core/test/exports.test.ts, covering five exports that no test referenced:isHarnessError,countDeliveries,deliveriesFor,summariseStatusesand the two version constants. Public, documented, and in the state where behaviour is whatever it happens to be rather than what it says it is. One of the five was wrong.describeCausetests, including the realm case that motivated it.
- An already-aborted
AbortSignalwas ignored. A signal fires its event once, so subscribing alone meant a caller who had already cancelled got the entire scenario executed anyway. Found while testing barriers. - The README printed a failure block no code could produce. Every fact in
it was verified; the rendering was typeset by hand.
formatReportnow produces it, and the acceptance fixture asserts its output equals the block inREADME.md, checked against the packed tarball on every CI job. npm testin an example directory did nothing — neither example package had ascriptsblock, and the README told readers to run it there.- The quickstart could not reproduce the README's bug. It defined two fixtures; the bug requires the third.
docs/concepts.mdnamed the wrong field:EventFixturecarriesid, noteventId.npm run docsalways failed — the script was declared with no TypeDoc configuration. Wiring it up foundRngreachable throughTransform's public signature but not exported.- The examples were never type-checked, so a
possibly undefinedindex read in the voting store went unnoticed.
-
@masterplaycoding/eventlab-cli, a second published package:eventlab runexecutes a scenario module,eventlab replayruns a saved plan against one, andeventlab reportrenders a JSON report as a standalone HTML timeline. Exit codes distinguish "your application failed" (1) from "the scenario could not run" (2). -
A static HTML timeline: one file, no server, no network, no analytics. Response bodies are deliberately omitted even though the report holds them.
-
Barriers.
createPlan({ phases })splits a plan at synchronisation points: everything before a barrier completes before anything after it is released, and a named checkpoint inhooks.checkpointsruns in between. This is the constructive answer to docs/decisions/001 - a scenario waits for a signal the application emits rather than for a duration somebody guessed. -
examples/inbox-outbox: durable inbox/outbox processing that survives a worker killed mid-transaction, onnode:sqlite(built into Node 22+, so no Docker and no install). Turns docs/decisions/003 from a design note into a running example. The library still has no database dependency. -
examples/barrier-settlement: a refund that must not be applied before its payment has settled. Needs a barrier to test at all, because "what is true after the worker finishes" cannot be asked by waiting longer. -
formatReport(report, options?): renders a report as text. Pure, returns a string, no TTY assumptions. -
assertRunPassed(report): throws with the formatted report as its message, so a failing run explains itself instead of producing "expected false to be true".runPlan's never-throws contract is unchanged. -
docs/api.md,docs/troubleshooting.md,docs/extending.md, and a quickstart section on getting an application onto loopback. -
npm publishing with provenance, a tagged release workflow, a release version guard, and RELEASING.md.
PLANNER_VERSIONandREPORT_SCHEMA_VERSIONare both2.DeliveryAttemptgainedphase, and plans and reports gainedbarriers. Saved version-1 plans are refused with the message that mechanism exists for.
createPlan(): seeded, validated expansion of a scenario into a serialisableDeliveryPlan. All randomness is consumed here.- Transforms:
duplicate,shuffle,delay,burst, applied in declaration order and recorded in the plan. runPlan(): HTTP execution with bounded concurrency, per-request and per-scenario timeouts, monotonic timing, cancellation and guaranteed teardown.- Assertions:
check(run once) andeventually(polled to a deadline), reported separately from delivery outcomes. RunReport: schema-versioned JSON with intended and observed timing, four separate outcome categories, and reproduction metadata.serializePlan()/parsePlan()/assertFixturesMatch(): saved plans that refuse to run against a different planner version or edited fixtures.- Examples: a duplicate payment handler in broken and corrected form, and a voting endpoint adapted from GOATalking in vulnerable and corrected form.
- CI on Linux, Windows and macOS with Node 22 and 24, including a packed-tarball install check that runs the README example as a real consumer would.
- Deliveries are loopback-only unless
allowRemoteTargetsis set. - No automatic retries, and redirects are not followed.
- Reports never contain request bodies and record request header names only.
- With no declared expectation, a run passes only if every delivery returned 2xx.
Controlled restart hooks, benchmarks, and recipes for unfamiliar frameworks. See the roadmap in the README.