@shivaedev/test-story 0.5.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 +16 -0
- package/README.md +63 -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 +2 -2
- package/dist/server/otlpReceiver.d.ts.map +1 -1
- package/dist/server/otlpReceiver.js +14 -13
- package/dist/server/otlpReceiver.js.map +1 -1
- package/docs/roadmap.md +7 -5
- package/package.json +3 -3
- 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 +15 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
13
|
+
## 0.6.0 - 2026-10-18
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Let the OTLP receiver's status callback return `undefined` to withhold response headers, or set `answer: null` to send headers without completing the body.
|
|
18
|
+
|
|
3
19
|
## 0.5.0 - 2026-10-17
|
|
4
20
|
|
|
5
21
|
### 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.
|
|
@@ -220,6 +222,44 @@ expect(receiver.requests.some(({ body }) => body.includes(Buffer.from("planted-s
|
|
|
220
222
|
|
|
221
223
|
`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.
|
|
222
224
|
|
|
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.
|
|
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
|
+
|
|
223
263
|
### 2. Once per feature: traits and verbs
|
|
224
264
|
|
|
225
265
|
A trait is a line and a change, tagged with a stage:
|
|
@@ -328,6 +368,14 @@ After the engine has been created, a failing story test adds the story to its fi
|
|
|
328
368
|
|
|
329
369
|
The package wraps between words or JSON fields toward 120 columns and prints the story's own lines whole. An indivisible word, field or rerun command component can exceed that width. A passing test adds nothing. A typed failure with no message, such as an `after` hook's `NoBowls`, prints by its tag.
|
|
330
370
|
|
|
371
|
+
### Library package kits
|
|
372
|
+
|
|
373
|
+
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.
|
|
374
|
+
|
|
375
|
+
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.
|
|
376
|
+
|
|
377
|
+
A fake belongs only at a true external boundary, wired in `create`; keep the package's own engine real. The [resume kit](https://github.com/ShivaeDev/platform/blob/main/packages/effect-contract/src/test-support/resumeStory.ts), for example, creates synthetic browser and native event sources while running the package's real `resumeSignal` API. Its [stories](https://github.com/ShivaeDev/platform/blob/main/packages/effect-contract/src/resume.dom.test.ts) exercise refresh on those events and listener cleanup. Those synthetic sources prove the package's response to events, not a physical browser or native application's event delivery.
|
|
378
|
+
|
|
331
379
|
### Whole-stack application kits
|
|
332
380
|
|
|
333
381
|
Keep one kit in the application’s `test-support/`. `effect-test` owns Vitest registration, worker Layer acquisition, the per-test Scope, cancellation and clock selection. `test-story` owns engine creation, staged traits, verbs and narration. The application owns database lifecycle, migrations, isolation, seeds, actual server/client wiring and screen interactions.
|
|
@@ -336,29 +384,9 @@ Set `runIf: hasPostgres` in the kit definition to gate all its stories once, whe
|
|
|
336
384
|
|
|
337
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.
|
|
338
386
|
|
|
339
|
-
|
|
340
|
-
import { app, realPostgres } from "./test-support/application.ts";
|
|
341
|
-
import { aliceHasOrder } from "./test-support/traits.ts";
|
|
342
|
-
|
|
343
|
-
app.it("a save commits and a rejected write leaves storage intact", [realPostgres(), aliceHasOrder("paper")], function* ({ saveOrder, rejectOrder, storedOrderIs, changesAre }) {
|
|
344
|
-
yield* saveOrder("paper", "Office chairs", 8);
|
|
345
|
-
yield* storedOrderIs("paper", "Office chairs", 8);
|
|
346
|
-
yield* rejectOrder("paper", -1);
|
|
347
|
-
yield* storedOrderIs("paper", "Office chairs", 8);
|
|
348
|
-
yield* changesAre(1);
|
|
349
|
-
});
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
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.
|
|
353
|
-
|
|
354
|
-
| Application mode | Reset policy | Transaction policy | Coverage |
|
|
355
|
-
| --- | --- | --- | --- |
|
|
356
|
-
| 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. |
|
|
357
|
-
| 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. |
|
|
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.
|
|
358
388
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
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.
|
|
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.
|
|
362
390
|
|
|
363
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.
|
|
364
392
|
|
|
@@ -405,22 +433,7 @@ const cookieApp = storyKit({
|
|
|
405
433
|
});
|
|
406
434
|
```
|
|
407
435
|
|
|
408
|
-
`startHost`, `postgres` and `authVerbs` are application plumbing.
|
|
409
|
-
|
|
410
|
-
```ts
|
|
411
|
-
cookieApp.it("sign-up and sign-in keep their cookie sessions against the PostgreSQL host", [], function* ({ signUp, signIn, signOut, sessionIs }) {
|
|
412
|
-
yield* signUp();
|
|
413
|
-
yield* sessionIs("alice@starter.test");
|
|
414
|
-
yield* signOut();
|
|
415
|
-
yield* sessionIs(null);
|
|
416
|
-
yield* signIn();
|
|
417
|
-
yield* sessionIs("alice@starter.test");
|
|
418
|
-
yield* signOut();
|
|
419
|
-
yield* sessionIs(null);
|
|
420
|
-
});
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
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.
|
|
424
437
|
|
|
425
438
|
#### Social sign-in through an outbound provider
|
|
426
439
|
|
|
@@ -440,20 +453,23 @@ const socialApp = storyKit({
|
|
|
440
453
|
await engine.screen.click();
|
|
441
454
|
await domNavigate();
|
|
442
455
|
}),
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
}),
|
|
456
|
+
signedInPage: () => Effect.promise(async () => ({
|
|
457
|
+
heading: document.querySelector("h1")?.textContent,
|
|
458
|
+
session: await (await engine.fetch("/api/auth/get-session")).json(),
|
|
459
|
+
})),
|
|
447
460
|
}),
|
|
448
461
|
});
|
|
449
462
|
|
|
450
|
-
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 }) {
|
|
451
464
|
yield* signInWithGitHub();
|
|
452
|
-
yield*
|
|
465
|
+
expect(yield* signedInPage()).toMatchObject({
|
|
466
|
+
heading: "Signed in as alice@social.test",
|
|
467
|
+
session: { user: { email: "alice@social.test" } },
|
|
468
|
+
});
|
|
453
469
|
});
|
|
454
470
|
```
|
|
455
471
|
|
|
456
|
-
`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.
|
|
457
473
|
|
|
458
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.
|
|
459
475
|
|
|
@@ -468,6 +484,8 @@ Import each module with the prefix `@shivaedev/test-story/`; there is no root en
|
|
|
468
484
|
| `domNavigate.ts` | `domNavigate(url?)` |
|
|
469
485
|
| `genreTags.ts` | `genreTags(...kitNames)`, `GenreTag` |
|
|
470
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` |
|
|
471
489
|
|
|
472
490
|
### Install and limits
|
|
473
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":"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"}
|
|
@@ -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"}
|
|
@@ -9,9 +9,9 @@ export interface OtlpExport {
|
|
|
9
9
|
readonly rawHeaders: readonly string[];
|
|
10
10
|
}
|
|
11
11
|
export interface OtlpReceiverOptions {
|
|
12
|
-
readonly answer?: string;
|
|
12
|
+
readonly answer?: string | null;
|
|
13
13
|
readonly path?: string;
|
|
14
|
-
readonly status?: number | ((attempt: number) => number);
|
|
14
|
+
readonly status?: number | ((attempt: number) => number | undefined);
|
|
15
15
|
}
|
|
16
16
|
export declare const otlpReceiver: (options?: OtlpReceiverOptions | undefined) => Effect.Effect<{
|
|
17
17
|
readonly requests: readonly OtlpExport[];
|
|
@@ -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 {
|
|
@@ -19,7 +20,17 @@ async function receive(request, response, requests, spans, options) {
|
|
|
19
20
|
return;
|
|
20
21
|
}
|
|
21
22
|
spans.push(...otlpReadout(body, contentType));
|
|
22
|
-
|
|
23
|
+
const status = typeof options.status === "function" ? options.status(requests.length) : (options.status ?? 200);
|
|
24
|
+
if (status === undefined) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
response.writeHead(status);
|
|
28
|
+
if (options.answer === null) {
|
|
29
|
+
response.flushHeaders();
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
response.end(options.answer ?? "{}");
|
|
33
|
+
}
|
|
23
34
|
}
|
|
24
35
|
catch {
|
|
25
36
|
response.writeHead(400).end();
|
|
@@ -29,21 +40,11 @@ export const otlpReceiver = Effect.fnUntraced(function* (options = {}) {
|
|
|
29
40
|
const requests = [];
|
|
30
41
|
const spans = [];
|
|
31
42
|
const server = createServer((request, response) => receive(request, response, requests, spans, options));
|
|
32
|
-
|
|
33
|
-
server.once("error", reject);
|
|
34
|
-
server.listen(0, "127.0.0.1", () => {
|
|
35
|
-
server.off("error", reject);
|
|
36
|
-
resolve();
|
|
37
|
-
});
|
|
38
|
-
})), () => Effect.promise(() => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))));
|
|
39
|
-
const address = server.address();
|
|
40
|
-
if (address === null || typeof address === "string") {
|
|
41
|
-
return yield* Effect.die("Missing OTLP receiver address");
|
|
42
|
-
}
|
|
43
|
+
const url = yield* loopbackServer(server);
|
|
43
44
|
const receiver = {
|
|
44
45
|
requests,
|
|
45
46
|
spans,
|
|
46
|
-
url
|
|
47
|
+
url,
|
|
47
48
|
};
|
|
48
49
|
return receiver;
|
|
49
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,
|
|
1
|
+
{"version":3,"file":"otlpReceiver.js","sourceRoot":"","sources":["../../src/server/otlpReceiver.ts"],"names":[],"mappings":"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"}
|
package/docs/roadmap.md
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# Roadmap
|
|
2
2
|
|
|
3
|
-
This file owns the story kit's implementation, next work and open questions. The
|
|
4
|
-
[framework roadmap](https://github.com/ShivaeDev/platform/blob/main/docs/framework/roadmap.md)
|
|
5
|
-
owns composed application features and host-level validation. Those roadmaps link
|
|
6
|
-
to each other rather than repeating a completion checklist.
|
|
3
|
+
This file owns the story kit's implementation, next work and open questions. The [framework roadmap](https://github.com/ShivaeDev/platform/blob/main/docs/framework/roadmap.md) owns composed application features and host-level validation. Those roadmaps link to each other rather than repeating a completion checklist.
|
|
7
4
|
|
|
8
5
|
## Built
|
|
9
6
|
|
|
@@ -13,10 +10,15 @@ to each other rather than repeating a completion checklist.
|
|
|
13
10
|
- [x] `story.runUntil(condition)`: steps the engine until the condition holds, stops on what `run.failed` reports, and stops after a step budget with a diagnosis, keeping every hook's typed failure in its error channel.
|
|
14
11
|
- [x] A failed test prints a short guide, its story, where it stopped with the spec line there, and the engine's state, inline or in a file under `node_modules/.cache/test-story/`, broken at 120 columns.
|
|
15
12
|
- [x] `domEnvironment` keeps Node fetch classes for an in-process host and happy-dom fetch for the client; `domFetch(baseUrl)` connects the window to that host, with a starter story proving Better Auth sign-up, sign-in, session reads and sign-out on PostgreSQL through cookies.
|
|
13
|
+
- [x] `domNavigate` loads a routed document into happy-dom, updates its location and returns a response whose body remains readable. [Document transport stories](../src/server/documentTransport.dom.spec.ts) assert the rendered heading, location and response body.
|
|
14
|
+
- [x] `outbound` routes exact hostnames to fake HTTP servers while preserving request paths, queries, methods, headers and bodies, and restores Node routing after successful and failed stories. [Outbound stories](../src/server/outbound.spec.ts) assert routing and restoration; [document transport stories](../src/server/documentTransport.dom.spec.ts) cover redirects, cookies and XMLHttpRequest; [DOM refusal tests](../src/server/domOutbound.spec.ts) assert that unrouted external fetches, document requests and redirects fail the story even when application code catches the error.
|
|
15
|
+
- [x] In-process host creation separates request traces from the story span: requests without trace headers get distinct root traces, supplied trace headers continue their trace, request work stays under the request span, and traits and steps stay under the story span. [Host tracing stories](../src/server/hostTracing.spec.ts) assert span parents, trace IDs and completed request spans.
|
|
16
|
+
- [x] Creation, traits and stage `after` hooks can return Effects directly, with services from the kit's Layer and resources released in the story's Scope, covered by [Effect hook stories](../src/server/effectHooks.spec.ts).
|
|
17
|
+
- [x] Kit hooks and steps support a live clock and a per-story TestClock override. [Live-time stories](../src/server/liveTime.spec.ts) cover polling real workers with `withLive` without advancing virtual token expiry.
|
|
16
18
|
|
|
17
19
|
## Next
|
|
18
20
|
|
|
19
|
-
- [ ] Move
|
|
21
|
+
- [ ] Move the Work Fleet engine's hand-written story DSL onto the kit and let what it needs decide the next hook.
|
|
20
22
|
|
|
21
23
|
## Open questions
|
|
22
24
|
|
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,8 +47,8 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@effect/vitest": "4.0.4",
|
|
50
|
-
"@shivaedev/quality": "0.
|
|
51
|
-
"@types/node": "24.19.
|
|
50
|
+
"@shivaedev/quality": "0.24.0",
|
|
51
|
+
"@types/node": "24.19.2",
|
|
52
52
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
53
53
|
"effect": "4.0.4",
|
|
54
54
|
"happy-dom": "20.14.6",
|
|
@@ -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];
|
|
@@ -10,9 +11,9 @@ export interface OtlpExport {
|
|
|
10
11
|
readonly rawHeaders: readonly string[];
|
|
11
12
|
}
|
|
12
13
|
export interface OtlpReceiverOptions {
|
|
13
|
-
readonly answer?: string;
|
|
14
|
+
readonly answer?: string | null;
|
|
14
15
|
readonly path?: string;
|
|
15
|
-
readonly status?: number | ((attempt: number) => number);
|
|
16
|
+
readonly status?: number | ((attempt: number) => number | undefined);
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
async function receive(request: IncomingMessage, response: ServerResponse, requests: OtlpExport[], spans: OtlpSpan[], options: OtlpReceiverOptions) {
|
|
@@ -33,7 +34,16 @@ async function receive(request: IncomingMessage, response: ServerResponse, reque
|
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
spans.push(...otlpReadout(body, contentType));
|
|
36
|
-
|
|
37
|
+
const status = typeof options.status === "function" ? options.status(requests.length) : (options.status ?? 200);
|
|
38
|
+
if (status === undefined) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
response.writeHead(status);
|
|
42
|
+
if (options.answer === null) {
|
|
43
|
+
response.flushHeaders();
|
|
44
|
+
} else {
|
|
45
|
+
response.end(options.answer ?? "{}");
|
|
46
|
+
}
|
|
37
47
|
} catch {
|
|
38
48
|
response.writeHead(400).end();
|
|
39
49
|
}
|
|
@@ -43,27 +53,11 @@ export const otlpReceiver = Effect.fnUntraced(function* (options: OtlpReceiverOp
|
|
|
43
53
|
const requests: OtlpExport[] = [];
|
|
44
54
|
const spans: OtlpSpan[] = [];
|
|
45
55
|
const server = createServer((request, response) => receive(request, response, requests, spans, options));
|
|
46
|
-
yield*
|
|
47
|
-
Effect.tryPromise(
|
|
48
|
-
() =>
|
|
49
|
-
new Promise<void>((resolve, reject) => {
|
|
50
|
-
server.once("error", reject);
|
|
51
|
-
server.listen(0, "127.0.0.1", () => {
|
|
52
|
-
server.off("error", reject);
|
|
53
|
-
resolve();
|
|
54
|
-
});
|
|
55
|
-
}),
|
|
56
|
-
),
|
|
57
|
-
() => Effect.promise(() => new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))),
|
|
58
|
-
);
|
|
59
|
-
const address = server.address();
|
|
60
|
-
if (address === null || typeof address === "string") {
|
|
61
|
-
return yield* Effect.die("Missing OTLP receiver address");
|
|
62
|
-
}
|
|
56
|
+
const url = yield* loopbackServer(server);
|
|
63
57
|
const receiver: { readonly requests: readonly OtlpExport[]; readonly spans: readonly OtlpSpan[]; readonly url: string } = {
|
|
64
58
|
requests,
|
|
65
59
|
spans,
|
|
66
|
-
url
|
|
60
|
+
url,
|
|
67
61
|
};
|
|
68
62
|
return receiver;
|
|
69
63
|
});
|