Changelog¶
All notable changes to vusys/laravel-runabout are documented here. The format follows Keep a Changelog, and the project adheres to Semantic Versioning.
[Unreleased]¶
[0.2.0] - 2026-08-24¶
Changed¶
src/is now split by concern instead of being one flat namespace. Everything a journey author touches keeps its exact fully-qualified name atVusys\Runabout\—Journey,Step,Context,Invariant,Invariants,RunsJourneys,PendingJourney,Actor,Trail,TrailCoverage,HttpDriver, andExceptions\*are all untouched — so journeys, docs, and existing imports need no changes. The machinery behind them moved into namespaces that say what it is:Execution\(JourneyRunner,JourneyInstance,DeferredStack),Randomness\(Draw,DrawSource,StreamDrawSource,ScriptedDrawSource,SeedSchema),Shrinking\(SequenceShrinker,ValueShrinker,FailureSignature),Replay\(TrailToken,TrailArtifact), andSupport\(Environment,TrailReporter). The root directory is now the package's public surface and nothing else, which is the contract the flat layout could not express. Behaviour is unchanged throughout; the existing suite passes untouched apart from imports.TrailShrinkeris nowShrinking\SequenceShrinker, so it reads as a sibling ofValueShrinker— the two are the same algorithm over sequence positions and over drawn values, and the old name described what it shrank a trail of rather than what it shrinks.
Added¶
Randomness\SeedSchema: seed schema v2's five stream derivations, which were private methods onJourneyRunner, are now a class of their own. The schema is a compatibility surface — changing any string built there re-keys every existing artifact — so it is worth being able to point at, andSeedDerivationTestnow pins the two properties that matter (each stream is a pure function of the execution's identity; the picker, teardown, baseline, and execution streams are all distinct).Replay\TrailArtifact: theRUNABOUT_TRAILwire format now has one owner. Encoding lived onTrailand decoding plus validation lived onPendingJourney, so a change to the format meant editing two unrelated classes at opposite ends of the package; both halves of the contract now sit together.Support\Environment: everyRUNABOUT_*variable a run responds to is read in one place, matching whatdocs/environment.mddocuments. The existing asymmetry is preserved and now documented rather than incidental —RUNABOUT_SHRINKis an opt-out where only the exact string"0"disables shrinking, while every other flag is an opt-in where unset, empty, and"0"all read as off.Support\TrailReporter: the two STDERR reporting concerns (RUNABOUT_VERBOSE's per-trail log andRUNABOUT_COVERAGE's end-of-run summary) moved out of the fluent executor.
[0.1.1] - 2026-08-08¶
Fixed¶
- Interleaved canonical order:
runCanonicalran each instance's whole journey before starting the next, so the canonical trail never interleaved and a step gated on another instance's state (only read B's records if B has any) could never be enabled — the run failed immediately with a message that sent you to checkwhen()/after()constraints that were correct. The canonical order now runs round robin across instances by declared position (A's first step, B's first step, A's second, and so on), keeping each instance's own order intact while making the canonical trail an actual interleaving.
[0.1.0] - 2026-07-24¶
Changed¶
Invariants::legalTransitions(): the "not a legal initial state" error now points at the fix for the common cause — a row that exists before the journey and is transitioned by the first step, so the invariant never observes its true initial state. Record it in a leading step.
Added¶
- Value shrinking: after a failing trail is minimised in length, its drawn values are minimised too, so the counterexample is concrete — not "open two deals of random amounts and close the larger" but "open a deal of 51, open a deal of 50, close the larger". Each
randomInt/pickdraw is pushed toward the low end of its domain (ints towardmin, picks toward the first option) by a budgeted, deterministic binary search gated by the same same-failure oracle as sequence shrinking, so it stops at the boundary that still reproduces. Pinned values ride along in theRUNABOUT_TRAILartifact's optional fourth element (existing artifacts stay valid) and show in the failure trail (open opportunity [drew 1, 51]). A step that reaches for the raw$ctx->randomizer()is left value-opaque and skipped. Invariants::uniqueBy($model, $columns): assert no two rows share a column tuple — the guarantee a unique constraint or a firstOrCreate/dedup path is meant to provide. Catches duplicate rows a missing or wrongly-scoped key lets through ("one metric per report", "one leaderboard period per (tenant, start)"). Reads the default scope, so soft-deleted rows may legitimately repeat a live key.Invariant::fromStart(): check an invariant once at the start of the trail, before any step runs, so it observes the world's baseline. The motivating case is a state invariant (Invariants::legalTransitions()) on a row that exists before the journey — without a baseline observation the invariant first sees the row after the opening step, mistaking that step's transition for the initial state. Opt-in per invariant, so existing journeys are unchanged.Invariants::quotaBalances()accepts a per-row starting allowance:$startingmay now be aClosure(TModel): intas well as anint, so quotas that differ by plan, tier, or tenant (the common case) can be expressed without a constant. The existingintform is unchanged.- Automatic trail shrinking: when a shuffled or repeat-heavy trail fails, Runabout minimises it to the shortest subsequence that still reproduces the same failure and leads the failure output with that — "shrunk from 23 executions to 5". It is delta-debugging (coarse chunk removal, then a single-execution polish sweep) with a same-failure oracle (same exception class plus the invariant's or failing step's labelled name; message text excluded, so a candidate that trips a different bug is rejected rather than reported). Deterministic, budget-capped (default 200 candidate replays,
RUNABOUT_SHRINK_BUDGETto change), off for canonical and structural failures, and disableable withRUNABOUT_SHRINK=0. - Explicit-order replay:
PendingJourney::trail($artifact)andRUNABOUT_TRAILreplay one exact trail — a seed plus an ordered token list[[label, step, run], ...]— under a new'replayed'mode, instead of re-deriving a shuffle from a bare seed. Order travels with the artifact, so it reproduces repeat-heavy and partial (shrunk) trails thatRUNABOUT_SEEDcannot;RUNABOUT_TRAIL=@path.jsonreads a large artifact from disk. This is the substrate the shrinker replays candidates through, and the replay line printed under every shrunk failure. - Seed schema v2 (position-independent randomness): the trail seed now derives one picker stream (all order decisions) and a fresh data stream per execution, keyed by (seed, instance label, step name, run index). A step's
randomInt()/pick()/randomizer()draws therefore depend only on which execution it is, never on what ran before it — so a surviving execution reproduces its draws verbatim when the trail is reordered or thinned, which is what makes replay and shrinking sound. A seed still reproduces the whole trail exactly; but because data draws left the shared stream, existing pinnedRUNABOUT_SEEDvalues re-key (the reason to land this while there are no external users). Journey::actors(): declare named actors (name => user) once on the journey and Runabout registers them on every trail's context, so HTTP steps can call$ctx->as('manager')->postJson(...)without a setup step (actors live on the per-trail context, so they otherwise have to be re-registered each trail). The users must exist before the run; anything created inside a trail is rolled back, so register those with$ctx->actingAs()in a step as before.- Per-actor session:
$ctx->actingAs($user, 'agent', ['tenant' => 5])attaches session data that rides along with every request the actor makes, andActor::withSession([...])layers more on for a single request — so journeys can drive apps whose tenancy (or other state) lives in the session, through the full middleware stack, without a per-request session dance. PendingJourney::resetConnections(...$connections)andresetExternal(Closure $cleanup): reset across more than one store between trails — roll back a transaction on each named connection (the multi-connection form of the default reset) and/or run a cleanup for non-transactional stores (a Mongo/Elasticsearch wipe, a cache flush). The two compose;resetExternal()alone still transacts the default connection.- Journey/Step/Context/Invariant core: define a journey's steps as actions plus assertions, and run them in seeded, randomized-but-deterministic orders with invariants checked after every step.
- Precondition-based ordering engine with
after()sugar,when()preconditions,repeatable()steps, and clear deadlock/runaway failures. Step::assertWhen($condition, $then, $otherwise = null): a conditional assertion in the spirit of Laravel'swhen()— when the condition holds$thenmust pass, otherwise$otherwisemust pass (or the step claims nothing when it's omitted).Context::push($key, $value)andContext::list($key): remembered lists without the read-append-write dance — journeys that create several of a thing can accumulate them with one call per step.- Trail observability:
->onTrail(fn (Trail $trail) => ...)receives every completed trail in every mode, andRUNABOUT_VERBOSE=1prints each one to stderr as it finishes — passing runs are no longer a black box. - Aggregate coverage:
TrailCoveragecollects completed trails into a summary (executions per step, distinct orderings, and the step-pair orderings no trail ever explored), andRUNABOUT_COVERAGE=1prints one to stderr when a run finishes — the direct answer to "is the shuffle count buying coverage?". Journey::aroundStep($execution, $context): an overridable per-instance wrapper around every step execution and every check of that journey's invariants — for environment that lives in shared global state but differs per instance, like session-keyed tenancy in interleaved trails. A wrapper that fails to invoke the execution closure is rejected as an invalid journey.- Interleaved invariant violations now carry both instance labels: the invariant's owning instance and the acting step's (
Invariant "A: ..." violated after step "B: ..."). - Seed derivation per journey and trail index,
RUNABOUT_SEEDreplay, andRUNABOUT_RANDOMIZE=1fresh-seed exploration for nightly jobs. - Per-execution teardown stack:
Step::teardown()and$ctx->defer(), run LIFO at the end of the trail, guaranteed on failure and never masking the primary failure. - Actors and HTTP: register named actors with
$ctx->actingAs($user, 'name')and make authenticated requests through$ctx->as('name')->postJson(...); the most recent response is available as$ctx->lastResponse(). - Clock control:
$ctx->travelTo(),$ctx->travel(), and$ctx->travelBack(), automatically unwound at the end of each trail. - Trail reset strategies: transaction rollback by default,
resetByTruncating(...tables)opt-in, andresetWith()for bespoke wrappers. - Built-in invariant library:
Invariants::cachedColumnMatches(),Invariants::quotaBalances(),Invariants::legalTransitions(), andInvariants::trashedLeavesNoLiveChildren(). Invariants::legalTransitions()handles non-string state columns: a nativeBackedEnumis coerced to its value automatically, and astateOfclosure maps any cast value object (such as a spatie ModelState) to its state string — so the helper covers cast state columns without depending on any state-cast library.Step::repeatable(max:, min:)gains a minimum run count: a single always-enabled "advance" step withmingreater than 1 drives a bounded, seeded random walk — the shape a state-machine journey needs, which a plain repeatable step (which runs once when it is the only pending step) cannot express. Defaults tomin: 1, so existing journeys are unchanged.- Execution modes: uniform shuffles,
repeatHeavy()bias toward repeatable steps, per-stepweight(), and boundedexhaustive()enumeration for small journeys. - Failure output with the full trail (repeat counts included), the failing step, the seed, and a one-line replay instruction on shuffled trails (canonical failures reproduce by re-running).
- Interleave mode:
$this->interleave($a, $b)->shuffles(15)->run()merge-shuffles several journey instances (own contexts and actors, shared seed and teardown stack) into one trail with cross-instance invariants — the mode built for tenant-isolation bugs no single journey can expose.