@shivaedev/test-story 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.1 - 2026-10-18
4
+
5
+ ### Fixed
6
+
7
+ - Publish against `@shivaedev/effect-test` 0.6.0 so framework consumers share the current test runner.
8
+
9
+ ## 0.7.0 - 2026-10-18
10
+
11
+ ### Added
12
+
13
+ - `fakeHttpService` serves a scoped loopback API for story kits, with named path-param routes, an auth guard, JSON pages, one-shot replies, request holds and an ordered request log. Bind `narrate(story.tell)` to tell requests and controls in the story.
14
+
15
+ ### Changed
16
+
17
+ - Point application story authors to the shared `story-testing` skill and align README guidance with observable results, explicit assertions and database isolation.
18
+
3
19
  ## 0.6.0 - 2026-10-18
4
20
 
5
21
  ### Added
package/README.md CHANGED
@@ -8,11 +8,13 @@ When agents write most of the code, test files grow into hundreds of lines nobod
8
8
 
9
9
  ```ts
10
10
  bakery.it("steps until the condition holds", [ovenIsLit(), hasDough(3)], function* ({ oven }) {
11
- expect(yield* oven.bakesEverything()).toEqual({ loaves: 3, minutes: 3 });
11
+ yield* oven.bakesEverything();
12
+ expect(oven.loaves()).toBe(3);
13
+ expect(oven.minutes()).toBe(3);
12
14
  });
13
15
  ```
14
16
 
15
- 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()` is a verb that runs the bakery's real code until the dough is gone. 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.
17
+ 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; `oven.loaves()` and `oven.minutes()` read separate observations. The spec states both expectations. 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.
16
18
 
17
19
  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:
18
20
 
@@ -28,7 +30,7 @@ loaves: expected 1 to be 2 // Object.is equality
28
30
  given the baker has 1 balls of dough
29
31
  1m a loaf comes out of the oven
30
32
  ✗ the test failed after the line above
31
- at src/test-support/failingStories.ts:10:59
33
+ at src/test-support/failingStories.ts:12:34
32
34
 
33
35
  The bakery when the test failed:
34
36
  {"bowls":1,"capacity":3,"dough":0,"flour":0,"loaves":1,"minute":1,"orders":[],"ovenLit":true,"overfired":false,
@@ -41,6 +43,8 @@ The first line is the assertion's own message. The box under it tells a reader w
41
43
 
42
44
  ## Using it
43
45
 
46
+ Before writing or reviewing application stories, follow the [story-testing skill](https://github.com/ShivaeDev/platform/blob/main/packages/skills/skills/story-testing/SKILL.md), shipped by `@shivaedev/skills`. It owns the rules for 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.
47
+
44
48
  ### How to think about it
45
49
 
46
50
  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.
@@ -55,6 +59,22 @@ The work splits into three layers, and each is written by a different hand at a
55
59
  2. **Once per feature**, traits and verbs in the kit name that feature in domain words.
56
60
  3. **In every test**, a spec tells a story with the kit's `it` and checks what a user would see.
57
61
 
62
+ ### The verb shape every kit follows
63
+
64
+ 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.
65
+
66
+ 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.
67
+
68
+ | Shape to avoid | What to do instead |
69
+ | --- | --- |
70
+ | 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. |
71
+ | 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. |
72
+ | A verb does two jobs and returns both results. | Split the jobs into two verbs and show both calls in the spec. |
73
+ | 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. |
74
+ | 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. |
75
+
76
+ 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.
77
+
58
78
  ### 1. Once per engine: the story kit
59
79
 
60
80
  `storyKit(definition)` from `@shivaedev/test-story/storyKit.ts` turns one definition into the kit. Every type, the engine's, the stages', the verbs' and the services', is inferred from it.
@@ -95,8 +115,10 @@ export const bakery = storyKit({
95
115
  },
96
116
  },
97
117
  oven: {
98
- bakesEverything: (maxSteps?: number): Effect.Effect<BakeReport, BurntLoaf> =>
99
- story.runUntil((current) => current.dough === 0, { maxSteps }).pipe(Effect.map(() => ({ loaves: state.loaves, minutes: state.minute }))),
118
+ bakesEverything: (maxSteps?: number): Effect.Effect<void, BurntLoaf> =>
119
+ story.runUntil((current) => current.dough === 0, { maxSteps }),
120
+ loaves: () => state.loaves,
121
+ minutes: () => state.minute,
100
122
  },
101
123
  }),
102
124
  });
@@ -116,7 +138,7 @@ This is the bakery kit this package tests itself with. `emptyBakery`, `stillWait
116
138
  | `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. Its typed failure fails the test. |
117
139
  | `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. |
118
140
  | `inspect` | Optional. The part of the engine a failure prints. Without it, the failure prints the whole engine. |
119
- | `verbs(engine, story)` | The entry points a test acts through. A verb that runs the engine returns the Effect from `story.runUntil`. |
141
+ | `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. |
120
142
 
121
143
  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:
122
144
 
@@ -152,7 +174,7 @@ Start an in-process host in `create` or a stage's `after` hook, such as `after:
152
174
 
153
175
  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.
154
176
 
155
- 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. Keep collection and any exporter flush or bounded wait in the kit's verbs. Assert only after the request span has ended. This small in-memory kit uses Effect's Web Request handler as its host:
177
+ 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:
156
178
 
157
179
  ```ts
158
180
  import { Effect, Tracer } from "effect";
@@ -160,44 +182,48 @@ import { HttpEffect, HttpServerResponse } from "effect/http";
160
182
  import { expect } from "vitest";
161
183
  import { storyKit } from "@shivaedev/test-story/storyKit.ts";
162
184
 
163
- const spans: Tracer.NativeSpan[] = [];
164
- const tracer = Tracer.make({
165
- span: (options) => {
166
- const span = new Tracer.NativeSpan(options);
167
- spans.push(span);
168
- return span;
169
- },
170
- });
171
-
172
185
  const app = storyKit({
173
- around: (effect) => effect.pipe(Effect.withSpan("story"), Effect.withTracer(tracer)),
186
+ around: (effect) => effect.pipe(Effect.withSpan("story")),
174
187
  *create() {
175
- const context = yield* Effect.context<never>();
176
- return HttpEffect.toWebHandlerWith(context)(Effect.succeed(HttpServerResponse.text("ok")));
188
+ const spans: Tracer.NativeSpan[] = [];
189
+ const tracer = Tracer.make({
190
+ span: (options) => {
191
+ const span = new Tracer.NativeSpan(options);
192
+ spans.push(span);
193
+ return span;
194
+ },
195
+ });
196
+ const context = yield* Effect.context<never>().pipe(Effect.withTracer(tracer));
197
+ const handler = HttpEffect.toWebHandlerWith(context)(Effect.succeed(HttpServerResponse.text("ok")));
198
+ return { handler, spans };
177
199
  },
178
200
  name: "traced application",
179
201
  stages: ["ready"],
180
- verbs: (handler) => ({
202
+ verbs: ({ handler, spans }) => ({
181
203
  request: (headers: HeadersInit = {}) => Effect.promise(async () => {
182
204
  const response = await handler(new Request("http://app.test/", { headers }));
183
- await response.text();
205
+ return response.text();
206
+ }),
207
+ requestSpan: () => {
184
208
  const span = spans.filter((span) => span.kind === "server").at(-1);
185
209
  if (span === undefined) throw new Error("The request exported no server span");
186
210
  return span;
187
- }),
211
+ },
188
212
  }),
189
213
  });
190
214
 
191
- app.it("a request is its own root trace", [], function* ({ request }) {
215
+ app.it("a request is its own root trace", [], function* ({ request, requestSpan }) {
192
216
  const storySpan = yield* Effect.currentSpan;
193
- const span = yield* request();
217
+ expect(yield* request()).toBe("ok");
218
+ const span = requestSpan();
194
219
  expect(span.parent._tag).toBe("None");
195
220
  expect(span.traceId).not.toBe(storySpan.traceId);
196
221
  expect(span.status._tag).toBe("Ended");
197
222
  });
198
223
 
199
- app.it("a request continues its supplied trace", [], function* ({ request }) {
200
- const span = yield* request({ traceparent: "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01" });
224
+ app.it("a request continues its supplied trace", [], function* ({ request, requestSpan }) {
225
+ yield* request({ traceparent: "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01" });
226
+ const span = requestSpan();
201
227
  expect(span.traceId).toBe("1234567890abcdef1234567890abcdef");
202
228
  expect(span.parent).toMatchObject({ value: { spanId: "1234567890abcdef" } });
203
229
  });
@@ -210,18 +236,54 @@ The package's [tracing kit](https://github.com/ShivaeDev/platform/blob/main/pack
210
236
  Use `otlpReceiver` from `@shivaedev/test-story/otlpReceiver.ts` in a kit's scoped `create` hook. It listens on an allocated loopback port and closes with the story scope. Point the application's real exporter at `${receiver.url}/v1/traces`, then flush the exporter before reading:
211
237
 
212
238
  ```ts
213
- import { otlpReceiver } from "@shivaedev/test-story/otlpReceiver.ts";
214
-
215
- const receiver = yield* otlpReceiver();
216
- yield* exportWork(`${receiver.url}/v1/traces`);
217
- expect(receiver.spans).toContainEqual(expect.objectContaining({ name: "CountWords" }));
218
- expect(receiver.requests.some(({ body }) => body.includes(Buffer.from("planted-secret")))).toBe(true);
239
+ app.it("exports the work span", [], function* ({ runWork, disposeRuntime, spans, requests }) {
240
+ yield* runWork();
241
+ yield* disposeRuntime();
242
+ expect(spans()).toContainEqual(expect.objectContaining({ name: "CountWords" }));
243
+ expect(requests().some(({ body }) => body.includes(Buffer.from("planted-secret")))).toBe(true);
244
+ });
219
245
  ```
220
246
 
221
- `exportWork` is the kit's verb that runs application work and flushes its exporter. 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`. Search all body bytes when checking that a secret was not exported. Optional `path`, `status` (a number or attempt callback) and `answer` configure collector responses for failure stories.
247
+ Here `app` is the application's kit. Its `create` acquires the receiver and the real exporter pointed at that receiver, `runWork` performs the traced operation, `disposeRuntime` disposes the exporter's runtime and flushes pending spans, and `spans` and `requests` read the receiver's observations. Each action is visible in the spec. 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`. Search all body bytes when checking that a secret was not exported. Optional `path`, `status` (a number or attempt callback) and `answer` configure collector responses for failure stories.
222
248
 
223
249
  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.
224
250
 
251
+ #### Faking an outbound HTTP API
252
+
253
+ Use `fakeHttpService` from `@shivaedev/test-story/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`. A reply receives the request and returns a JSON `body`, optional `status` (default 200), and optional response `headers`.
254
+
255
+ ```ts
256
+ import { fakeHttpService } from "@shivaedev/test-story/fakeHttpService.ts";
257
+
258
+ const api = yield* fakeHttpService({
259
+ guard: ({ headers }) => headers.authorization === "Bearer story-key",
260
+ routes: {
261
+ user: { path: "/users/:id", reply: ({ params }) => ({ body: { id: params.id } }) },
262
+ items: { path: "/items", pages: [["paper", "ink"], ["pens"]] },
263
+ },
264
+ });
265
+ ```
266
+
267
+ 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. Omit the guard for a public fake. Unmatched paths and methods get 404.
268
+
269
+ 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 its log; narration of normal requests includes only method, path and status.
270
+
271
+ ```ts
272
+ api.narrate(story.tell);
273
+ yield* api.failOnce("user", { status: 502, body: { error: "try again" } });
274
+ const paused = yield* api.hold("user");
275
+ const pending = yield* Effect.forkChild(application.loadUser("alice"));
276
+ yield* paused.waiting;
277
+ yield* paused.release;
278
+ const user = yield* Fiber.join(pending);
279
+ ```
280
+
281
+ `application.loadUser` is the application's real client call. `failOnce` affects the next authorized matching request to that named route, then the route answers normally. `hold` pauses that route until `release`; `waiting` completes when a request reaches the hold, so a story needs no polling to know when to release it. Other routes still answer. Effects that wait for HTTP should use a live-clock kit.
282
+
283
+ 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`.
284
+
285
+ The [fake HTTP stories](https://github.com/ShivaeDev/platform/blob/main/packages/test-story/src/server/outboundApi.spec.ts) use Node fetch to check params, credentials, pages, one-shot failure, hold/release ordering, request logs, narration and listener cleanup.
286
+
225
287
  ### 2. Once per feature: traits and verbs
226
288
 
227
289
  A trait is a line and a change, tagged with a stage:
@@ -265,7 +327,7 @@ help: a trait throws when the bakery it asks for cannot exist. Give the test tra
265
327
 
266
328
  given the oven is lit
267
329
  ✗ given the baker has 4 balls of dough
268
- refused at src/test-support/failingStories.ts:13:57
330
+ refused at src/test-support/failingStories.ts:15:57
269
331
  given the bakery has 1 bowls
270
332
 
271
333
  The bakery when the test failed:
@@ -332,7 +394,7 @@ The package wraps between words or JSON fields toward 120 columns and prints the
332
394
 
333
395
  ### Library package kits
334
396
 
335
- A library package has an engine too: its own services or Layer under test. Keep its kit in `src/test-support/`, acquire the real services in `create` with their dependencies supplied by the kit's `layer`, and use real PostgreSQL wherever the package touches it. Traits seed that engine in domain words; verbs call the package's public API. The same division applies as for an application: the kit owns setup and wiring, and a spec names traits, calls verbs and checks results. Following the [root test guidance](https://github.com/ShivaeDev/platform/blob/main/AGENTS.md), reserve plain Vitest tests for pure functions with no engine behind them.
397
+ A library package has an engine too: its own services or Layer under test. Keep its kit in `src/test-support/`, acquire the real services in `create` or a trait inside the story's per-test Scope, with their dependencies supplied by the kit's `layer`, and use real PostgreSQL wherever the package touches it. Traits seed that engine in domain words; each verb calls one action on the package's public API or reads one observation from that same engine. Verbs never create a fresh service just to run a scenario and return its measurements. The same division applies as for an application: the kit owns setup and wiring, and a spec names traits, shows every action through verbs and states each expectation. Following the [root test guidance](https://github.com/ShivaeDev/platform/blob/main/AGENTS.md), reserve plain Vitest tests for pure functions with no engine behind them.
336
398
 
337
399
  The [`backfill` kit](https://github.com/ShivaeDev/platform/blob/main/packages/effect-test-sql/src/test-support/migrationStory.ts) in `effect-test-sql` is a library example. Its `create` returns the package's `migrationSteps(migrations)` API, its worker Layer provides PostgreSQL, and `around: withIsolatedDatabase` isolates each story. The `oldSchema` and `existingContacts` traits put contacts with email addresses into the old schema; the `backfill` verb calls `steps.migrateToLatest`. Its [story](https://github.com/ShivaeDev/platform/blob/main/packages/effect-test-sql/src/server/migrationStory.spec.ts) proves that those existing addresses survive the migration into a new table. It runs only when `PLATFORM_EFFECT_TEST_SQL_TEST_DATABASE_URL` is configured; a skipped story proves nothing.
338
400
 
@@ -346,29 +408,9 @@ Set `runIf: hasPostgres` in the kit definition to gate all its stories once, whe
346
408
 
347
409
  The [order feature kit](https://github.com/ShivaeDev/platform/tree/main/packages/effect-react/src/test-support/order-example) runs the existing feature’s services, repository, contracts, JSON serialization and authentication middleware over a loopback HTTP listener. Its [stories](https://github.com/ShivaeDev/platform/blob/main/packages/effect-react/src/orderStory.dom.spec.tsx) test visible commits through a separate PostgreSQL pool, rollback of a rejected write without publication, cross-owner rejection, a native React action refreshing a list, and an editor retaining rejected input before a corrected save.
348
410
 
349
- ```ts
350
- import { app, realPostgres } from "./test-support/application.ts";
351
- import { aliceHasOrder } from "./test-support/traits.ts";
352
-
353
- app.it("a save commits and a rejected write leaves storage intact", [realPostgres(), aliceHasOrder("paper")], function* ({ saveOrder, rejectOrder, storedOrderIs, changesAre }) {
354
- yield* saveOrder("paper", "Office chairs", 8);
355
- yield* storedOrderIs("paper", "Office chairs", 8);
356
- yield* rejectOrder("paper", -1);
357
- yield* storedOrderIs("paper", "Office chairs", 8);
358
- yield* changesAre(1);
359
- });
360
- ```
361
-
362
- The application’s worker Layer leases a uniquely named disposable database, opens native PgClient pools and runs `migratePostgres`. Multiple kits in the worker share the database; the last lease drops it after the pools close. The PostgreSQL trait checks that infrastructure and the declared policy; it does not start a database. The application validates its local disposable destination before database creation or reset and skips database stories without `PLATFORM_EFFECT_SQL_TEST_DATABASE_URL`. Tests sharing that database run serially; parallelism belongs between workers or inside one story.
411
+ For full-app stories, use `around: withIsolatedDatabase` from `@shivaedev/effect-test-sql/withIsolatedDatabase.ts` with a PostgreSQL test layer prepared from the application's migrations. Each story gets a database cloned from that template, and HTTP and worker pools commit into the same clone. The clone is dropped after scoped hosts and pools close. The starter's [application kit](https://github.com/ShivaeDev/platform/blob/main/examples/starter/src/test-support/application.ts) uses this isolation path.
363
412
 
364
- | Application mode | Reset policy | Transaction policy | Coverage |
365
- | --- | --- | --- | --- |
366
- | HTTP | Truncate application tables and restart identities before and after the story; retain the migration ledger. | Commands perform real commits. | RPC serialization/transport, independent persistence reads, React refresh and forms. |
367
- | Local | Reset the baseline before and after the story. | Wrap creation, seeds and body in an outer Platform `transact`, then force rollback after success with a private sentinel. | Local repositories and services, transactional seeds and nested writes. |
368
-
369
- The kit’s `around` implements that policy. HTTP requests run in separate fibers, so an outer test transaction cannot isolate their writes. Local rollback starts with Platform `transact`; putting it inside a native outer transaction violates transaction ownership. Catch only the wrapper’s private rollback sentinel, let other failures escape, and check independent storage before cleanup truncation so cleanup cannot hide an accidental commit.
370
-
371
- Application-authored seed builders reuse application schemas, validate overrides, insert actual rows and return generated values. Traits retain aliases such as `"paper"` so specs avoid generated IDs. There is no shared factory or automatic relationship seeding.
413
+ For repository-level tests on a migrated template, `withTestTransaction` from `@shivaedev/effect-test-sql/withTestTransaction.ts` provides rollback isolation when all work uses its transaction connection. See the [database test helpers](https://github.com/ShivaeDev/platform/blob/main/packages/effect-test-sql/README.md) for template preparation and both wrappers.
372
414
 
373
415
  React screen traits acquire roots in Scope and use a fresh native atom registry for each screen. Cleanup unmounts roots before disposing registries and servers. Screen verbs dispatch DOM events and use bounded observations of rendered output. Select a live clock once on an HTTP/DOM kit; keep TestClock for deterministic local stories. `runUntil` is for engines with a meaningful step hook, not HTTP or DOM polling. Creation forms can use the same pattern with the existing `useCreate` path.
374
416
 
@@ -415,22 +457,7 @@ const cookieApp = storyKit({
415
457
  });
416
458
  ```
417
459
 
418
- `startHost`, `postgres` and `authVerbs` are application plumbing. The starter's [cookie application kit](https://github.com/ShivaeDev/platform/blob/main/examples/starter/src/test-support/cookieApp.ts) shows the full setup. Its Better Auth client signs up and signs in, and its verbs read `/api/auth/get-session` and post to `/api/auth/sign-out` through the DOM fetch. They do not copy cookies into request headers or use bearer tokens.
419
-
420
- ```ts
421
- cookieApp.it("sign-up and sign-in keep their cookie sessions against the PostgreSQL host", [], function* ({ signUp, signIn, signOut, sessionIs }) {
422
- yield* signUp();
423
- yield* sessionIs("alice@starter.test");
424
- yield* signOut();
425
- yield* sessionIs(null);
426
- yield* signIn();
427
- yield* sessionIs("alice@starter.test");
428
- yield* signOut();
429
- yield* sessionIs(null);
430
- });
431
- ```
432
-
433
- This story proves Better Auth cookie sessions across real loopback HTTP requests from happy-dom with an in-process PostgreSQL host. It also reads sessions through Better Auth's client and checks the fetch response's status, headers and JSON body.
460
+ `startHost`, `postgres` and `authVerbs` are application plumbing. Actions in `authVerbs` perform requests, and readouts return response data for assertions in the spec. A sign-in story drives its real sign-in flow; feature stories start from a faked signed-in precondition as described in the shared skill.
434
461
 
435
462
  #### Social sign-in through an outbound provider
436
463
 
@@ -445,25 +472,25 @@ const socialApp = storyKit({
445
472
  ...socialDefinition,
446
473
  outbound: { "github.com": fakeUrl, "api.github.com": fakeUrl },
447
474
  verbs: (engine, story) => ({
448
- signInWithGitHub: () => Effect.promise(async () => {
475
+ clickGitHubSignIn: () => Effect.promise(async () => {
449
476
  story.tell("Alice clicks Sign in with GitHub");
450
477
  await engine.screen.click();
451
- await domNavigate();
452
- }),
453
- signedInPageIs: (email: string) => Effect.promise(async () => {
454
- expect(document.querySelector("h1")?.textContent).toBe(`Signed in as ${email}`);
455
- expect(await (await engine.fetch("/api/auth/get-session")).json()).toMatchObject({ user: { email } });
456
478
  }),
479
+ followRedirect: () => Effect.promise(() => domNavigate()),
480
+ heading: () => document.querySelector("h1")?.textContent,
481
+ session: () => Effect.promise(async () => (await engine.fetch("/api/auth/get-session")).json()),
457
482
  }),
458
483
  });
459
484
 
460
- socialApp.it("a social sign-in returns with a cookie session", [], function* ({ signInWithGitHub, signedInPageIs }) {
461
- yield* signInWithGitHub();
462
- yield* signedInPageIs("alice@social.test");
485
+ socialApp.it("a social sign-in returns with a cookie session", [], function* ({ clickGitHubSignIn, followRedirect, heading, session }) {
486
+ yield* clickGitHubSignIn();
487
+ yield* followRedirect();
488
+ expect(heading()).toBe("Signed in as alice@social.test");
489
+ expect(yield* session()).toMatchObject({ user: { email: "alice@social.test" } });
463
490
  });
464
491
  ```
465
492
 
466
- `socialDefinition` starts the host and renders the screen with a Better Auth client whose `customFetchImpl` is `domFetch(baseUrl)`. The [story](https://github.com/ShivaeDev/platform/blob/main/examples/starter/src/server/socialSessions.dom.spec.ts) also checks the authorize, token and user requests at the fake, persisted PostgreSQL rows, and an HttpOnly session cookie read through a later document request. It does not copy cookies into request headers.
493
+ `socialDefinition` starts the host and renders the screen with a Better Auth client whose `customFetchImpl` is `domFetch(baseUrl)`. The authorize, token and user requests at the fake and a later session response provide evidence across the provider and application boundaries. It does not copy cookies into request headers.
467
494
 
468
495
  `domFetch` uses the current window and its cookie jar; it does not isolate multiple users. Run stories using that window serially, and let the application's cleanup end its sessions, restore the page location and close its host. Cookies follow hostnames, not listener ports, so a different loopback port alone does not isolate accounts.
469
496
 
@@ -478,6 +505,8 @@ Import each module with the prefix `@shivaedev/test-story/`; there is no root en
478
505
  | `domNavigate.ts` | `domNavigate(url?)` |
479
506
  | `genreTags.ts` | `genreTags(...kitNames)`, `GenreTag` |
480
507
  | `domEnvironment.config.ts` | Default Vitest environment preserving Node host globals and happy-dom document transport |
508
+ | `fakeHttpService.ts` | `fakeHttpService(options)`, `FakeHttpRoute`, `FakeHttpRequest`, `FakeHttpReply`, `FakeHttpHold`, `FakeHttpServiceOptions` |
509
+ | `otlpReceiver.ts` | `otlpReceiver(options?)`, `OtlpSpan`, `OtlpExport`, `OtlpReceiverOptions` |
481
510
 
482
511
  ### Install and limits
483
512
 
@@ -0,0 +1,42 @@
1
+ import { type IncomingHttpHeaders } from "node:http";
2
+ import { Effect } from "effect";
3
+ export interface FakeHttpRequest {
4
+ readonly body: Buffer;
5
+ readonly headers: IncomingHttpHeaders;
6
+ readonly method: string;
7
+ readonly params: Readonly<Record<string, string>>;
8
+ readonly path: string;
9
+ readonly query: URLSearchParams;
10
+ }
11
+ export interface FakeHttpReply {
12
+ readonly body?: unknown;
13
+ readonly headers?: Readonly<Record<string, string>>;
14
+ readonly status?: number;
15
+ }
16
+ export type FakeHttpRoute = {
17
+ readonly method?: string;
18
+ readonly path: string;
19
+ } & ({
20
+ readonly reply: (request: FakeHttpRequest) => FakeHttpReply;
21
+ readonly pages?: never;
22
+ } | {
23
+ readonly pages: readonly (readonly unknown[])[];
24
+ readonly reply?: never;
25
+ });
26
+ export interface FakeHttpServiceOptions {
27
+ readonly guard?: (request: FakeHttpRequest) => boolean;
28
+ readonly routes: Readonly<Record<string, FakeHttpRoute>>;
29
+ readonly tell?: (line: string) => void;
30
+ }
31
+ export interface FakeHttpHold {
32
+ readonly release: Effect.Effect<void>;
33
+ readonly waiting: Effect.Effect<void>;
34
+ }
35
+ export declare const fakeHttpService: (options: FakeHttpServiceOptions) => Effect.Effect<{
36
+ failOnce: (route: string, reply: FakeHttpReply) => Effect.Effect<void, never, never>;
37
+ hold: (route: string) => Effect.Effect<FakeHttpHold, never, never>;
38
+ narrate: (sink: (line: string) => void) => void;
39
+ readonly requests: readonly FakeHttpRequest[];
40
+ url: string;
41
+ }, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
42
+ //# sourceMappingURL=fakeHttpService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fakeHttpService.d.ts","sourceRoot":"","sources":["../../src/server/fakeHttpService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,mBAAmB,EAAE,MAAM,WAAW,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAKhC,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAClD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;CAChC;AAED,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACpD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,MAAM,aAAa,GAAG;IAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB,GAAG,CACD;IAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,aAAa,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,GACvF;IAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,SAAS,OAAO,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAA;CAAE,CAC7E,CAAC;AAEF,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC;IACvD,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC;IACzD,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AAED,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACtC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CACtC;AAED,eAAO,MAAM,eAAe;sBAgBR,MAAM,SAAS,aAAa;kBAChC,MAAM;oBACJ,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI;uBAGtB,SAAS,eAAe,EAAE;;qEAK1C,CAAC"}
@@ -0,0 +1,33 @@
1
+ import { createServer } from "node:http";
2
+ import { Effect } from "effect";
3
+ import { fakeHttpControls } from "#internal/fakeHttpControls.ts";
4
+ import { fakeHttpReceive } from "#internal/fakeHttpReceive.ts";
5
+ import { loopbackServer } from "#internal/loopback.ts";
6
+ export const fakeHttpService = Effect.fnUntraced(function* (options) {
7
+ const requests = [];
8
+ let narrate = options.tell;
9
+ function tell(line) {
10
+ narrate?.(line);
11
+ }
12
+ const controls = fakeHttpControls(tell);
13
+ const server = createServer((request, response) => fakeHttpReceive(request, response, options, requests, controls, tell));
14
+ const url = yield* loopbackServer(server);
15
+ yield* Effect.addFinalizer(() => Effect.sync(controls.close));
16
+ function checkRoute(route) {
17
+ if (!Object.hasOwn(options.routes, route)) {
18
+ throw new Error(`Unknown fake HTTP route "${route}".\nhelp: Use a name from fakeHttpService's routes when scripting a reply or hold.`);
19
+ }
20
+ }
21
+ return {
22
+ failOnce: (route, reply) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.failOnce(route, reply))),
23
+ hold: (route) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.hold(route))),
24
+ narrate: (sink) => {
25
+ narrate = sink;
26
+ },
27
+ get requests() {
28
+ return requests.slice();
29
+ },
30
+ url,
31
+ };
32
+ });
33
+ //# sourceMappingURL=fakeHttpService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fakeHttpService.js","sourceRoot":"","sources":["../../src/server/fakeHttpService.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAA4B,MAAM,WAAW,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AAC/D,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAoCvD,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,OAA+B;IAC1F,MAAM,QAAQ,GAAsB,EAAE,CAAC;IACvC,IAAI,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAC3B,SAAS,IAAI,CAAC,IAAY;QACzB,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;IACD,MAAM,QAAQ,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;IACxC,MAAM,MAAM,GAAG,YAAY,CAAC,CAAC,OAAO,EAAE,QAAQ,EAAE,EAAE,CAAC,eAAe,CAAC,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC;IAC1H,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC;IAC1C,KAAK,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;IAC9D,SAAS,UAAU,CAAC,KAAa;QAChC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,CAAC;YAC3C,MAAM,IAAI,KAAK,CAAC,4BAA4B,KAAK,oFAAoF,CAAC,CAAC;QACxI,CAAC;IACF,CAAC;IACD,OAAO;QACN,QAAQ,EAAE,CAAC,KAAa,EAAE,KAAoB,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC;QAC7I,IAAI,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;QACxG,OAAO,EAAE,CAAC,IAA4B,EAAE,EAAE;YACzC,OAAO,GAAG,IAAI,CAAC;QAChB,CAAC;QACD,IAAI,QAAQ;YACX,OAAO,QAAQ,CAAC,KAAK,EAAE,CAAC;QACzB,CAAC;QACD,GAAG;KACH,CAAC;AACH,CAAC,CAAC,CAAC"}
@@ -0,0 +1,10 @@
1
+ import { Effect } from "effect";
2
+ import type { FakeHttpHold, FakeHttpReply } from "#fakeHttpService.ts";
3
+ export declare function fakeHttpControls(tell: (line: string) => void): {
4
+ close: () => void;
5
+ failOnce: (route: string, reply: FakeHttpReply) => Effect.Effect<void, never, never>;
6
+ hold: (route: string) => Effect.Effect<FakeHttpHold>;
7
+ pause: (route: string) => Promise<void>;
8
+ takeFailure: (route: string) => FakeHttpReply | undefined;
9
+ };
10
+ //# sourceMappingURL=fakeHttpControls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fakeHttpControls.d.ts","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpControls.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC1C,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEvE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI;IAI3D,KAAK;IAKL,QAAQ,UAAU,MAAM,SAAS,aAAa;IAO9C,IAAI,UAAU,MAAM,KAAG,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC;IA0BlD,KAAK,UAAgB,MAAM;IAG3B,WAAW,UAAU,MAAM;EAE5B"}
@@ -0,0 +1,48 @@
1
+ import { Deferred, Effect } from "effect";
2
+ export function fakeHttpControls(tell) {
3
+ const failures = new Map();
4
+ const holds = new Map();
5
+ return {
6
+ close: () => {
7
+ for (const hold of holds.values()) {
8
+ hold.release();
9
+ }
10
+ },
11
+ failOnce: (route, reply) => Effect.sync(() => {
12
+ const queue = failures.get(route) ?? [];
13
+ queue.push(reply);
14
+ failures.set(route, queue);
15
+ tell(`fake HTTP ${route} will answer once with ${reply.status ?? 200}`);
16
+ }),
17
+ hold: (route) => Effect.sync(() => {
18
+ if (holds.has(route)) {
19
+ throw new Error(`Fake HTTP route "${route}" is already held.\nhelp: Release its current hold before holding the route again.`);
20
+ }
21
+ const waiting = Deferred.makeUnsafe();
22
+ const released = Promise.withResolvers();
23
+ const hold = {
24
+ enter: () => {
25
+ tell(`fake HTTP ${route} has a waiting request`);
26
+ Deferred.doneUnsafe(waiting, Effect.void);
27
+ return released.promise;
28
+ },
29
+ release: () => {
30
+ if (holds.get(route) !== hold) {
31
+ return;
32
+ }
33
+ holds.delete(route);
34
+ released.resolve();
35
+ tell(`fake HTTP ${route} released`);
36
+ },
37
+ };
38
+ holds.set(route, hold);
39
+ tell(`fake HTTP ${route} held`);
40
+ return { release: Effect.sync(hold.release), waiting: Deferred.await(waiting) };
41
+ }),
42
+ pause: async (route) => {
43
+ await holds.get(route)?.enter();
44
+ },
45
+ takeFailure: (route) => failures.get(route)?.shift(),
46
+ };
47
+ }
48
+ //# sourceMappingURL=fakeHttpControls.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fakeHttpControls.js","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpControls.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAG1C,MAAM,UAAU,gBAAgB,CAAC,IAA4B;IAC5D,MAAM,QAAQ,GAAG,IAAI,GAAG,EAA2B,CAAC;IACpD,MAAM,KAAK,GAAG,IAAI,GAAG,EAAiF,CAAC;IACvG,OAAO;QACN,KAAK,EAAE,GAAG,EAAE;YACX,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC;gBACnC,IAAI,CAAC,OAAO,EAAE,CAAC;YAChB,CAAC;QACF,CAAC;QACD,QAAQ,EAAE,CAAC,KAAa,EAAE,KAAoB,EAAE,EAAE,CACjD,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE;YAChB,MAAM,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;YACxC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAClB,QAAQ,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;YAC3B,IAAI,CAAC,aAAa,KAAK,0BAA0B,KAAK,CAAC,MAAM,IAAI,GAAG,EAAE,CAAC,CAAC;QACzE,CAAC,CAAC;QACH,IAAI,EAAE,CAAC,KAAa,EAA+B,EAAE,CACpD,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE;YAChB,IAAI,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;gBACtB,MAAM,IAAI,KAAK,CAAC,oBAAoB,KAAK,oFAAoF,CAAC,CAAC;YAChI,CAAC;YACD,MAAM,OAAO,GAAG,QAAQ,CAAC,UAAU,EAAQ,CAAC;YAC5C,MAAM,QAAQ,GAAG,OAAO,CAAC,aAAa,EAAQ,CAAC;YAC/C,MAAM,IAAI,GAAG;gBACZ,KAAK,EAAE,GAAG,EAAE;oBACX,IAAI,CAAC,aAAa,KAAK,wBAAwB,CAAC,CAAC;oBACjD,QAAQ,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;oBAC1C,OAAO,QAAQ,CAAC,OAAO,CAAC;gBACzB,CAAC;gBACD,OAAO,EAAE,GAAG,EAAE;oBACb,IAAI,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC;wBAC/B,OAAO;oBACR,CAAC;oBACD,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;oBACpB,QAAQ,CAAC,OAAO,EAAE,CAAC;oBACnB,IAAI,CAAC,aAAa,KAAK,WAAW,CAAC,CAAC;gBACrC,CAAC;aACD,CAAC;YACF,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;YACvB,IAAI,CAAC,aAAa,KAAK,OAAO,CAAC,CAAC;YAChC,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;QACjF,CAAC,CAAC;QACH,KAAK,EAAE,KAAK,EAAE,KAAa,EAAE,EAAE;YAC9B,MAAM,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,CAAC;QACjC,CAAC;QACD,WAAW,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE;KAC5D,CAAC;AACH,CAAC"}
@@ -0,0 +1,5 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { FakeHttpRequest, FakeHttpServiceOptions } from "#fakeHttpService.ts";
3
+ import type { fakeHttpControls } from "#internal/fakeHttpControls.ts";
4
+ export declare function fakeHttpReceive(incoming: IncomingMessage, response: ServerResponse, options: FakeHttpServiceOptions, requests: FakeHttpRequest[], controls: ReturnType<typeof fakeHttpControls>, tell: (line: string) => void): Promise<void>;
5
+ //# sourceMappingURL=fakeHttpReceive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fakeHttpReceive.d.ts","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpReceive.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,KAAK,EAAiB,eAAe,EAAiB,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AACjH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAmDtE,wBAAsB,eAAe,CACpC,QAAQ,EAAE,eAAe,EACzB,QAAQ,EAAE,cAAc,EACxB,OAAO,EAAE,sBAAsB,EAC/B,QAAQ,EAAE,eAAe,EAAE,EAC3B,QAAQ,EAAE,UAAU,CAAC,OAAO,gBAAgB,CAAC,EAC7C,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,iBAoD5B"}
@@ -0,0 +1,102 @@
1
+ function match(route, method, pathname) {
2
+ if ((route.method ?? "GET").toUpperCase() !== method) {
3
+ return;
4
+ }
5
+ const pattern = route.path.split("/");
6
+ const segments = pathname.split("/");
7
+ if (pattern.length !== segments.length) {
8
+ return;
9
+ }
10
+ const params = {};
11
+ for (const [index, part] of pattern.entries()) {
12
+ const segment = segments[index] ?? "";
13
+ if (part.startsWith(":")) {
14
+ params[part.slice(1)] = segment;
15
+ }
16
+ else if (part !== segment) {
17
+ return;
18
+ }
19
+ }
20
+ return params;
21
+ }
22
+ function decodeParams(params) {
23
+ try {
24
+ for (const [name, value] of Object.entries(params)) {
25
+ params[name] = decodeURIComponent(value);
26
+ }
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ function pageReply(route, request) {
34
+ if (route.reply) {
35
+ return route.reply(request);
36
+ }
37
+ const page = Number(request.query.get("page") ?? "1");
38
+ if (!Number.isInteger(page) || page < 1) {
39
+ return { body: { error: "page must be a positive integer" }, status: 400 };
40
+ }
41
+ return { body: { items: route.pages[page - 1] ?? [], next: page < route.pages.length ? page + 1 : null } };
42
+ }
43
+ function send(response, reply) {
44
+ const body = reply.body === undefined ? undefined : JSON.stringify(reply.body);
45
+ response.writeHead(reply.status ?? 200, { "content-type": "application/json", ...reply.headers });
46
+ response.end(body);
47
+ }
48
+ export async function fakeHttpReceive(incoming, response, options, requests, controls, tell) {
49
+ try {
50
+ const path = incoming.url ?? "/";
51
+ const url = new URL(path, "http://127.0.0.1");
52
+ const method = incoming.method ?? "GET";
53
+ const found = Object.entries(options.routes).flatMap(([name, route]) => {
54
+ const params = match(route, method, url.pathname);
55
+ return params ? [{ name, params, route }] : [];
56
+ })[0];
57
+ const chunks = [];
58
+ for await (const chunk of incoming) {
59
+ chunks.push(Buffer.from(chunk));
60
+ }
61
+ const params = found?.params ?? {};
62
+ const request = {
63
+ body: Buffer.concat(chunks),
64
+ headers: incoming.headers,
65
+ method,
66
+ params,
67
+ path,
68
+ query: url.searchParams,
69
+ };
70
+ requests.push(request);
71
+ tell(`fake HTTP received ${method} ${path}`);
72
+ if (!decodeParams(params)) {
73
+ tell(`fake HTTP rejected ${method} ${path} with 400`);
74
+ send(response, { status: 400 });
75
+ return;
76
+ }
77
+ if (options.guard && !options.guard(request)) {
78
+ tell(`fake HTTP rejected ${method} ${path} with 401`);
79
+ send(response, { status: 401 });
80
+ return;
81
+ }
82
+ if (!found) {
83
+ tell(`fake HTTP answered ${method} ${path} with 404`);
84
+ send(response, { status: 404 });
85
+ return;
86
+ }
87
+ await controls.pause(found.name);
88
+ const reply = controls.takeFailure(found.name) ?? pageReply(found.route, request);
89
+ tell(`fake HTTP answered ${method} ${path} with ${reply.status ?? 200}`);
90
+ send(response, reply);
91
+ }
92
+ catch (error) {
93
+ tell(`fake HTTP could not answer: ${String(error)}`);
94
+ if (response.headersSent) {
95
+ response.end();
96
+ }
97
+ else {
98
+ send(response, { status: 500 });
99
+ }
100
+ }
101
+ }
102
+ //# sourceMappingURL=fakeHttpReceive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fakeHttpReceive.js","sourceRoot":"","sources":["../../../src/server/internal/fakeHttpReceive.ts"],"names":[],"mappings":"AAIA,SAAS,KAAK,CAAC,KAAoB,EAAE,MAAc,EAAE,QAAgB;IACpE,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC,WAAW,EAAE,KAAK,MAAM,EAAE,CAAC;QACtD,OAAO;IACR,CAAC;IACD,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACtC,MAAM,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACrC,IAAI,OAAO,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM,EAAE,CAAC;QACxC,OAAO;IACR,CAAC;IACD,MAAM,MAAM,GAA2B,EAAE,CAAC;IAC1C,KAAK,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;QAC/C,MAAM,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QACtC,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;YAC1B,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC;QACjC,CAAC;aAAM,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;YAC7B,OAAO;QACR,CAAC;IACF,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC;AAED,SAAS,YAAY,CAAC,MAA8B;IACnD,IAAI,CAAC;QACJ,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACpD,MAAM,CAAC,IAAI,CAAC,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC;QAC1C,CAAC;QACD,OAAO,IAAI,CAAC;IACb,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,KAAK,CAAC;IACd,CAAC;AACF,CAAC;AAED,SAAS,SAAS,CAAC,KAAoB,EAAE,OAAwB;IAChE,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC7B,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC,CAAC;IACtD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,CAAC,EAAE,CAAC;QACzC,OAAO,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,iCAAiC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;IAC5E,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;AAC5G,CAAC;AAED,SAAS,IAAI,CAAC,QAAwB,EAAE,KAAoB;IAC3D,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC/E,QAAQ,CAAC,SAAS,CAAC,KAAK,CAAC,MAAM,IAAI,GAAG,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;IAClG,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACpB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACpC,QAAyB,EACzB,QAAwB,EACxB,OAA+B,EAC/B,QAA2B,EAC3B,QAA6C,EAC7C,IAA4B;IAE5B,IAAI,CAAC;QACJ,MAAM,IAAI,GAAG,QAAQ,CAAC,GAAG,IAAI,GAAG,CAAC;QACjC,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,EAAE,kBAAkB,CAAC,CAAC;QAC9C,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,IAAI,KAAK,CAAC;QACxC,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE;YACtE,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;YAClD,OAAO,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAChD,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACN,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,QAAQ,EAAE,CAAC;YACpC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;QACjC,CAAC;QACD,MAAM,MAAM,GAAG,KAAK,EAAE,MAAM,IAAI,EAAE,CAAC;QACnC,MAAM,OAAO,GAAoB;YAChC,IAAI,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;YAC3B,OAAO,EAAE,QAAQ,CAAC,OAAO;YACzB,MAAM;YACN,MAAM;YACN,IAAI;YACJ,KAAK,EAAE,GAAG,CAAC,YAAY;SACvB,CAAC;QACF,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACvB,IAAI,CAAC,sBAAsB,MAAM,IAAI,IAAI,EAAE,CAAC,CAAC;QAC7C,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,CAAC;YAC3B,IAAI,CAAC,sBAAsB,MAAM,IAAI,IAAI,WAAW,CAAC,CAAC;YACtD,IAAI,CAAC,QAAQ,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;YAChC,OAAO;QACR,CAAC;QACD,IAAI,OAAO,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;YAC9C,IAAI,CAAC,sBAAsB,MAAM,IAAI,IAAI,WAAW,CAAC,CAAC;YACtD,IAAI,CAAC,QAAQ,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;YAChC,OAAO;QACR,CAAC;QACD,IAAI,CAAC,KAAK,EAAE,CAAC;YACZ,IAAI,CAAC,sBAAsB,MAAM,IAAI,IAAI,WAAW,CAAC,CAAC;YACtD,IAAI,CAAC,QAAQ,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;YAChC,OAAO;QACR,CAAC;QACD,MAAM,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACjC,MAAM,KAAK,GAAG,QAAQ,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,SAAS,CAAC,KAAK,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QAClF,IAAI,CAAC,sBAAsB,MAAM,IAAI,IAAI,SAAS,KAAK,CAAC,MAAM,IAAI,GAAG,EAAE,CAAC,CAAC;QACzE,IAAI,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;IACvB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,IAAI,CAAC,+BAA+B,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACrD,IAAI,QAAQ,CAAC,WAAW,EAAE,CAAC;YAC1B,QAAQ,CAAC,GAAG,EAAE,CAAC;QAChB,CAAC;aAAM,CAAC;YACP,IAAI,CAAC,QAAQ,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QACjC,CAAC;IACF,CAAC;AACF,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { Server } from "node:http";
2
+ import { Effect } from "effect";
3
+ export declare const loopbackServer: (server: Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>) => Effect.Effect<string, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
4
+ //# sourceMappingURL=loopback.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loopback.d.ts","sourceRoot":"","sources":["../../../src/server/internal/loopback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,eAAO,MAAM,cAAc,2LA0BzB,CAAC"}
@@ -0,0 +1,19 @@
1
+ import { Effect } from "effect";
2
+ export const loopbackServer = Effect.fnUntraced(function* (server) {
3
+ yield* Effect.acquireRelease(Effect.tryPromise(() => new Promise((resolve, reject) => {
4
+ server.once("error", reject);
5
+ server.listen(0, "127.0.0.1", () => {
6
+ server.off("error", reject);
7
+ resolve();
8
+ });
9
+ })), () => Effect.promise(() => new Promise((resolve, reject) => {
10
+ server.close((error) => (error ? reject(error) : resolve()));
11
+ server.closeAllConnections();
12
+ })));
13
+ const address = server.address();
14
+ if (address === null || typeof address === "string") {
15
+ return yield* Effect.die("Missing loopback HTTP server address.\nhelp: The scoped HTTP server must be listening before reading its URL.");
16
+ }
17
+ return `http://127.0.0.1:${address.port}`;
18
+ });
19
+ //# sourceMappingURL=loopback.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loopback.js","sourceRoot":"","sources":["../../../src/server/internal/loopback.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAc;IACxE,KAAK,CAAC,CAAC,MAAM,CAAC,cAAc,CAC3B,MAAM,CAAC,UAAU,CAChB,GAAG,EAAE,CACJ,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC7B,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE;YAClC,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC5B,OAAO,EAAE,CAAC;QACX,CAAC,CAAC,CAAC;IACJ,CAAC,CAAC,CACH,EACD,GAAG,EAAE,CACJ,MAAM,CAAC,OAAO,CACb,GAAG,EAAE,CACJ,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QAC7D,MAAM,CAAC,mBAAmB,EAAE,CAAC;IAC9B,CAAC,CAAC,CACH,CACF,CAAC;IACF,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;IACjC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QACrD,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,+GAA+G,CAAC,CAAC;IAC3I,CAAC;IACD,OAAO,oBAAoB,OAAO,CAAC,IAAI,EAAE,CAAC;AAC3C,CAAC,CAAC,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import { Effect, type Scope } from "effect";
2
- import type { AnyTestLayer } from "@shivaedev/effect-test/any-test-layer.ts";
2
+ import type { AnyTestLayer } from "@shivaedev/effect-test/anyTestLayer.ts";
3
3
  import type { EffectClock } from "@shivaedev/effect-test/types.ts";
4
4
  import { type AnyEffect, type EffectHook, type Hook } from "#internal/hooked.ts";
5
5
  import { type Narration } from "#internal/narration.ts";
@@ -1 +1 @@
1
- {"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["../../../src/server/internal/seed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,MAAM,EAAE,KAAK,KAAK,EAAU,MAAM,QAAQ,CAAC;AACpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAC7E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,KAAK,IAAI,EAAU,MAAM,qBAAqB,CAAC;AACzF,OAAO,EAAE,KAAK,SAAS,EAAa,MAAM,wBAAwB,CAAC;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD,MAAM,MAAM,UAAU,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;AAEnG,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,eAAe,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC5I,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IACnG,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;IAC1G,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;IACpD,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,CAAC;IAChD,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrD,QAAQ,CAAC,GAAG,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,KAAK,MAAM,CAAC;CAC/D;AAED,eAAO,MAAM,IAAI,6IAuBf,CAAC"}
1
+ {"version":3,"file":"seed.d.ts","sourceRoot":"","sources":["../../../src/server/internal/seed.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,MAAM,EAAE,KAAK,KAAK,EAAU,MAAM,QAAQ,CAAC;AACpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wCAAwC,CAAC;AAC3E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAE,KAAK,IAAI,EAAU,MAAM,qBAAqB,CAAC;AACzF,OAAO,EAAE,KAAK,SAAS,EAAa,MAAM,wBAAwB,CAAC;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD,MAAM,MAAM,UAAU,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;AAEnG,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,eAAe,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC5I,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC;AAED,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;IACnG,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;IAC1G,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC;IACpD,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,CAAC;IAChD,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrD,QAAQ,CAAC,GAAG,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,KAAK,MAAM,CAAC;CAC/D;AAED,eAAO,MAAM,IAAI,6IAuBf,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"otlpReceiver.d.ts","sourceRoot":"","sources":["../../src/server/otlpReceiver.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,mBAAmB,EAA6C,MAAM,WAAW,CAAC;AAC9G,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAEvD,MAAM,MAAM,QAAQ,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAC9D,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CACvC;AACD,MAAM,WAAW,mBAAmB;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC;CACrE;AAmCD,eAAO,MAAM,YAAY;uBA4Ba,SAAS,UAAU,EAAE;oBAAkB,SAAS,QAAQ,EAAE;kBAAgB,MAAM;qEAMpH,CAAC"}
1
+ {"version":3,"file":"otlpReceiver.d.ts","sourceRoot":"","sources":["../../src/server/otlpReceiver.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,mBAAmB,EAA6C,MAAM,WAAW,CAAC;AAC9G,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAEvD,MAAM,MAAM,QAAQ,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAC9D,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,mBAAmB,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,CAAC;CACvC;AACD,MAAM,WAAW,mBAAmB;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC,CAAC;CACrE;AAmCD,eAAO,MAAM,YAAY;uBAKa,SAAS,UAAU,EAAE;oBAAkB,SAAS,QAAQ,EAAE;kBAAgB,MAAM;qEAMpH,CAAC"}
@@ -1,5 +1,6 @@
1
1
  import { createServer } from "node:http";
2
2
  import { Effect } from "effect";
3
+ import { loopbackServer } from "#internal/loopback.ts";
3
4
  import { otlpReadout } from "#internal/otlpReadout.ts";
4
5
  async function receive(request, response, requests, spans, options) {
5
6
  try {
@@ -39,24 +40,11 @@ export const otlpReceiver = Effect.fnUntraced(function* (options = {}) {
39
40
  const requests = [];
40
41
  const spans = [];
41
42
  const server = createServer((request, response) => receive(request, response, requests, spans, options));
42
- yield* Effect.acquireRelease(Effect.tryPromise(() => new Promise((resolve, reject) => {
43
- server.once("error", reject);
44
- server.listen(0, "127.0.0.1", () => {
45
- server.off("error", reject);
46
- resolve();
47
- });
48
- })), () => Effect.promise(() => new Promise((resolve, reject) => {
49
- server.close((error) => (error ? reject(error) : resolve()));
50
- server.closeAllConnections();
51
- })));
52
- const address = server.address();
53
- if (address === null || typeof address === "string") {
54
- return yield* Effect.die("Missing OTLP receiver address");
55
- }
43
+ const url = yield* loopbackServer(server);
56
44
  const receiver = {
57
45
  requests,
58
46
  spans,
59
- url: `http://127.0.0.1:${address.port}`,
47
+ url,
60
48
  };
61
49
  return receiver;
62
50
  });
@@ -1 +1 @@
1
- {"version":3,"file":"otlpReceiver.js","sourceRoot":"","sources":["../../src/server/otlpReceiver.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"otlpReceiver.js","sourceRoot":"","sources":["../../src/server/otlpReceiver.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/test-story",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "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",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/test-story#readme",
6
6
  "bugs": {
@@ -24,9 +24,8 @@
24
24
  },
25
25
  "exports": {
26
26
  "./*.ts": {
27
- "types": "./dist/server/*.d.ts",
28
27
  "source": "./src/server/*.ts",
29
- "import": "./dist/server/*.js",
28
+ "types": "./dist/server/*.d.ts",
30
29
  "default": "./dist/server/*.js"
31
30
  },
32
31
  "./internal/*": null,
@@ -43,11 +42,11 @@
43
42
  "README.md"
44
43
  ],
45
44
  "dependencies": {
46
- "@shivaedev/effect-test": "0.5.0"
45
+ "@shivaedev/effect-test": "0.6.0"
47
46
  },
48
47
  "devDependencies": {
49
48
  "@effect/vitest": "4.0.4",
50
- "@shivaedev/quality": "0.23.0",
49
+ "@shivaedev/quality": "0.25.0",
51
50
  "@types/node": "24.19.2",
52
51
  "@typescript/native": "npm:typescript@7.0.2",
53
52
  "effect": "4.0.4",
@@ -0,0 +1,67 @@
1
+ import { createServer, type IncomingHttpHeaders } from "node:http";
2
+ import { Effect } from "effect";
3
+ import { fakeHttpControls } from "#internal/fakeHttpControls.ts";
4
+ import { fakeHttpReceive } from "#internal/fakeHttpReceive.ts";
5
+ import { loopbackServer } from "#internal/loopback.ts";
6
+
7
+ export interface FakeHttpRequest {
8
+ readonly body: Buffer;
9
+ readonly headers: IncomingHttpHeaders;
10
+ readonly method: string;
11
+ readonly params: Readonly<Record<string, string>>;
12
+ readonly path: string;
13
+ readonly query: URLSearchParams;
14
+ }
15
+
16
+ export interface FakeHttpReply {
17
+ readonly body?: unknown;
18
+ readonly headers?: Readonly<Record<string, string>>;
19
+ readonly status?: number;
20
+ }
21
+
22
+ export type FakeHttpRoute = {
23
+ readonly method?: string;
24
+ readonly path: string;
25
+ } & (
26
+ | { readonly reply: (request: FakeHttpRequest) => FakeHttpReply; readonly pages?: never }
27
+ | { readonly pages: readonly (readonly unknown[])[]; readonly reply?: never }
28
+ );
29
+
30
+ export interface FakeHttpServiceOptions {
31
+ readonly guard?: (request: FakeHttpRequest) => boolean;
32
+ readonly routes: Readonly<Record<string, FakeHttpRoute>>;
33
+ readonly tell?: (line: string) => void;
34
+ }
35
+
36
+ export interface FakeHttpHold {
37
+ readonly release: Effect.Effect<void>;
38
+ readonly waiting: Effect.Effect<void>;
39
+ }
40
+
41
+ export const fakeHttpService = Effect.fnUntraced(function* (options: FakeHttpServiceOptions) {
42
+ const requests: FakeHttpRequest[] = [];
43
+ let narrate = options.tell;
44
+ function tell(line: string) {
45
+ narrate?.(line);
46
+ }
47
+ const controls = fakeHttpControls(tell);
48
+ const server = createServer((request, response) => fakeHttpReceive(request, response, options, requests, controls, tell));
49
+ const url = yield* loopbackServer(server);
50
+ yield* Effect.addFinalizer(() => Effect.sync(controls.close));
51
+ function checkRoute(route: string) {
52
+ if (!Object.hasOwn(options.routes, route)) {
53
+ throw new Error(`Unknown fake HTTP route "${route}".\nhelp: Use a name from fakeHttpService's routes when scripting a reply or hold.`);
54
+ }
55
+ }
56
+ return {
57
+ failOnce: (route: string, reply: FakeHttpReply) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.failOnce(route, reply))),
58
+ hold: (route: string) => Effect.sync(() => checkRoute(route)).pipe(Effect.andThen(controls.hold(route))),
59
+ narrate: (sink: (line: string) => void) => {
60
+ narrate = sink;
61
+ },
62
+ get requests(): readonly FakeHttpRequest[] {
63
+ return requests.slice();
64
+ },
65
+ url,
66
+ };
67
+ });
@@ -0,0 +1,51 @@
1
+ import { Deferred, Effect } from "effect";
2
+ import type { FakeHttpHold, FakeHttpReply } from "#fakeHttpService.ts";
3
+
4
+ export function fakeHttpControls(tell: (line: string) => void) {
5
+ const failures = new Map<string, FakeHttpReply[]>();
6
+ const holds = new Map<string, { readonly enter: () => Promise<void>; readonly release: () => void }>();
7
+ return {
8
+ close: () => {
9
+ for (const hold of holds.values()) {
10
+ hold.release();
11
+ }
12
+ },
13
+ failOnce: (route: string, reply: FakeHttpReply) =>
14
+ Effect.sync(() => {
15
+ const queue = failures.get(route) ?? [];
16
+ queue.push(reply);
17
+ failures.set(route, queue);
18
+ tell(`fake HTTP ${route} will answer once with ${reply.status ?? 200}`);
19
+ }),
20
+ hold: (route: string): Effect.Effect<FakeHttpHold> =>
21
+ Effect.sync(() => {
22
+ if (holds.has(route)) {
23
+ throw new Error(`Fake HTTP route "${route}" is already held.\nhelp: Release its current hold before holding the route again.`);
24
+ }
25
+ const waiting = Deferred.makeUnsafe<void>();
26
+ const released = Promise.withResolvers<void>();
27
+ const hold = {
28
+ enter: () => {
29
+ tell(`fake HTTP ${route} has a waiting request`);
30
+ Deferred.doneUnsafe(waiting, Effect.void);
31
+ return released.promise;
32
+ },
33
+ release: () => {
34
+ if (holds.get(route) !== hold) {
35
+ return;
36
+ }
37
+ holds.delete(route);
38
+ released.resolve();
39
+ tell(`fake HTTP ${route} released`);
40
+ },
41
+ };
42
+ holds.set(route, hold);
43
+ tell(`fake HTTP ${route} held`);
44
+ return { release: Effect.sync(hold.release), waiting: Deferred.await(waiting) };
45
+ }),
46
+ pause: async (route: string) => {
47
+ await holds.get(route)?.enter();
48
+ },
49
+ takeFailure: (route: string) => failures.get(route)?.shift(),
50
+ };
51
+ }
@@ -0,0 +1,112 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { FakeHttpReply, FakeHttpRequest, FakeHttpRoute, FakeHttpServiceOptions } from "#fakeHttpService.ts";
3
+ import type { fakeHttpControls } from "#internal/fakeHttpControls.ts";
4
+
5
+ function match(route: FakeHttpRoute, method: string, pathname: string) {
6
+ if ((route.method ?? "GET").toUpperCase() !== method) {
7
+ return;
8
+ }
9
+ const pattern = route.path.split("/");
10
+ const segments = pathname.split("/");
11
+ if (pattern.length !== segments.length) {
12
+ return;
13
+ }
14
+ const params: Record<string, string> = {};
15
+ for (const [index, part] of pattern.entries()) {
16
+ const segment = segments[index] ?? "";
17
+ if (part.startsWith(":")) {
18
+ params[part.slice(1)] = segment;
19
+ } else if (part !== segment) {
20
+ return;
21
+ }
22
+ }
23
+ return params;
24
+ }
25
+
26
+ function decodeParams(params: Record<string, string>) {
27
+ try {
28
+ for (const [name, value] of Object.entries(params)) {
29
+ params[name] = decodeURIComponent(value);
30
+ }
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ function pageReply(route: FakeHttpRoute, request: FakeHttpRequest): FakeHttpReply {
38
+ if (route.reply) {
39
+ return route.reply(request);
40
+ }
41
+ const page = Number(request.query.get("page") ?? "1");
42
+ if (!Number.isInteger(page) || page < 1) {
43
+ return { body: { error: "page must be a positive integer" }, status: 400 };
44
+ }
45
+ return { body: { items: route.pages[page - 1] ?? [], next: page < route.pages.length ? page + 1 : null } };
46
+ }
47
+
48
+ function send(response: ServerResponse, reply: FakeHttpReply) {
49
+ const body = reply.body === undefined ? undefined : JSON.stringify(reply.body);
50
+ response.writeHead(reply.status ?? 200, { "content-type": "application/json", ...reply.headers });
51
+ response.end(body);
52
+ }
53
+
54
+ export async function fakeHttpReceive(
55
+ incoming: IncomingMessage,
56
+ response: ServerResponse,
57
+ options: FakeHttpServiceOptions,
58
+ requests: FakeHttpRequest[],
59
+ controls: ReturnType<typeof fakeHttpControls>,
60
+ tell: (line: string) => void,
61
+ ) {
62
+ try {
63
+ const path = incoming.url ?? "/";
64
+ const url = new URL(path, "http://127.0.0.1");
65
+ const method = incoming.method ?? "GET";
66
+ const found = Object.entries(options.routes).flatMap(([name, route]) => {
67
+ const params = match(route, method, url.pathname);
68
+ return params ? [{ name, params, route }] : [];
69
+ })[0];
70
+ const chunks: Buffer[] = [];
71
+ for await (const chunk of incoming) {
72
+ chunks.push(Buffer.from(chunk));
73
+ }
74
+ const params = found?.params ?? {};
75
+ const request: FakeHttpRequest = {
76
+ body: Buffer.concat(chunks),
77
+ headers: incoming.headers,
78
+ method,
79
+ params,
80
+ path,
81
+ query: url.searchParams,
82
+ };
83
+ requests.push(request);
84
+ tell(`fake HTTP received ${method} ${path}`);
85
+ if (!decodeParams(params)) {
86
+ tell(`fake HTTP rejected ${method} ${path} with 400`);
87
+ send(response, { status: 400 });
88
+ return;
89
+ }
90
+ if (options.guard && !options.guard(request)) {
91
+ tell(`fake HTTP rejected ${method} ${path} with 401`);
92
+ send(response, { status: 401 });
93
+ return;
94
+ }
95
+ if (!found) {
96
+ tell(`fake HTTP answered ${method} ${path} with 404`);
97
+ send(response, { status: 404 });
98
+ return;
99
+ }
100
+ await controls.pause(found.name);
101
+ const reply = controls.takeFailure(found.name) ?? pageReply(found.route, request);
102
+ tell(`fake HTTP answered ${method} ${path} with ${reply.status ?? 200}`);
103
+ send(response, reply);
104
+ } catch (error) {
105
+ tell(`fake HTTP could not answer: ${String(error)}`);
106
+ if (response.headersSent) {
107
+ response.end();
108
+ } else {
109
+ send(response, { status: 500 });
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,30 @@
1
+ import type { Server } from "node:http";
2
+ import { Effect } from "effect";
3
+
4
+ export const loopbackServer = Effect.fnUntraced(function* (server: Server) {
5
+ yield* Effect.acquireRelease(
6
+ Effect.tryPromise(
7
+ () =>
8
+ new Promise<void>((resolve, reject) => {
9
+ server.once("error", reject);
10
+ server.listen(0, "127.0.0.1", () => {
11
+ server.off("error", reject);
12
+ resolve();
13
+ });
14
+ }),
15
+ ),
16
+ () =>
17
+ Effect.promise(
18
+ () =>
19
+ new Promise<void>((resolve, reject) => {
20
+ server.close((error) => (error ? reject(error) : resolve()));
21
+ server.closeAllConnections();
22
+ }),
23
+ ),
24
+ );
25
+ const address = server.address();
26
+ if (address === null || typeof address === "string") {
27
+ return yield* Effect.die("Missing loopback HTTP server address.\nhelp: The scoped HTTP server must be listening before reading its URL.");
28
+ }
29
+ return `http://127.0.0.1:${address.port}`;
30
+ });
@@ -1,5 +1,5 @@
1
1
  import { Cause, Context, Effect, type Scope, Tracer } from "effect";
2
- import type { AnyTestLayer } from "@shivaedev/effect-test/any-test-layer.ts";
2
+ import type { AnyTestLayer } from "@shivaedev/effect-test/anyTestLayer.ts";
3
3
  import type { EffectClock } from "@shivaedev/effect-test/types.ts";
4
4
  import { refusal } from "#internal/errors.ts";
5
5
  import { type AnyEffect, type EffectHook, type Hook, hooked } from "#internal/hooked.ts";
@@ -1,5 +1,6 @@
1
1
  import { createServer, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import { Effect } from "effect";
3
+ import { loopbackServer } from "#internal/loopback.ts";
3
4
  import { otlpReadout } from "#internal/otlpReadout.ts";
4
5
 
5
6
  export type OtlpSpan = ReturnType<typeof otlpReadout>[number];
@@ -52,34 +53,11 @@ export const otlpReceiver = Effect.fnUntraced(function* (options: OtlpReceiverOp
52
53
  const requests: OtlpExport[] = [];
53
54
  const spans: OtlpSpan[] = [];
54
55
  const server = createServer((request, response) => receive(request, response, requests, spans, options));
55
- yield* Effect.acquireRelease(
56
- Effect.tryPromise(
57
- () =>
58
- new Promise<void>((resolve, reject) => {
59
- server.once("error", reject);
60
- server.listen(0, "127.0.0.1", () => {
61
- server.off("error", reject);
62
- resolve();
63
- });
64
- }),
65
- ),
66
- () =>
67
- Effect.promise(
68
- () =>
69
- new Promise<void>((resolve, reject) => {
70
- server.close((error) => (error ? reject(error) : resolve()));
71
- server.closeAllConnections();
72
- }),
73
- ),
74
- );
75
- const address = server.address();
76
- if (address === null || typeof address === "string") {
77
- return yield* Effect.die("Missing OTLP receiver address");
78
- }
56
+ const url = yield* loopbackServer(server);
79
57
  const receiver: { readonly requests: readonly OtlpExport[]; readonly spans: readonly OtlpSpan[]; readonly url: string } = {
80
58
  requests,
81
59
  spans,
82
- url: `http://127.0.0.1:${address.port}`,
60
+ url,
83
61
  };
84
62
  return receiver;
85
63
  });