npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@shivaedev/test-story

v0.46.0

Published

Tests that read like stories over the real engine: a story kit per engine with its own it, traits per feature, and a failure that prints the story, where it stopped and the engine's state

Readme

@shivaedev/test-story

Tests that read like short English stories and run the real engine. A spec names its setup in domain words, acts through the application's verbs and lets the engine run, so a human sees at a glance what is tested and an agent writes fewer lines to test more of the stack.

Why you want this

When agents write most of the code, test files grow into hundreds of lines nobody reads. Each test rebuilds its setup by hand, mocks whatever was too much work to build, and hides what it actually checks. A story test holds only what is specific to it:

bakery.it("steps the bakery one minute at a time until the dough runs out", [ovenIsLit(), hasDough(3)], function* ({ oven }) {
	expect(oven.minutes()).toBe(0);

	yield* oven.bakesEverything();

	expect(oven.minutes()).toBe(3);
});

That is a whole test. bakery.it comes from the bakery's story kit. The list holds its traits, ovenIsLit() and hasDough(3), which are written once and shared by every spec. oven.bakesEverything() performs the baking action until the dough is gone, and oven.minutes() reads how long the oven has baked, before and after it. The spec states each expectation. Every story runs the whole engine, so each test also checks the parts its author never thought to mock, and because a spec reads as English, a sloppy test stands out in review.

When a story fails, it prints itself. One of this package's own failing stories expects 2 loaves from one ball of dough, and the test fails with this message:

loaves: expected 1 to be 2 // Object.is equality

╭─ test-story: how to read the story below
│ "given" lines are the test's traits, the other lines were told by verbs and engine steps as they ran, and ✗ marks
│ where the test stopped. The traits, verbs and steps live in the bakery story kit that this test imports.
╰─

  given  the oven is lit
  given  the baker has 1 balls of dough
         1m a loaf comes out of the oven
✗        the test failed after the line above
         at src/test-support/failingStories.ts:12:34

The bakery when the test failed:
{"bowls":1,"capacity":3,"dough":0,"flour":0,"loaves":1,"minute":1,"orders":[],"ovenLit":true,"overfired":false,
"smoking":false,"starter":false}

Rerun: vitest run 'src/test-support/failingStories.ts' -t 'Bakery Story: marks where an assertion stopped the story'

The first line is the assertion's own message. The box under it tells a reader who has never seen the package how to read what follows. Then comes the story: the two traits the test named, the line the bakery told as it baked, and a ✗ where the test stopped, with the spec line it stopped at. Last come the bakery's whole state at that moment and the command that reruns the test, so the failure alone is enough to find the cause.

Using it

Every source module is available through its .ts subpath. Kit authors choose which engine pieces to depend on. The core kit imports Effect submodules directly and leaves DOM, browser, PostgreSQL and OTLP engines out of its module graph. React mounting, member windows, cookie seeding, browser reads and OTLP decoding load their optional implementations when used, so a shared application kit can import an integration without loading its engine.

Before writing or reviewing stories, follow the story-testing skill, shipped by @shivaedev/skills. It owns the rules for how a story reads, observable evidence, boundary-driven setup, verbs and readouts, assertions, jobs, framework gaps, faked preconditions, database isolation and focused runs. The examples below explain this package's API; framework fixtures exercise this package's responsibilities, rather than prescribe application scenarios.

How to think about it

An application has an engine: the real thing a test drives, such as a bakery simulation, a game store or a service with its database. A story is one test's run of a fresh engine. It starts from traits, sentences of setup in domain words that each change the engine, and goes on through verbs, the entry points a user or caller reaches, such as baker.kneads(2) or oven.bakesEverything(). The engine may also run on its own, one step at a time, until something the test waits for holds. Every trait tells a line, and verbs and steps tell what they do, so the story is both the plan the test gave and the trace of what happened, in order.

Every story belongs to a genre, the kind of engine it runs. The kit names each of its tests after its genre, such as Bakery Story: steps until the condition holds, so a report shows which engine a test runs and one run can pick the bakery's stories and leave the others.

The package runs on Effect and Vitest, but an engine needs neither: every hook, trait and test body may be a plain function. Use a generator function to yield Effects when a hook needs a service or fails with a typed error; the kit runs it, so no definition needs Effect.gen. Creation, stage after hooks, inspect and traits may also be plain functions that return an Effect directly. The kit runs that Effect in the story's Scope, with the services from its layer.

The work splits into three layers, and each is written by a different hand at a different time:

  1. Once per engine, a story kit in test-support/ says how to create the engine, which services it needs, and how to seed, step and show it.
  2. Once per feature, traits and verbs in the kit name that feature in domain words.
  3. In every test, a spec tells a story with the kit's it and checks what a user would see.

The verb shape every kit follows

A verb performs one action on the real engine or reads one observation. An action may return its real response, such as an HTTP response; it does not orchestrate a scenario and package the measurements for the spec. A readout reports what happened without deciding whether it was correct. The spec shows each action in order and states every expectation.

Acquire real services in create or a trait inside the story's per-test Scope, with dependencies provided by layer. Keep those services on the engine so verbs use the same running instance; scoped cleanup releases them after the story. Do not build and tear down services inside each verb. When closing a service is the action under test, expose that action separately from acquisition and observation.

| Shape to avoid | What to do instead | | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | A verb runs a whole scenario and returns the measurements the spec asserts. | Give each action and observation its own verb; let the spec compose them and compare the observations. | | A verb hard-codes the expected outcome or hides assertions. | Return the real response or observation, and put expected values and all assertions in the spec. | | A verb does two jobs and returns both results. | Split the jobs into two verbs and show both calls in the spec. | | A verb silently does nothing when its target is missing, such as button?.click(). | Fail with a message naming the missing target, then perform the action only once the target exists. | | An engine default hides what the story declares, such as a lit oven, signed-in user or authorization header. | Start with a neutral engine and name the precondition in a trait or pass the input explicitly in the action. |

The bakery below starts with a cold oven and no dough. Its traits declare heat and dough, its baking action returns no report, and its readouts expose loaves and elapsed minutes separately. runUntil waits for that one engine action to complete; it does not replace a spec's sequence of actions.

Every story gets the effect/crypto services of NodeCrypto.layer, with SecureRandom seeded with 0, created afresh before its wrapper, engine creation, traits and body. StoryCrypto from server/storyIt.ts names those services. A command that yields SecureRandom can therefore produce the same invite code in every story, without a kit-specific random source. UUIDv7 and ULID also read the story's clock; pin it when asserting the complete value. These deterministic values belong only in tests.

1. Once per engine: the story kit

storyKit(definition) from @shivaedev/test-story/server/storyKit.ts turns one definition into the kit. Every type, the engine's, the stages', the verbs' and the services', is inferred from it.

export const bakery = storyKit({
	after: {
		*kitchen(state) {
			if (state.bowls === 0) {
				return yield* new NoBowls();
			}
			state.capacity = state.bowls * DOUGH_PER_BOWL;
		},
	},
	create: emptyBakery,
	layer: Supplier.layer,
	name: "bakery",
	run: {
		diagnose: stillWaiting,
		failed: ovenTrouble,
		maxSteps: 60,
		*step(state, tell) {
			state.minute += 1;
			if (state.overfired) {
				return yield* new BurntLoaf({ minute: state.minute });
			}
			state.dough += state.starter ? 0 : -1;
			state.loaves += 1;
			tell(`${state.minute}m a loaf comes out of the oven`);
		},
	},
	stages: ["kitchen", "pantry"],
	verbs: (state, story) => ({
		baker: {
			kneads: (count: number) => {
				story.tell(`the baker kneads ${count} balls of dough`);
				state.dough += count;
			},
		},
		oven: {
			bakesEverything: (maxSteps?: number): Effect.Effect<void, BurntLoaf> => story.runUntil((current) => current.dough === 0, { maxSteps }),
			loaves: () => state.loaves,
			minutes: () => state.minute,
		},
	}),
});

This is the bakery kit this package tests itself with. emptyBakery, stillWaiting and ovenTrouble are plain functions beside it. kitchen and step are generators because they fail with typed errors, NoBowls and BurntLoaf.

| Hook | Says | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | name | What the engine is called in failures, "the bakery", and the kit's genre, "Bakery Story". | | outbound | Optional. A map of exact hostnames to fake server origins for Node fetch and the DOM environment's document requests. | | outboundPassthrough | Optional. Set to true to let unrouted Node fetch requests reach the network. The DOM environment still refuses external hosts without a route. | | around(effect) | Optional. Wraps creation, staged seeding and the body; worker Layer acquisition remains outside it. It may require the worker services and per-test Scope. | | clock | Optional. "test" by default, or a UTC DateTime to start TestClock at that instant. A story’s options.clock takes precedence. | | create() | A fresh engine. Every story starts from a new one. | | layer | Optional. The worker Effect layer that provides the services hooks, traits and test bodies use. Per-test Scope is already available to creation, staged and run hooks, traits and bodies. A hook that needs a service the layer does not provide does not compile, and neither does a test whose traits or body need one. | | stages | The order traits are applied in. A trait names its stage, and one that names a stage the kit does not declare does not compile. | | after | Optional. A hook per stage that runs once that stage's traits are applied, even when no trait names the stage, so a value it derives is ready before a later stage reads it. A returned native Effect Context provides services to later stages and the body. Its typed failure fails the test. | | run | Optional. What story.runUntil needs: step(engine, tell) advances the engine once and tells what happened, maxSteps caps one run, failed(engine) returns a sentence when the engine is in a state it cannot recover from, and diagnose(engine) says why it has not finished. | | inspect | Optional. The part of the engine a failure prints. Without it, the failure prints a bounded summary without invoking custom inspectors or getters. | | verbs(engine, story) | One action or one observation per verb. A verb that waits for a stepped engine returns the Effect from story.runUntil; the spec reads observations and asserts separately. |

Every test of the kit is named Bakery Story: <its name>, so vitest run -t "Bakery Story: " runs only the bakery's stories with no setup. A project that splits runs by Vitest tags can also tag them:

import { genreTags } from "@shivaedev/test-story/server/genreTags.ts";
import { defineConfig } from "vitest/config";

export default defineConfig({
	test: { tags: genreTags("bakery", "mill") },
});

genreTags(...kitNames) declares one tag per kit, named after it: bakery-story and mill-story here, and game-store-story for a kit named "game store". A kit tags its tests only with a genre the config declares, because Vitest refuses a test whose tag the config leaves out, so a config without the line runs every story untagged and prints nothing about it. With the tags declared, vitest run --tags-filter=mill-story runs the mill's stories and skips the bakery's, and --tags-filter takes any of Vitest's tag expressions, such as '!mill-story'. A root config whose inline projects extend the package's config gets these tags from it, so --tags-filter works from the root too.

Composing feature kits

Compose kits when one story crosses features that already have their own engines, traits and verbs. Keep each feature kit independent; an integration kit imports the kits and their public trait factories, so neither feature needs to import the other's internals.

import { storyKit } from "@shivaedev/test-story/server/storyKit.ts";
import { bakery } from "./bakery.ts";
import { mill } from "./mill.ts";

export const village = storyKit({ extends: [mill, bakery], name: "village" });
import { expect } from "vitest";
import { hasDough, ovenIsLit } from "#test/bakery.ts";
import { village } from "#test/village.ts";
import { sailsAreTurning } from "#test/mill.ts";

village.it("runs the mill's traits when the village joins the mill and the bakery", [sailsAreTurning()], ({ sails }) => {
	expect(sails()).toBe("turning");
});

village.it("keeps the bakery's stepped verbs when the village joins the mill and the bakery", [ovenIsLit(), hasDough(2)], function* ({ oven }) {
	yield* oven.bakesEverything();

	expect(oven.loaves()).toBe(2);
});

The kit lives in test-support/village.ts, and the specs import it with the mill's and the bakery's traits. extends takes one or more kits. The composed engine includes each kit's engine fields, the body receives all their verbs, and traits from either kit work directly. Types for engines, stages, verbs and layer services remain inferred. A composed kit can itself appear in extends.

Creation runs in dependency order, left to right, before any traits. Stages and their after hooks then run in that same order, regardless of the order traits appear in the spec. All creation, traits, hooks and the body share one per-test Scope, whose finalizers release resources in reverse acquisition order, including after a failed story. Feature verbs retain their own runUntil hooks, so the bakery's waiting verb still steps the bakery. Use each feature's waiting verbs when driving a composed story.

Top-level engine fields, verb names and trait stage names must be unique across the kits. A collision fails typechecking with a message such as Cannot compose story kits: duplicate verb names and the conflicting name. Give stages feature-specific names when composing; a trait's stage selects its feature's setup hooks. Traits are each feature kit's own kit.trait values and kit.traits combinations.

Routing outbound HTTP

When application code calls a hard-coded third-party URL, set outbound on its kit to send those requests to a fake server:

const app = storyKit({
	...appDefinition,
	outbound: { "github.com": fakeUrl, "api.github.com": fakeUrl },
});

appDefinition holds the application's kit hooks, and fakeUrl is the listening fake server's HTTP or HTTPS origin, such as http://127.0.0.1:4321. The map matches exact URL hostnames, across ports. It replaces the destination origin while keeping the request's path, query, method, headers and body. For example, a POST to https://github.com/login/oauth/access_token reaches the fake's /login/oauth/access_token. When a kit declares outbound, only loopback hosts (localhost, IPv4 loopback addresses and [::1]) may pass through without a route; every other hostname must have an outbound fake. Node kits can opt into network access for unrouted hosts with outboundPassthrough: true. Composed kits inherit that opt-in from their constituent kits. The DOM environment always refuses unrouted external hosts. A refused request fails the story and names its hostname, even when application code catches the fetch error.

routeOutbound(routes) from @shivaedev/test-story/server/routeOutbound.ts installs the same routing in the current Effect Scope. Acquire a fakeHttpService in create, then yield routeOutbound({ "api.example.com": api.url }) so the fake listener and its routing belong to that story. Pass { passthrough: true } as its second argument to allow unrouted Node fetch requests.

Separate registrations compose: every active registration's hosts remain reachable. If registrations name the same host, the newest active registration wins. Closing a scope removes only its registrations, revealing any earlier route for that host. Unrouted external hosts remain refused while any active registration requires strict routing.

Routing uses the global dispatcher that Node's built-in fetch already uses. The kit's outbound option installs routing before around, creation, traits and the body, stays active during scoped cleanup, and removes its registrations after each story, including a failing story. The original dispatcher is restored when the last registration closes. Stories within one worker must run one at a time because that dispatcher is shared; parallelism belongs between workers or inside one story. The DOM environment uses the same routing for host-side Node fetch and document requests, including redirect destinations. It refuses external hosts even when the kit omits outbound.

Asserting on application traces

Start an in-process host in create or a stage's after hook, such as after: { deployment }. Creation and every after hook run without the ambient parent span, so an Effect HTTP handler that captures its context does not inherit a span placed around the story. Their scoped teardown and finalizers retain that detached context. The tracer, services and per-story Scope remain available. Traits, verbs, engine steps and assertions in the story body keep the span supplied by around.

This lets an application's story assert that a request starts its own trace: a request without trace headers starts a root trace, and a request with traceparent continues that trace. Host startup and cleanup do not join the story's trace.

A kit can collect spans with a native in-memory tracer, or start a fake OTLP receiver and point the application's exporter at it. Expose collection, exporter flush and any bounded wait as separate verbs. Assert only after the request span has ended. This small in-memory kit uses Effect's Web Request handler as its host:

import { Effect, Option, Tracer } from "effect";
import { HttpEffect, HttpServerResponse } from "effect/http";
import { expect } from "vitest";
import { storyKit } from "@shivaedev/test-story/server/storyKit.ts";

function describeSpan(span: Tracer.NativeSpan) {
	const status = span.status._tag === "Ended" ? "ended" : "still open";
	if (Option.isNone(span.parent)) {
		return `a root span, ${status}`;
	}
	const parent = span.parent.value;
	return parent._tag === "ExternalSpan"
		? `under external span ${parent.spanId} in trace ${parent.traceId}, ${status}`
		: `under "${parent.name}", ${status}`;
}

const app = storyKit({
	around: (effect) => effect.pipe(Effect.withSpan("story")),
	*create() {
		const spans: Tracer.NativeSpan[] = [];
		const tracer = Tracer.make({
			span: (options) => {
				const span = new Tracer.NativeSpan(options);
				spans.push(span);
				return span;
			},
		});
		const context = yield* Effect.context<never>().pipe(Effect.withTracer(tracer));
		const handler = HttpEffect.toWebHandlerWith(context)(Effect.succeed(HttpServerResponse.text("ok")));
		return { handler, spans };
	},
	name: "traced application",
	stages: ["ready"],
	verbs: ({ handler, spans }, story) => ({
		sendsRequest: (headers: HeadersInit = {}) =>
			Effect.promise(async () => {
				story.tell("a caller sends a request");
				await (await handler(new Request("http://app.test/", { headers }))).text();
			}),
		serverSpans: () => Effect.sync(() => spans.filter((span) => span.kind === "server").map(describeSpan)),
	}),
});

app.it("starts each request in a root span of its own", [], function* ({ sendsRequest, serverSpans }) {
	yield* sendsRequest();

	expect(yield* serverSpans()).toEqual(["a root span, ended"]);
});

app.it("continues the trace context a request's headers carry", [], function* ({ sendsRequest, serverSpans }) {
	yield* sendsRequest({ traceparent: "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01" });

	expect(yield* serverSpans()).toEqual(["under external span 1234567890abcdef in trace 1234567890abcdef1234567890abcdef, ended"]);
});

describeSpan turns each span into words once in the kit, so the specs compare sentences instead of reading span fields and tags. Detachment only removes the incoming parent: spans that application startup explicitly creates still follow native Effect semantics.

Visiting an app over HTTP

Use httpVisitor from @shivaedev/test-story/server/httpVisitor.ts in the kit's scoped create or after hook after starting the real app. Each call acquires a separate cookie jar and a separate edge proxy, which closes with the calling Scope:

import { httpVisitor } from "@shivaedev/test-story/server/httpVisitor.ts";

const ada = yield* httpVisitor(upstream, {
  baseUrl: "https://app.example",
  headers: {
    origin: "https://foreign.example",
    "x-forwarded-for": "198.51.100.7",
    "x-real-ip": "192.0.2.4",
  },
  edge: { forwardedHops: ["203.0.113.9"] },
});

const bob = yield* httpVisitor(upstream, { baseUrl: "https://app.example" });

upstream is the app's local HTTP listener. baseUrl is the public URL the visitor sees; it defaults to upstream. HTTPS here models TLS termination at the edge: transport to the local app remains HTTP. Requests present that public URL through X-Forwarded-Host and X-Forwarded-Proto. Secure cookies are accepted and sent only for HTTPS public URLs, including when the hostname is localhost. Ordinary and HttpOnly cookies are kept automatically, scoped by host/domain and path, and updated or removed by the app's Set-Cookie headers. Cookie expiry uses the story's Effect Clock. The visitors never share cookies.

headers supplies visitor request defaults; a request's own headers override them without case sensitivity. In this example the app receives Origin https://foreign.example, X-Forwarded-For 198.51.100.7, 203.0.113.9, and X-Real-IP 192.0.2.4. edge accepts ForwardingProxyOptions: its headers override the visitor's headers, and forwardedHops chooses which addresses the edge appends. Give each visitor its own edge policy to test trusted proxy boundaries.

yield* ada.request("/session") returns a native Response in an Effect. It accepts a path or an absolute public URL and optional fetch request options for method, headers, body and signal. Every request goes through this visitor's edge to the same upstream app; an absolute URL changes the public host/scheme used for headers and cookie selection. Redirects are returned without following them, and their cookies are retained, so the next story action can explicitly visit the Location. There is no credentials or redirect option. This HTTP client does not enforce browser CORS or SameSite policy.

Keep requests and response decoding inside the application's verbs and readouts. A verb performs one action, and a readout translates the HTTP answer into domain words such as "signed in as ada" or "signed out"; specs never inspect Exit, Cause or _tag shapes. For example, an application's kit exposes as(name).signsIn() and as(name).session() over its visitor:

app.it("keeps ada's session separate from bob's", [visiting("ada"), visiting("bob")], function* ({ as }) {
	yield* as("ada").signsIn();

	yield* outcomes([
		["ada's session", () => as("ada").session(), "signed in as ada"],
		["bob's session", () => as("bob").session(), "signed out"],
	]);
});

The kit awaits the HTTP answer directly; it needs no sleeps, polling or manual Cookie header copying. For DOM rendering and browser sign-in flows, use the document and member-screen transports below.

Controlling a forwarding proxy directly

Use forwardingProxy from @shivaedev/test-story/server/forwardingProxy.ts in the kit's scoped create or after hook after starting the host under test:

import { forwardingProxy } from "@shivaedev/test-story/server/forwardingProxy.ts";

const proxy = yield* forwardingProxy(upstream, {
  localAddress: "::1",
  forwardedHops: ["203.0.113.9", "2001:db8::1"],
  headers: {
    host: "internal.test",
    "x-forwarded-host": "app.example",
    "x-forwarded-proto": "https",
  },
});

Point story requests at proxy.url. The proxy listens on IPv4 and IPv6 loopback on the same allocated port, forwards the request's method, path, query, body and headers to upstream, and returns the host's status, headers and body. It appends the connecting caller's address to x-forwarded-for, preserving any existing chain. Set forwardedHops to append those addresses in order instead of the caller, so a client chain of spoofed with hops visitor and edge becomes spoofed, visitor, edge. The optional headers map overrides incoming and generated headers, including x-forwarded-for when the story needs a fixed chain.

Set localAddress to choose the source socket address of the proxy's connection to the host. The upstream must accept that address family; for a hostname, the proxy resolves the matching family. Use 127.0.0.1 or ::1 for portable loopback stories. An unavailable local address fails acquisition with a message naming it. The proxy returns 502 if it cannot connect to the host. Both listeners close with the story's Scope.

Reading OTLP exports

Use otlpReceiver from @shivaedev/test-story/server/otlpReceiver.ts in a kit's scoped create hook. It listens on an allocated loopback port and closes with the story scope. Build the application's tracing layer with receiver.tracerLayer(options?), providing native OTLP serialization and an HTTP client:

const receiver = yield* otlpReceiver();
const tracing = receiver.tracerLayer({ resource: { serviceName: "documents" } }).pipe(
  Layer.provide(OtlpSerialization.layerJson),
  Layer.provide(FetchHttpClient.layer),
);
const context = yield* Layer.build(tracing);

Import Layer from effect, OtlpSerialization from effect/observability and FetchHttpClient from effect/http. Provide context to the application's work. The layer accepts native tracer options except the destination URL and compression; the receiver supplies its own URL. Use OtlpSerialization.layerProtobuf for protobuf exports. The layer includes Effect's tracer logger, so logs become span events even when the surrounding test logger omits it. Logger changes stay inside the provided context, and the story's Scope releases the exporter.

Expose receiver.flush as a separate story step. It exports buffered traces and waits on receiver signals for every sampled span that ended before the step, including exports already in flight. It leaves tracing running for later actions. Finish the traced operation before flushing; a span still running has nothing to export. This step tracks exporters built with receiver.tracerLayer; an independently configured exporter still needs its own flush.

app.it("exports a leaked credential and omits a redacted credential", [], function* ({ tracesSecret, flush, exportsSecret }) {
	yield* tracesSecret("redacted credential", true);
	yield* tracesSecret("leaked credential", false);
	yield* flush();

	yield* outcomes([
		["redacted credential exported", () => exportsSecret("redacted credential"), false],
		["leaked credential exported", () => exportsSecret("leaked credential"), true],
	]);
});

Here app is the application's kit. tracesSecret performs one traced action, and the kit exposes the receiver's readouts through its own domain verbs. Readouts return values and the spec states expectations:

| Readout | Returns | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | receiver.exportsSecret(value) | Whether the value occurs in any received export body. It searches raw bytes and decoded JSON keys and values, including escaped strings and fields outside the decoded spans. It also matches URL-encoded and form-encoded values. Transport headers are excluded. | | receiver.queries() | SQL text from db.query.text or the older db.statement attribute, in received span order. Repeated queries remain repeated. | | receiver.errors() | Exception events as { operation, message, service, type }, where operation is the span name, service is the resource service.name (undefined when absent), and type is the optional exception type. A failed span without an exception event contributes its status message (or span name), with no type. Status and exception representations of the same failure produce one entry. |

The receiver accepts OTLP/HTTP JSON and protobuf. spans flattens all exports into decoded trace and span IDs, parent span IDs, names, numeric OTLP kinds and status codes, attributes, events and links. Attributes retain OTLP key/value objects, including lossless decimal strings for integers; each span also has resourceAttributes. requests retains every request's unmodified body Buffer, parsed headers, original rawHeaders pairs and path. Optional path, status (a number or attempt callback) and answer configure collector responses for failure stories.

For timeout stories, return undefined from the status callback to accept an export without sending response headers. Set answer: null to send headers without completing the response body.

Faking an outbound HTTP API

Use fakeHttpService from @shivaedev/test-story/server/fakeHttpService.ts in a kit's scoped create hook. Like the OTLP receiver, it listens on an allocated loopback port and closes with the scope. Routes have names for controls, a method (default GET), and a path whose :segments become decoded request.params. Plain :name matches exactly one segment. A final :name* matches one or more remaining segments, decodes each segment and joins them with / in params.name: /repos/:owner/:repo/contents/:path* matches nested content such as contents/a/b/package.json. A rest parameter cannot appear before the last segment. A reply receives the request and returns a JSON body (or a Uint8Array/Buffer sent as raw bytes), optional status (default 200), and optional response headers.

import { fakeHttpService } from "@shivaedev/test-story/server/fakeHttpService.ts";

const api = yield* fakeHttpService({
  guard: ({ headers }) => headers.authorization === "Bearer story-key",
  routes: {
    user: { path: "/users/:id", reply: ({ params }) => ({ body: { id: params.id } }) },
    items: { path: "/items", pages: [["paper", "ink"], ["pens"]] },
  },
});

Point the application's real HTTP client at api.url, or use the kit's outbound map to route its external hostname there. A service-wide guard returns a boolean; a missing or wrong credential gets 401 before holds or scripted replies are consumed. Each route can also declare a guard(request); both guards must accept the request before holds, arrival scripts or faults run. A route's guard applies only to that route, so an installation token check can sit beside public routes. Omit the guard for a public fake. Unmatched paths and methods get 404.

If a fake handler throws, the service records the failure and closes the connection. Scope close fails the story with the request and the thrown value, even if the application handles the connection error. Return an error status or script a fault to simulate an upstream failure.

In verbs, bind api.narrate(story.tell) to tell received requests, replies, rejections and hold controls through the story. An optional tell callback can also be supplied at acquisition. The service keeps request headers and bytes in api.requests. Each entry has a status: the numeric status of a completed response, "pending" while waiting (including a stalled body), or "unanswered" when the connection closes without completing a response. Dropped connections, resets after headers and clients that hang up are unanswered. Statuses include guard rejections, unmatched routes and replies replaced by faults. Readouts can format entries as GET /repos/o/r/actions/runs/7/artifacts 500. Narration of normal requests includes only method, path and status.

api.narrate(story.tell);
yield* api.scriptFault("user", { kind: "status", reply: { status: 502, body: { error: "try again" } } });
const paused = yield* api.hold("user");
const pending = yield* Effect.forkChild(application.loadUser("alice"));
yield* paused.waiting;
yield* paused.release;
const user = yield* Fiber.join(pending);

application.loadUser is the application's real client call. scriptFault affects the next authorized matching request to that named route, then the route answers normally. hold pauses delivery until release; the route's reply handler runs when the request arrives, so a held reply sees the request and state at arrival. waiting completes when the first request reaches the hold. For several arrivals, observe received as described under waiting for a signal. Other routes still answer. HTTP stories use the kit’s controlled clock and wait for request or response signals; the same clock controls the fake’s reply time.

Use the controls through kit verbs after acquiring the service. scriptFault(route, { kind: "status", reply }) scripts the next authorized reply, including raw bytes or chunks, a synchronous or asynchronous iterable of strings and byte arrays. Set headers["content-type"] to the story's media type; the default is JSON. Chunks are written in iteration order as they become available, so an async iterable can wait between pieces.

yield* api.scriptFault("download", {
  kind: "status",
  reply: {
    body: Uint8Array.from([0, 255, 128]),
    headers: { "content-type": "application/octet-stream" },
  },
});
const stalled = yield* api.stall("download");
const pending = yield* Effect.forkChild(application.download());
yield* stalled.waiting;
yield* stalled.release;
const bytes = yield* Fiber.join(pending);

stall(route) sends the reply's status and headers, then pauses its body. Its waiting effect completes at that pause. Its release lets the body continue; a client that gives up also stops waiting on the server. Like a hold, a stall remains installed for matching requests until released. Holds pause before headers. releaseOnArrival(heldRoute, arrivingRoute, scope?) releases the current hold when an authorized request reaches the named route; script it before that arrival. Rejected requests do not trigger release.

fault(route, fault) acts on an arrived request in the required phase. { kind: "status", reply: { status: 503, body: { error: "unavailable" } } } replaces a held reply before headers. { kind: "drop" } drops a connection before headers. { kind: "reset" } terminates a reply after headers, including after body chunks have arrived. Wait for the hold, stall or client observation before injecting the fault. A fault fails with guidance when no request is in the required phase; it does not queue a failure for a future call. Use scriptFault to queue a reply for a future call.

hold(route, scope), stall(route, scope), fault(route, fault, scope), scriptFault(route, fault, options) accept an optional { params: { number: "7" } }. Every supplied parameter must equal the decoded request parameter. Other resources on the same named route answer normally and do not consume the scoped reply. Scoped holds and stalls return waiting and release. hold(route, { once: true, params: { number: "7" } }) holds only the first matching request; later matching requests answer while it remains held. Omit params to hold only the first request across the route. Omit once to keep holding every matching request until release. Pass the same parameter scope as the third argument to releaseOnArrival to release that hold.

Use scriptFault(route, fault, options) to install a fault for future authorized matching requests. It returns a control with a narrated lift effect. The default strikes once before the route's reply runs; { repeat: true } keeps it installed until lifted. Status faults can repeat too.

A status fault’s reply can be a fixed FakeHttpReply or a function (request, now) => FakeHttpReply. A scripted function runs at arrival, before any hold, using the arriving request and milliseconds from the Effect Clock captured when the service was created. A repeating fault rebuilds its reply for every request. An immediate fault evaluates its function when injected, using the waiting request and the clock at injection. TestClock stories therefore get deterministic refusal times:

yield* api.scriptFault("user", {
  kind: "status",
  reply: (request, now) => ({
    status: 429,
    headers: { "x-ratelimit-reset": String(now + 60_000) },
    body: { user: request.params.id },
  }),
});
const outage = yield* api.scriptFault("pull", { kind: "drop" }, {
  params: { number: "7" },
  repeat: true,
});
yield* application.loadPull("7");
yield* outage.lift;

yield* api.scriptFault("comment", {
  kind: "status",
  reply: { status: 503, body: { error: "try later" } },
}, { params: { number: "7" }, phase: "after" });
yield* application.postComment("7", "Ready for review");

With phase: "after", the route's reply function runs and applies its effects before the fault strikes. A status fault replaces the reply, a drop closes before headers, and a reset sends the route's status and headers then closes its unfinished body. The comment therefore exists even though the client sees a failure. This lets an application story check retry behavior after a side effect. The default phase: "before" skips the reply function when the fault strikes.

All controls validate route names and narrate through the service's story sink. Closing the scope closes active connections and releases holds, stalls and arrival scripts.

A pages route accepts a one-based ?page= query and returns { items, next }, with next: null on the last page. No query means page 1; a page past the end returns an empty list; a non-positive or non-integer page gets 400. api.requests reads a snapshot of the request log, including rejected and held requests, with method, path (including query), raw body Buffer, headers, params and query.

2. Once per feature: traits and verbs

A trait is a line and a change, tagged with a stage:

export function hasDough(count: number) {
	return bakery.trait("pantry", `the baker has ${count} balls of dough`, (state) => {
		if (count > state.capacity) {
			throw new Error(`the bowls hold only ${state.capacity}`);
		}
		state.dough = count;
	});
}

export function hasFlourDelivered(sacks: number) {
	return bakery.trait("pantry", `the supplier has delivered ${sacks} sacks of flour`, function* (state) {
		const supplier = yield* Supplier;
		yield* supplier.deliver(sacks);
		state.flour = sacks;
	});
}

export function morningShift() {
	return bakery.traits(ovenIsLit(), hasBowls(1));
}

hasDough is a plain function. hasFlourDelivered is a generator because it reaches the Supplier service, which the kit's layer provides. traits(...) bundles traits into one, so a setup that recurs gets one name. A test may name traits in any order: [hasDough(6), hasBowls(2)] fills the bowls only after the kitchen has them.

A trait that throws, fails or dies refuses the setup instead of seeding a state the application could never reach. This package's failing story bakery.it("marks the trait that refused", [ovenIsLit(), hasDough(4), hasBowls(1)]) asks for more dough than one bowl holds and fails with:

the trait "the baker has 4 balls of dough" refused to set up the bakery: the bowls hold only 3
help: a trait throws when the bakery it asks for cannot exist. Give the test traits that fit together, or fix the trait
      in the bakery story kit if this bakery should be possible.

╭─ test-story: how to read the story below
│ "given" lines are the test's traits, the other lines were told by verbs and engine steps as they ran, and ✗ marks
│ where the test stopped. The traits, verbs and steps live in the bakery story kit that this test imports.
╰─

  given  the oven is lit
✗ given  the baker has 4 balls of dough
         refused at src/test-support/failingStories.ts:15:57
  given  the bakery has 1 bowls

The bakery when the test failed:
{"bowls":1,"capacity":3,"dough":0,"flour":0,"loaves":0,"minute":0,"orders":[],"ovenLit":true,"overfired":false,
"smoking":false,"starter":false}

Rerun: vitest run 'src/test-support/failingStories.ts' -t 'Bakery Story: marks the trait that refused'

The first line names the trait and what it threw, which the error also keeps as its cause, and the help: line says what to change. The ✗ marks the trait that refused, and the line under it points at the spec line that named it. A trait that fails with a string, a plain object or a tagged error prints by its content, never as [object Object], and an interrupted trait stays interrupted.

A verb that a feature adds goes into the kit's verbs. It acts through the engine's real entry point and tells a line, so the story shows it.

3. In every test: a spec

bakery.it("tells each step into the story", [ovenIsLit()], function* ({ baker, oven, story }) {
	baker.kneads(2);
	yield* oven.bakesEverything();

	expect(story.lines).toEqual([
		"the oven is lit",
		"the baker kneads 2 balls of dough",
		"1m a loaf comes out of the oven",
		"2m a loaf comes out of the oven",
	]);
});

kit.it(name, traits, body, options) declares one Vitest test, named after the kit's genre: Bakery Story: tells each step into the story. It starts a fresh engine, seeds the traits stage by stage and runs the body with the kit's verbs and the story: its engine, lines, tell(line), runUntil, apply(traits) and restart(traits). A spec reads the engine through the kit's readouts rather than story.engine, so each check names what it observes. The body's second argument is Vitest's test context. A body that yields Effects, such as a verb that runs the engine, is a generator, and one that yields nothing is a plain function. A test that only checks that its traits refuse may leave the body out. options is a timeout in milliseconds or Vitest's test options, whose own tags join the genre tag when the config declares it. it.fails, it.only, it.skip, it.runIf(condition) and it.skipIf(condition) work as they do in Vitest.

story in a spec carries the kit's stage and verb types. Story<Engine, Run, Stage, Verbs> is the explicit form when a helper needs those controls; an existing Story<Engine, Run> annotation remains usable for engine reads, narration and runUntil.

yield* story.apply(traits) applies more traits to the current engine, using the kit's stage order and running each stage's after hook again. Bundles open in place and tell their lines at this point in the story. A refusal marks the mid-story trait that failed.

yield* story.restart(traits) creates a fresh engine from the same kit, applies the supplied traits and returns its verbs and story. It uses the current test's layer and Scope. Earlier engines stay readable; their resources and the restarted engine's resources close in reverse acquisition order when the test ends.

bakery.it("compares two ovens and adds dough after baking", [ovenIsLit(), hasDough(1)], function* ({ oven, story }) {
	yield* oven.bakesEverything();
	expect(oven.loaves()).toBe(1);

	const second = yield* story.restart([ovenIsLit(), hasDough(2)]);
	yield* second.oven.bakesEverything();
	expect(second.oven.loaves()).toBe(2);
	expect(oven.loaves()).toBe(1);

	yield* second.story.apply([hasDough(1)]);
	yield* second.oven.bakesEverything();
	expect(second.oven.loaves()).toBe(3);
});

A spec starts a story, acts through verbs and checks what a user would see. When it needs a setup or an action the kit lacks, add a trait or verb to the kit instead of building it in the spec.

Use captureOutcome from @shivaedev/test-story/server/captureOutcome.ts to turn an Effect's outcome into your kit's domain words. Supply onSuccess(value), onFailure(error), onDefect(defect, cause) and onInterrupt() renderers. Return the value unchanged from onSuccess to keep it. renderOutcome(exit, renderers) applies the same renderers to an existing native Exit. Expected failures take precedence over defects in a combined Cause; defects take precedence over interruption. Pure interruption calls onInterrupt. Capture returns an Effect with no expected error channel and retains the original service requirements.

The general answered, answerOf, causeWords, resultOf, errorWords, decodedErrorWords, words and countWords helpers live in @shivaedev/test-story/server/answers.ts. A kit with its own vocabulary supplies its renderers to captureOutcome instead.

For several outcomes after one action, use outcomes from @shivaedev/test-story/server/outcomes.ts. Each row is [label, () => readout(), expected]. The list states the run order, independently of alphabetical key sorting:

import { outcomes } from "@shivaedev/test-story/server/outcomes.ts";

accounts.it("removing Bob leaves Alice signed in", [bobAndAliceAreSignedIn()], function* ({ remove, request, sessions }) {
	yield* remove("bob");

	yield* outcomes([
		["bob's next request", () => request("bob"), "unauthorized"],
		["bob's sessions", () => sessions("bob"), 0],
		["alice's next request", () => request("alice"), "ok"],
	]);
});

outcomes builds and runs each readout only when its row starts, so a verb that tells its line before returning an Effect still prints in row order. It compares all results with one native Vitest toMatchObject: objects match partially at every depth, arrays keep their length and order, and asymmetric matchers such as expect.any(Number) still work. Each expected value is checked against its own readout’s success type. Repeated labels remain separate rows. A failing readout keeps its typed error and stops later rows; a mismatch shows a labelled diff and the story report points to the outcomes call.

Import lessThan, atMost, greaterThan or atLeast from @shivaedev/test-story/server/adapters/outcomeMatchers.ts to state numeric bounds, including inside nested partial values:

import { atMost, lessThan } from "@shivaedev/test-story/server/adapters/outcomeMatchers.ts";
import { outcomes } from "@shivaedev/test-story/server/outcomes.ts";

accounts.it("removing Bob leaves no sessions within the request budget", [bobIsSignedIn()], function* ({ remove, details, elapsed }) {
	yield* remove("bob");

	yield* outcomes([
		["bob’s account", () => details("bob"), { account: { sessions: atMost(0) } }],
		["request time", () => elapsed(), lessThan(100)],
	]);
});

lessThan and greaterThan exclude the limit; atMost and atLeast include it. A bound mismatch prints its name and limit beside the received value. For one outcome, keep a plain expect:

accounts.it("removing Bob ends his session", [bobIsSignedIn()], function* ({ remove, request }) {
	yield* remove("bob");

	expect(yield* request("bob")).toBe("unauthorized");
});

Pinning time

Stories and kits never wait on real time: use the controlled clock for elapsed time and engine signals for completion. Real sleeps, wall-clock polling, Date.now() and real timers in test code are forbidden; a deadline that only bounds failure is allowed.

Pass a UTC DateTime as the story's clock option to pin time before its wrapper, creation, traits and hooks run. Code that uses Clock or DateTime.now reads that instant. Advance it with native TestClock.adjust in a kit verb, so the spec names the time that passes:

import * as TestClock from "effect/testing/TestClock";

verbs: (engine, story) => ({
	aDayPasses: () => {
		story.tell("a day passes");
		return TestClock.adjust("1 day");
	},
}),
import { DateTime } from "effect";

app.it(
	"a day later, the worker shows the next date",
	[],
	function* ({ aDayPasses, click, settle, text }) {
		yield* click("button");
		yield* settle();
		expect(text("#worker")).toBe("Worker: since Oct 17");

		yield* aDayPasses();

		yield* click("button");
		yield* settle();
		expect(text("#worker")).toBe("Worker: since Oct 18");
	},
	{ clock: DateTime.makeUnsafe("2026-10-17T12:34:56.000Z") },
);

Acquire hosts, workers and screens in per-story Scope so they inherit the story's Clock. An independent runtime or native HTTP handler must capture the Effect Context at acquisition, as with HttpEffect.toWebHandlerWith(context) or Effect.runPromiseWith(context). Worker-scoped kit layer acquisition stays outside the per-story clock; shared services acquired there do not acquire a new clock for each story. Direct JavaScript wall-time reads are not replaced. Formatting stays with the application, including its locale and time zone.

Long jumps also expire signed-in sessions. With Better Auth, use signInAgain(auth, userId) from @shivaedev/test-kits/identity/adapters/betterAuth.ts to mint a fresh session on the caller’s Clock for the same user. Replace the visitor’s headers, or pass its returned cookies to seedCookies for a browser. The starter’s app kit exposes signsInAgain(name) for this step. Its TestClock starts at the present by default so Better Auth’s wall-clock endpoints accept minted sessions; an explicit story clock still takes precedence.

Waiting for a timer

Every story uses watchedClock automatically, including stories that pin a UTC instant. It instruments the native TestClock and publishes every sleep start as a native Duration through its consuming sleeps stream. Starts stay queued until consumed, so a wait can observe a sleep that began before it subscribed. Each story gets its own queue.

Use untilTimer(interval) in a kit verb to wait for the next matching sleep start before advancing time. It consumes starts in arrival order, skipping other durations. Equivalent durations such as "1 second" and "1000 millis" match. A start records that the sleep was called; it does not mean the timer is still pending. untilTimer returns the matching Duration and never advances virtual time. Its default deadline is five seconds of live time; pass { timeout } to change it. If no matching sleep starts, it fails with SignalNotReceived, naming the missing sleep and the last observation.

passes(interval, then) advances the native TestClock by the interval, then awaits the supplied Effect and returns its result. Give it the task's completion signal so the verb waits for what the elapsed time triggers:

import { Queue } from "effect";
import { passes, untilTimer } from "@shivaedev/test-story/server/watchedClock.ts";

verbs: (engine, story) => ({
	untilDeliveryTimer: () => {
		story.tell("wait until the delivery timer starts");
		return untilTimer("1 second");
	},
	twoSecondsPass: () => {
		story.tell("two seconds pass and the delivery completes");
		return passes("2 seconds", Queue.take(engine.delivered));
	},
}),
deliveries.it("delivers after its timer fires", [], function* ({ send, untilDeliveryTimer, twoSecondsPass }) {
	yield* send("parcel");
	yield* untilDeliveryTimer();

	const delivered = yield* twoSecondsPass();

	expect(delivered).toBe("parcel");
});

Outside a story kit, yield* watchedClock instruments the active native TestClock within Scope; provide the returned value as Clock.Clock within its Scope. It preserves the active clock's time, adjustments and withLive behavior. Acquire it under TestClock.layer() to retain the native scheduler. Sleeps inside withLive do not enter the watched queue.

Running the engine

Hooks and verbs wait for real workers through the engine's completion signals: take from a queue the worker offers to, await a Deferred it completes, or use until on its observable stream. Keep these waits in the kit under domain names. A deadline bounds a failed wait; it never proves completion. For React stories, pass the application's pending signal to a screen driver, await settle() after an action, then read the screen and assert in the spec.

story.runUntil(condition, { maxSteps }) returns an Effect that asks three questions before every step:

  1. run.failed returns a sentence: it dies with it, even when the condition also holds.
  2. The condition holds: it succeeds.
  3. maxSteps steps have run, or the kit's run.maxSteps when the call names none: it dies with run.diagnose.
the bakery ran 12 steps and never reached what runUntil waits for: 1 balls of dough still wait after 12 minutes: the
  sourdough starter never runs out
help: either the bakery never gets there, so check the setup and the engine, or it needs more steps, so pass a larger
      maxSteps to runUntil.

That is what oven.bakesEverything(12) dies with when the baker keeps a sourdough starter, so the dough never runs out. The typed failures of step, failed, diagnose and the condition stay in the Effect's error channel: a step that fails with BurntLoaf gives the test a BurntLoaf to assert on. A kit without run hooks dies with a message that says to add them.

Waiting for a signal

Use until in a kit verb to consume a native stream through its first matching value. collectUntil collects values in arrival order and stops when its predicate matches the collection, including the value that completes it. Supply a name that says what the story is waiting for.

import { SubscriptionRef } from "effect";
import { collectUntil, until } from "@shivaedev/test-story/server/until.ts";

const ready = yield* until(
	SubscriptionRef.changes(status),
	(value) => value === "ready",
	{ name: "worker ready" },
);
const deliveries = yield* collectUntil(
	deliveryStream,
	(values) => values.some((delivery) => delivery.id === requestedId),
	{ name: "requested delivery", timeout: "2 seconds" },
);

The helpers subscribe by running the stream before inspecting its values. Use a stream that includes the current value, such as SubscriptionRef.changes or AtomRegistry.toStream, when readiness may already hold. They release the subscription on completion or timeout. Their default deadline is five seconds of live time; timeout accepts an Effect duration. Only the deadline uses live time: the source keeps the story's clock, and neither helper advances its TestClock.

A deadline or stream ending without a match fails with SignalNotReceived in the error channel. Its message names the missing signal, shows the last observation (the collection for collectUntil, or that no value arrived), and includes a help: line. Source failures pass through.

Use untilAtom from @shivaedev/test-story/server/untilAtom.ts to wait for an atom's current or next matching value through its registry subscription:

import { Effect } from "effect";
import * as AtomRegistry from "effect/reactivity/AtomRegistry";
import { untilAtom } from "@shivaedev/test-story/server/untilAtom.ts";

const ready = yield* untilAtom(status, (value) => value === "ready", { name: "worker ready" }).pipe(
	Effect.provideService(AtomRegistry.AtomRegistry, registry),
);

It releases the subscription on success, timeout or interruption. An AsyncResult failure fails the wait with AtomFailed, carrying the signal's name and native cause. A missing match uses the same SignalNotReceived diagnostics as until.

Waiting for process output, spans and abandoned requests

Acquire lineSignals(child.stdout) or lineSignals(child.stderr) in the kit's scoped create hook before driving the child. Its untilLine(matches, options) waits for a complete line and returns it; lines reads the captured lines. It handles lines split across writes, CRLF, UTF-8 characters split across writes, and the final line without a newline. Closing the story scope closes the line reader; the kit owns the child process lifetime.

import { lineSignals } from "@shivaedev/test-story/server/lineSignals.ts";

const output = yield* lineSignals(child.stdout);
const readyLine = yield* output.untilLine(
	(line) => line === "Worker ready",
	{ name: "worker readiness", timeout: "2 seconds" },
);

The OTLP receiver offers untilSpan(matches, options) and returns the decoded matching OtlpSpan. Use it to wait for a real exporter without polling receiver.spans.

const completed = yield* receiver.untilSpan(
	(span) => span.name === "process-order",
	{ name: "order processing span" },
);

fakeHttpService exposes two Effect streams of FakeHttpRequestRecord: received emits once the request body is read and path parameters are decoded, before guards and holds; completed emits when the server finishes writing the response, including its body. Receipt includes guard rejections, unmatched routes and malformed path parameters. A held request has arrived without completing; a stalled body has sent headers without completing. Each record's status reflects its current state, just like requests.

const arrived = yield* until(
	service.received,
	(request) => request.path === "/orders/42",
	{ name: "order request received" },
);
const completed = yield* until(
	service.completed,
	(request) => request.path === "/orders/42",
	{ name: "order response completed" },
);

Import until and collectUntil from @shivaedev/test-story/server/until.ts. To wait for several new arrivals to the same path, drop the already recorded requests before filtering and collecting. Capture this stream before starting the requests:

const arrivals = service.received.pipe(
	Stream.drop(service.requests.length),
	Stream.filter((request) => request.path === "/orders/42"),
);
yield* collectUntil(arrivals, (requests) => requests.length === 3, { name: "three order requests received" });

Both streams replay observations from acquisition to each subscriber independently. A wait can therefore start before or after arrival or completion; observing one request does not consume another observer's signal. Use different predicates to wait for individual requests, or drop earlier observations as above when requests share a path. These waits use until's live-time deadline and SignalNotReceived diagnostics.

untilAbandoned(matches, options) returns the request record when the connection closes before its response finishes, including a held request or a stalled response body. Completed responses do not emit this signal, and abandoned responses do not emit completed. An unfinished connection closed by a scripted fault also qualifies; the signal observes the connection, not who closed it.

const abandoned = yield* service.untilAbandoned(
	(request) => request.path === "/orders/42",
	{ name: "abandoned order request" },
);

All three adapters retain observations from acquisition, so a wait can start before or after its matching event. Each wait observes independently. They use until and its live-time deadline and SignalNotReceived diagnostics. Ending child output without a match fails immediately with the last line; a missing span or abandoned request fails at the deadline with the last observation. A silent source reports that no values arrived.

When a story fails

After the engine has been created, a failing story test adds the story to its first error, as in the examples above:

  • a short guide to reading the story, in a box;
  • every line in order, traits marked given;
  • ✗ after the last line, on the trait that refused, or alone when the test failed before the story told a line, and under it the spec line where the test stopped or that named the refusing trait;
  • the engine's state from inspect as JSON, broken between its fields to fit 120 columns. State longer than 2,000 characters is written to node_modules/.cache/test-story/ under the working directory, and the failure names the file. If writing fails, the report keeps the story and sh