@nwire/test-chaos
v0.9.2
Published
Chaos + failure-mode scenario suite for Nwire — proves the framework degrades gracefully under stress.
Readme
@nwire/test-chaos
Chaos + failure-mode scenarios for Nwire. Every scenario boots a real
createApp + real Runtime + in-memory adapters, then asserts on
runtime.onTelemetry records to prove the framework degrades gracefully.
The runtime is never mocked — this package answers "did the framework actually handle this gracefully?", not "did the mock that we wired up fire?".
Running
pnpm vitest packages/nwire-test-chaosOr run the whole repo suite with pnpm test. The chaos suites are normal
vitest files and contribute to the global green bar.
What each scenario proves
| File | Failure mode | Invariant verified |
| ------------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| slow-handler.test.ts | Handler resolves after long delay; caller aborts early | Caller-side abort doesn't leak; background dispatch completes cleanly; next dispatch on same actor proceeds with up-to-date state; handler that observes ctx.signal bails fast with no DLQ |
| crashed-workflow.test.ts | Workflow body throws inside on() | reaction.failed emits with correlationId; error re-raises; other actions unaffected; declared WorkflowRetryPolicy honored and reaction.exhausted emits on final failure |
| partial-publish.test.ts | bus.publish() rejects after local commit | Actor + projection state persist; error propagates; recovery path exists |
| queue-replay-storm.test.ts | 1000 concurrent dispatches; same-key race | Every event processed; per-correlation-key FIFO order preserved; memory bounded; per-(actor, key, tenant) lock serializes same-key concurrent dispatches |
| dlq-explosion.test.ts | Every handler throws | DLQ accepts N records in order; runtime stays responsive to healthy actions |
| retry-exhaustion.test.ts | retry: { max: 3 } + always-throwing handler | Exactly 4 attempts; attempt-numbered telemetry; one DLQ entry; before/after hooks fire at the dispatch boundary, not the retry boundary |
| idempotency-boundary.test.ts | Same event published twice with same messageId | Apply exactly once; event.deduped telemetry emits on the second delivery; works on both publish and applyExternalEvent paths |
| projection-drift.test.ts | Projection reducer throws mid-batch | Drift is observable via actor.transitioned count > projection.folded; projection rebuilds from event log; projection.failed telemetry emits when a reducer throws |
Framework gaps surfaced by this suite
All five gaps documented in earlier revisions of this README are closed
(Phase 66). Each scenario file calls the corresponding new contract
directly; see repo CHANGELOG.md ([Unreleased]) for the forge changes
per gap.
The closed gaps (kept here as a reference for what each scenario now asserts):
HandlerContext.signal— handlers may observe caller-side cancellation viactx.signal.throwIfAborted(). The runtime checkssignal.abortedbetween retry attempts and skips remaining retries- DLQ when the caller has given up.
WorkflowRetryPolicyhonored —defineWorkflow(name, closure, { retry: { max, backoff, baseDelayMs, maxDelayMs } })retries the saga's_fireper the same back-off mathdispatch()uses for actions; final failure emitsreaction.exhausted.- Envelope-level idempotency —
runtime.idempotencyStore(defaultInMemoryIdempotencyStore) dedups byenvelope.messageIdon both the in-processpublishpath and the bus inboundapplyExternalEventpath. Duplicates emitevent.deduped. projection.failedtelemetry — the runtime wraps each projection fold in try/catch, emits a first-classprojection.failedrecord on throw, and re-raises so the apply path still fails fast.- Per-key actor lock —
ActorStore.lockKey?(actor, key, tenant)gives the runtime a serialization seam aroundload → reduce → save.InMemoryActorStoreimplements it via a per-key Promise chain; persistent adapters (Mongo, file, future SQL) ship a no-op + TODO for storage-native locking.
Adding a new scenario
- Create
src/scenarios/<verb-noun>.test.ts. - Use
harness({ app })from@nwire/test-kitanddefineApp(...)from@nwire/forge. Boot one app perit()block so tests stay isolated. - Compose any helpers you need from
src/helpers.ts(or add new ones there + export fromsrc/index.ts). - Assert on
h.telemetry.count(kind, predicate)andh.telemetry.ofKind(kind). The telemetry stream is the framework's source of truth for "did this work?". - If your scenario relies on a contract the framework doesn't honor
today, mark the
it()with.skipand document the gap in a comment — never break CI on a known gap. Add a row to the gaps table above.
Helpers (re-exported from the package)
slowHandler(action, { ms, emit? })— delays then emits.failingHandler(action, { message? })— throws on every dispatch.flakyHandler(action, { throwTimes, emit? })— throws N times then succeeds.crashingWorkflow(message?)— workflow closure factory that throws.countingHandler(action, emit?)— records every call + input.rejectOnPublishBus(innerBus, error?)— wraps anEventBusso.publish()rejects untilrelease()is called.
License
MIT
