@shivaedev/test-story 0.6.0 → 0.7.0
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 +10 -0
- package/README.md +53 -45
- package/dist/server/fakeHttpService.d.ts +42 -0
- package/dist/server/fakeHttpService.d.ts.map +1 -0
- package/dist/server/fakeHttpService.js +33 -0
- package/dist/server/fakeHttpService.js.map +1 -0
- package/dist/server/internal/fakeHttpControls.d.ts +10 -0
- package/dist/server/internal/fakeHttpControls.d.ts.map +1 -0
- package/dist/server/internal/fakeHttpControls.js +48 -0
- package/dist/server/internal/fakeHttpControls.js.map +1 -0
- package/dist/server/internal/fakeHttpReceive.d.ts +5 -0
- package/dist/server/internal/fakeHttpReceive.d.ts.map +1 -0
- package/dist/server/internal/fakeHttpReceive.js +102 -0
- package/dist/server/internal/fakeHttpReceive.js.map +1 -0
- package/dist/server/internal/loopback.d.ts +4 -0
- package/dist/server/internal/loopback.d.ts.map +1 -0
- package/dist/server/internal/loopback.js +19 -0
- package/dist/server/internal/loopback.js.map +1 -0
- package/dist/server/otlpReceiver.d.ts.map +1 -1
- package/dist/server/otlpReceiver.js +3 -15
- package/dist/server/otlpReceiver.js.map +1 -1
- package/package.json +2 -2
- package/src/server/fakeHttpService.ts +67 -0
- package/src/server/internal/fakeHttpControls.ts +51 -0
- package/src/server/internal/fakeHttpReceive.ts +112 -0
- package/src/server/internal/loopback.ts +30 -0
- package/src/server/otlpReceiver.ts +3 -25
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 - 2026-10-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `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.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Point application story authors to the shared `story-testing` skill and align README guidance with observable results, explicit assertions and database isolation.
|
|
12
|
+
|
|
3
13
|
## 0.6.0 - 2026-10-18
|
|
4
14
|
|
|
5
15
|
### Added
|
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ The first line is the assertion's own message. The box under it tells a reader w
|
|
|
41
41
|
|
|
42
42
|
## Using it
|
|
43
43
|
|
|
44
|
+
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.
|
|
45
|
+
|
|
44
46
|
### How to think about it
|
|
45
47
|
|
|
46
48
|
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.
|
|
@@ -222,6 +224,42 @@ expect(receiver.requests.some(({ body }) => body.includes(Buffer.from("planted-s
|
|
|
222
224
|
|
|
223
225
|
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
226
|
|
|
227
|
+
#### Faking an outbound HTTP API
|
|
228
|
+
|
|
229
|
+
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`.
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { fakeHttpService } from "@shivaedev/test-story/fakeHttpService.ts";
|
|
233
|
+
|
|
234
|
+
const api = yield* fakeHttpService({
|
|
235
|
+
guard: ({ headers }) => headers.authorization === "Bearer story-key",
|
|
236
|
+
routes: {
|
|
237
|
+
user: { path: "/users/:id", reply: ({ params }) => ({ body: { id: params.id } }) },
|
|
238
|
+
items: { path: "/items", pages: [["paper", "ink"], ["pens"]] },
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
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.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
api.narrate(story.tell);
|
|
249
|
+
yield* api.failOnce("user", { status: 502, body: { error: "try again" } });
|
|
250
|
+
const paused = yield* api.hold("user");
|
|
251
|
+
const pending = yield* Effect.forkChild(application.loadUser("alice"));
|
|
252
|
+
yield* paused.waiting;
|
|
253
|
+
yield* paused.release;
|
|
254
|
+
const user = yield* Fiber.join(pending);
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`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.
|
|
258
|
+
|
|
259
|
+
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`.
|
|
260
|
+
|
|
261
|
+
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.
|
|
262
|
+
|
|
225
263
|
### 2. Once per feature: traits and verbs
|
|
226
264
|
|
|
227
265
|
A trait is a line and a change, tagged with a stage:
|
|
@@ -346,29 +384,9 @@ Set `runIf: hasPostgres` in the kit definition to gate all its stories once, whe
|
|
|
346
384
|
|
|
347
385
|
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
386
|
|
|
349
|
-
|
|
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.
|
|
363
|
-
|
|
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.
|
|
387
|
+
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.
|
|
370
388
|
|
|
371
|
-
|
|
389
|
+
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
390
|
|
|
373
391
|
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
392
|
|
|
@@ -415,22 +433,7 @@ const cookieApp = storyKit({
|
|
|
415
433
|
});
|
|
416
434
|
```
|
|
417
435
|
|
|
418
|
-
`startHost`, `postgres` and `authVerbs` are application plumbing.
|
|
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.
|
|
436
|
+
`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
437
|
|
|
435
438
|
#### Social sign-in through an outbound provider
|
|
436
439
|
|
|
@@ -450,20 +453,23 @@ const socialApp = storyKit({
|
|
|
450
453
|
await engine.screen.click();
|
|
451
454
|
await domNavigate();
|
|
452
455
|
}),
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
}),
|
|
456
|
+
signedInPage: () => Effect.promise(async () => ({
|
|
457
|
+
heading: document.querySelector("h1")?.textContent,
|
|
458
|
+
session: await (await engine.fetch("/api/auth/get-session")).json(),
|
|
459
|
+
})),
|
|
457
460
|
}),
|
|
458
461
|
});
|
|
459
462
|
|
|
460
|
-
socialApp.it("a social sign-in returns with a cookie session", [], function* ({ signInWithGitHub,
|
|
463
|
+
socialApp.it("a social sign-in returns with a cookie session", [], function* ({ signInWithGitHub, signedInPage }) {
|
|
461
464
|
yield* signInWithGitHub();
|
|
462
|
-
yield*
|
|
465
|
+
expect(yield* signedInPage()).toMatchObject({
|
|
466
|
+
heading: "Signed in as alice@social.test",
|
|
467
|
+
session: { user: { email: "alice@social.test" } },
|
|
468
|
+
});
|
|
463
469
|
});
|
|
464
470
|
```
|
|
465
471
|
|
|
466
|
-
`socialDefinition` starts the host and renders the screen with a Better Auth client whose `customFetchImpl` is `domFetch(baseUrl)`. The
|
|
472
|
+
`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
473
|
|
|
468
474
|
`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
475
|
|
|
@@ -478,6 +484,8 @@ Import each module with the prefix `@shivaedev/test-story/`; there is no root en
|
|
|
478
484
|
| `domNavigate.ts` | `domNavigate(url?)` |
|
|
479
485
|
| `genreTags.ts` | `genreTags(...kitNames)`, `GenreTag` |
|
|
480
486
|
| `domEnvironment.config.ts` | Default Vitest environment preserving Node host globals and happy-dom document transport |
|
|
487
|
+
| `fakeHttpService.ts` | `fakeHttpService(options)`, `FakeHttpRoute`, `FakeHttpRequest`, `FakeHttpReply`, `FakeHttpHold`, `FakeHttpServiceOptions` |
|
|
488
|
+
| `otlpReceiver.ts` | `otlpReceiver(options?)`, `OtlpSpan`, `OtlpExport`, `OtlpReceiverOptions` |
|
|
481
489
|
|
|
482
490
|
### Install and limits
|
|
483
491
|
|
|
@@ -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 +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;
|
|
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
|
-
|
|
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
|
|
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":"AAAA,OAAO,EAAE,YAAY,EAAuE,MAAM,WAAW,CAAC;AAC9G,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAevD,KAAK,UAAU,OAAO,CAAC,OAAwB,EAAE,QAAwB,EAAE,QAAsB,EAAE,KAAiB,EAAE,OAA4B;IACjJ,IAAI,CAAC;QACJ,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YACnC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;QACjC,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACnC,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,GAAG,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;QACrG,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,IAAI,OAAO,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,IAAI,YAAY,CAAC,EAAE,CAAC;YACjF,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;YAC9B,OAAO;QACR,CAAC;QACD,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,cAAc,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;QAC3E,IAAI,WAAW,KAAK,kBAAkB,IAAI,WAAW,KAAK,wBAAwB,EAAE,CAAC;YACpF,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;YAC9B,OAAO;QACR,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,GAAG,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC,CAAC;QAC9C,MAAM,MAAM,GAAG,OAAO,OAAO,CAAC,MAAM,KAAK,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC;QAChH,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YAC1B,OAAO;QACR,CAAC;QACD,QAAQ,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;QAC3B,IAAI,OAAO,CAAC,MAAM,KAAK,IAAI,EAAE,CAAC;YAC7B,QAAQ,CAAC,YAAY,EAAE,CAAC;QACzB,CAAC;aAAM,CAAC;YACP,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,IAAI,CAAC,CAAC;QACtC,CAAC;IACF,CAAC;IAAC,MAAM,CAAC;QACR,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;IAC/B,CAAC;AACF,CAAC;AAED,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAwB,EAAE;IACzF,MAAM,QAAQ,GAAiB,EAAE,CAAC;IAClC,MAAM,KAAK,GAAe,EAAE,CAAC;IAC7B,MAAM,MAAM,GAAG,YAAY,CAAC,CAAC,OAAO,EAAE,QAAQ,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC;IACzG,
|
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
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": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@effect/vitest": "4.0.4",
|
|
50
|
-
"@shivaedev/quality": "0.
|
|
50
|
+
"@shivaedev/quality": "0.24.0",
|
|
51
51
|
"@types/node": "24.19.2",
|
|
52
52
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
53
53
|
"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,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*
|
|
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
|
|
60
|
+
url,
|
|
83
61
|
};
|
|
84
62
|
return receiver;
|
|
85
63
|
});
|