@shivaedev/test-story 0.8.0 → 0.10.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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 - 2026-10-18
4
+
5
+ ### Added
6
+
7
+ - Platform exports `authTestUtils(options?)` and `signedInUser(auth, fields)` from `server/better-auth/testing.ts` for typed Better Auth test utilities and signed-in story preconditions with user fields and roles, without OAuth or password hashing.
8
+ - Test-story exports `seedCookies(cookies)` and `DomCookie` from `seedCookies.ts` to seed happy-dom's cookie container, including HttpOnly session cookies accepted by a real HTTP host.
9
+
10
+ ## 0.9.0 - 2026-10-18
11
+
12
+ ### Added
13
+
14
+ - `forwardingProxy(upstream, options?)` and `ForwardingProxyOptions` from `@shivaedev/test-story/forwardingProxy.ts` provide a scoped loopback proxy for host stories. It forwards requests and responses, appends the caller to `x-forwarded-for`, and applies the story's configured headers.
15
+
3
16
  ## 0.8.0 - 2026-10-18
4
17
 
5
18
  ### Added
package/README.md CHANGED
@@ -233,6 +233,24 @@ app.it("a request continues its supplied trace", [], function* ({ request, reque
233
233
 
234
234
  Detachment only removes the incoming parent: spans that application startup explicitly creates still follow native Effect semantics.
235
235
 
236
+ #### Forwarding requests to a host
237
+
238
+ Use `forwardingProxy` from `@shivaedev/test-story/forwardingProxy.ts` in the kit's scoped `create` or `after` hook after starting the host under test:
239
+
240
+ ```ts
241
+ import { forwardingProxy } from "@shivaedev/test-story/forwardingProxy.ts";
242
+
243
+ const proxy = yield* forwardingProxy(upstream, {
244
+ headers: {
245
+ host: "internal.test",
246
+ "x-forwarded-host": "app.example",
247
+ "x-forwarded-proto": "https",
248
+ },
249
+ });
250
+ ```
251
+
252
+ Point story requests at `proxy.url`. The proxy listens on IPv4 and IPv6 loopback on the same allocated port, forwards the request's method, path, query, body and headers to `upstream`, and returns the host's status, headers and body. It appends the connecting caller's address to `x-forwarded-for`, preserving any existing chain. The optional `headers` map overrides incoming and generated headers, including `x-forwarded-for` when the story needs a fixed chain. The proxy returns 502 if it cannot connect to the host. Both listeners close with the story's Scope.
253
+
236
254
  #### Reading OTLP exports
237
255
 
238
256
  Use `otlpReceiver` from `@shivaedev/test-story/otlpReceiver.ts` in a kit's scoped `create` hook. It listens on an allocated loopback port and closes with the story scope. Point the application's real exporter at `${receiver.url}/v1/traces`, then flush the exporter before reading:
@@ -459,6 +477,23 @@ const cookieApp = storyKit({
459
477
 
460
478
  `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.
461
479
 
480
+ #### Seeding a signed-in precondition
481
+
482
+ `seedCookies(cookies)` from `@shivaedev/test-story/seedCookies.ts` returns an Effect that seeds the current happy-dom window's cookie container, including HttpOnly cookies. Call `domFetch(baseUrl)` first so the document and requests use the same origin. Each cookie has `name` and `value`, with optional `domain`, `path`, `httpOnly`, `secure`, `sameSite` (`Lax`, `Strict` or `None`) and `expires` (Unix seconds). Better Auth's test-utils login cookies fit this shape directly.
483
+
484
+ ```ts
485
+ import { signedInUser } from "@shivaedev/platform/server/better-auth/testing.ts";
486
+ import { seedCookies } from "@shivaedev/test-story/seedCookies.ts";
487
+
488
+ const { cookies } = yield* Effect.promise(() => signedInUser(testAuth, {
489
+ email: "alice@example.test",
490
+ name: "Alice",
491
+ }));
492
+ yield* seedCookies(cookies);
493
+ ```
494
+
495
+ `testAuth` is a test-only auth instance configured with Platform's `authTestUtils()` and the real host's database, secret and cookie policy. The seeded session travels on the document's fetch and stays hidden from `document.cookie` when HttpOnly. Use `http://localhost:<port>` for local DOM hosts with secure cookies: happy-dom matches secure cookies on localhost, but not on `http://127.0.0.1`. The listener can still bind to `127.0.0.1`.
496
+
462
497
  #### Social sign-in through an outbound provider
463
498
 
464
499
  Happy-dom's detached window records a location change without loading the next document. `domNavigate(url = window.location.href)` from `@shivaedev/test-story/domNavigate.ts` performs that load: it follows HTTP redirects through the document transport, keeps cookies for each destination, sets the final location, writes the returned HTML and returns the happy-dom response. Call it after the sign-in client has set the provider location, and before checking the returned page. An unrouted external redirect fails with its hostname.
@@ -513,12 +548,14 @@ Import each module with the prefix `@shivaedev/test-story/`; there is no root en
513
548
  | --- | --- |
514
549
  | `storyKit.ts` | `storyKit`, `Trait`, `RunUntilOptions`, `Story`, `RunHooks`, `StoryKitDefinition`, `Told`, `StoryBody`, `StoryTest`, `StoryIt`, `StoryKit` |
515
550
  | `domFetch.ts` | `domFetch(baseUrl)` |
551
+ | `seedCookies.ts` | `seedCookies(cookies)`, `DomCookie` |
516
552
  | `routeOutbound.ts` | `routeOutbound(routes)` |
517
553
  | `screenDriver.ts` | `screenDriver(options)`, `ScreenDriverOptions`, `ScreenDriver` |
518
554
  | `domNavigate.ts` | `domNavigate(url?)` |
519
555
  | `genreTags.ts` | `genreTags(...kitNames)`, `GenreTag` |
520
556
  | `domEnvironment.config.ts` | Default Vitest environment preserving Node host globals and happy-dom document transport |
521
557
  | `fakeHttpService.ts` | `fakeHttpService(options)`, `FakeHttpRoute`, `FakeHttpRequest`, `FakeHttpReply`, `FakeHttpHold`, `FakeHttpServiceOptions` |
558
+ | `forwardingProxy.ts` | `forwardingProxy(upstream, options?)`, `ForwardingProxyOptions` |
522
559
  | `otlpReceiver.ts` | `otlpReceiver(options?)`, `OtlpSpan`, `OtlpExport`, `OtlpReceiverOptions` |
523
560
 
524
561
  ### Install and limits
@@ -0,0 +1,8 @@
1
+ import { Effect } from "effect";
2
+ export interface ForwardingProxyOptions {
3
+ readonly headers?: Readonly<Record<string, string>>;
4
+ }
5
+ export declare const forwardingProxy: (upstream: string, options?: ForwardingProxyOptions | undefined) => Effect.Effect<{
6
+ url: string;
7
+ }, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
8
+ //# sourceMappingURL=forwardingProxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forwardingProxy.d.ts","sourceRoot":"","sources":["../../src/server/forwardingProxy.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGhC,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACpD;AAED,eAAO,MAAM,eAAe;;qEA8B1B,CAAC"}
@@ -0,0 +1,32 @@
1
+ import { createServer, request } from "node:http";
2
+ import { Effect } from "effect";
3
+ import { loopbackServer } from "#internal/loopback.ts";
4
+ export const forwardingProxy = Effect.fnUntraced(function* (upstream, options = {}) {
5
+ const forward = (incoming, response) => {
6
+ const outgoing = request(`${upstream}${incoming.url}`, {
7
+ headers: {
8
+ ...incoming.headers,
9
+ "x-forwarded-for": [incoming.headers["x-forwarded-for"], incoming.socket.remoteAddress].filter(Boolean).join(", "),
10
+ ...options.headers,
11
+ },
12
+ method: incoming.method,
13
+ }, (result) => {
14
+ response.writeHead(result.statusCode ?? 502, result.headers);
15
+ result.pipe(response);
16
+ });
17
+ outgoing.on("error", (error) => {
18
+ if (response.headersSent) {
19
+ response.destroy(error);
20
+ }
21
+ else {
22
+ response.writeHead(502).end();
23
+ }
24
+ });
25
+ response.on("close", () => outgoing.destroy());
26
+ incoming.pipe(outgoing);
27
+ };
28
+ const url = yield* loopbackServer(createServer(forward));
29
+ yield* loopbackServer(createServer(forward), { host: "::1", port: Number(new URL(url).port) });
30
+ return { url };
31
+ });
32
+ //# sourceMappingURL=forwardingProxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forwardingProxy.js","sourceRoot":"","sources":["../../src/server/forwardingProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAwB,OAAO,EAAE,MAAM,WAAW,CAAC;AACxE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAMvD,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,QAAgB,EAAE,OAAO,GAA2B,EAAE;IACjH,MAAM,OAAO,GAAoB,CAAC,QAAQ,EAAE,QAAQ,EAAE,EAAE;QACvD,MAAM,QAAQ,GAAG,OAAO,CACvB,GAAG,QAAQ,GAAG,QAAQ,CAAC,GAAG,EAAE,EAC5B;YACC,OAAO,EAAE;gBACR,GAAG,QAAQ,CAAC,OAAO;gBACnB,iBAAiB,EAAE,CAAC,QAAQ,CAAC,OAAO,CAAC,iBAAiB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;gBAClH,GAAG,OAAO,CAAC,OAAO;aAClB;YACD,MAAM,EAAE,QAAQ,CAAC,MAAM;SACvB,EACD,CAAC,MAAM,EAAE,EAAE;YACV,QAAQ,CAAC,SAAS,CAAC,MAAM,CAAC,UAAU,IAAI,GAAG,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;YAC7D,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACvB,CAAC,CACD,CAAC;QACF,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE;YAC9B,IAAI,QAAQ,CAAC,WAAW,EAAE,CAAC;gBAC1B,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YACzB,CAAC;iBAAM,CAAC;gBACP,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC;YAC/B,CAAC;QACF,CAAC,CAAC,CAAC;QACH,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;QAC/C,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACzB,CAAC,CAAC;IACF,MAAM,GAAG,GAAG,KAAK,CAAC,CAAC,cAAc,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC;IACzD,KAAK,CAAC,CAAC,cAAc,CAAC,YAAY,CAAC,OAAO,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC/F,OAAO,EAAE,GAAG,EAAE,CAAC;AAChB,CAAC,CAAC,CAAC"}
@@ -1,4 +1,7 @@
1
1
  import type { Server } from "node:http";
2
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>;
3
+ export declare const loopbackServer: (server: Server<typeof import("http").IncomingMessage, typeof import("http").ServerResponse>, options?: {
4
+ readonly host?: string;
5
+ readonly port?: number;
6
+ } | undefined) => Effect.Effect<string, import("effect/Cause").UnknownError, import("effect/Scope").Scope>;
4
7
  //# sourceMappingURL=loopback.d.ts.map
@@ -1 +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"}
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;oBAA2E,MAAM;oBAAkB,MAAM;0GA0BlI,CAAC"}
@@ -1,8 +1,8 @@
1
1
  import { Effect } from "effect";
2
- export const loopbackServer = Effect.fnUntraced(function* (server) {
2
+ export const loopbackServer = Effect.fnUntraced(function* (server, options = {}) {
3
3
  yield* Effect.acquireRelease(Effect.tryPromise(() => new Promise((resolve, reject) => {
4
4
  server.once("error", reject);
5
- server.listen(0, "127.0.0.1", () => {
5
+ server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
6
6
  server.off("error", reject);
7
7
  resolve();
8
8
  });
@@ -1 +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
+ {"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,EAAE,OAAO,GAAuD,EAAE;IAC1I,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,OAAO,CAAC,IAAI,IAAI,CAAC,EAAE,OAAO,CAAC,IAAI,IAAI,WAAW,EAAE,GAAG,EAAE;YAClE,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"}
@@ -0,0 +1,13 @@
1
+ import { Effect } from "effect";
2
+ export interface DomCookie {
3
+ readonly domain?: string;
4
+ readonly expires?: number;
5
+ readonly httpOnly?: boolean;
6
+ readonly name: string;
7
+ readonly path?: string;
8
+ readonly sameSite?: "Lax" | "Strict" | "None";
9
+ readonly secure?: boolean;
10
+ readonly value: string;
11
+ }
12
+ export declare const seedCookies: (cookies: readonly DomCookie[]) => Effect.Effect<undefined, never, never>;
13
+ //# sourceMappingURL=seedCookies.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seedCookies.d.ts","sourceRoot":"","sources":["../../src/server/seedCookies.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAIhC,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC9C,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAID,eAAO,MAAM,WAAW,2EAqBtB,CAAC"}
@@ -0,0 +1,25 @@
1
+ import { Effect } from "effect";
2
+ import { BrowserWindow, CookieSameSiteEnum } from "happy-dom";
3
+ import WindowBrowserContext from "happy-dom/lib/window/WindowBrowserContext.js";
4
+ const sameSites = { Lax: CookieSameSiteEnum.lax, None: CookieSameSiteEnum.none, Strict: CookieSameSiteEnum.strict };
5
+ export const seedCookies = Effect.fnUntraced(function* (cookies) {
6
+ if (!(window instanceof BrowserWindow)) {
7
+ return yield* Effect.die("Cookie preconditions need a happy-dom window.\nhelp: Use test-story's DOM environment before seeding cookies.");
8
+ }
9
+ const context = new WindowBrowserContext(window).getBrowserContext();
10
+ if (context === null) {
11
+ return yield* Effect.die("Cannot seed cookies after the document has closed.");
12
+ }
13
+ context.cookieContainer.addCookies(cookies.map((cookie) => ({
14
+ key: cookie.name,
15
+ "originURL": new URL(window.location.href),
16
+ value: cookie.value,
17
+ ...(cookie.domain === undefined ? {} : { domain: cookie.domain }),
18
+ ...(cookie.path === undefined ? {} : { path: cookie.path }),
19
+ ...(cookie.httpOnly === undefined ? {} : { httpOnly: cookie.httpOnly }),
20
+ ...(cookie.secure === undefined ? {} : { secure: cookie.secure }),
21
+ ...(cookie.sameSite === undefined ? {} : { sameSite: sameSites[cookie.sameSite] }),
22
+ ...(cookie.expires === undefined ? {} : { expires: new Date(cookie.expires * 1000) }),
23
+ })));
24
+ });
25
+ //# sourceMappingURL=seedCookies.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seedCookies.js","sourceRoot":"","sources":["../../src/server/seedCookies.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAC9D,OAAO,oBAAoB,MAAM,8CAA8C,CAAC;AAahF,MAAM,SAAS,GAAG,EAAE,GAAG,EAAE,kBAAkB,CAAC,GAAG,EAAE,IAAI,EAAE,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,kBAAkB,CAAC,MAAM,EAAE,CAAC;AAEpH,MAAM,CAAC,MAAM,WAAW,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,OAA6B;IACpF,IAAI,CAAC,CAAC,MAAM,YAAY,aAAa,CAAC,EAAE,CAAC;QACxC,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,+GAA+G,CAAC,CAAC;IAC3I,CAAC;IACD,MAAM,OAAO,GAAG,IAAI,oBAAoB,CAAC,MAAM,CAAC,CAAC,iBAAiB,EAAE,CAAC;IACrE,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACtB,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,oDAAoD,CAAC,CAAC;IAChF,CAAC;IACD,OAAO,CAAC,eAAe,CAAC,UAAU,CACjC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QACxB,GAAG,EAAE,MAAM,CAAC,IAAI;QAChB,WAAW,EAAE,IAAI,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;QAC1C,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,GAAG,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;QACjE,GAAG,CAAC,MAAM,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC;QAC3D,GAAG,CAAC,MAAM,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC;QACvE,GAAG,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;QACjE,GAAG,CAAC,MAAM,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;QAClF,GAAG,CAAC,MAAM,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,IAAI,CAAC,MAAM,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,CAAC;KACrF,CAAC,CAAC,CACH,CAAC;AACH,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/test-story",
3
- "version": "0.8.0",
3
+ "version": "0.10.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": {
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@effect/vitest": "4.0.4",
49
- "@shivaedev/quality": "0.26.0",
49
+ "@shivaedev/quality": "0.27.1",
50
50
  "@types/node": "24.19.2",
51
51
  "effect": "4.0.4",
52
52
  "happy-dom": "20.14.6",
@@ -0,0 +1,39 @@
1
+ import { createServer, type RequestListener, request } from "node:http";
2
+ import { Effect } from "effect";
3
+ import { loopbackServer } from "#internal/loopback.ts";
4
+
5
+ export interface ForwardingProxyOptions {
6
+ readonly headers?: Readonly<Record<string, string>>;
7
+ }
8
+
9
+ export const forwardingProxy = Effect.fnUntraced(function* (upstream: string, options: ForwardingProxyOptions = {}) {
10
+ const forward: RequestListener = (incoming, response) => {
11
+ const outgoing = request(
12
+ `${upstream}${incoming.url}`,
13
+ {
14
+ headers: {
15
+ ...incoming.headers,
16
+ "x-forwarded-for": [incoming.headers["x-forwarded-for"], incoming.socket.remoteAddress].filter(Boolean).join(", "),
17
+ ...options.headers,
18
+ },
19
+ method: incoming.method,
20
+ },
21
+ (result) => {
22
+ response.writeHead(result.statusCode ?? 502, result.headers);
23
+ result.pipe(response);
24
+ },
25
+ );
26
+ outgoing.on("error", (error) => {
27
+ if (response.headersSent) {
28
+ response.destroy(error);
29
+ } else {
30
+ response.writeHead(502).end();
31
+ }
32
+ });
33
+ response.on("close", () => outgoing.destroy());
34
+ incoming.pipe(outgoing);
35
+ };
36
+ const url = yield* loopbackServer(createServer(forward));
37
+ yield* loopbackServer(createServer(forward), { host: "::1", port: Number(new URL(url).port) });
38
+ return { url };
39
+ });
@@ -1,13 +1,13 @@
1
1
  import type { Server } from "node:http";
2
2
  import { Effect } from "effect";
3
3
 
4
- export const loopbackServer = Effect.fnUntraced(function* (server: Server) {
4
+ export const loopbackServer = Effect.fnUntraced(function* (server: Server, options: { readonly host?: string; readonly port?: number } = {}) {
5
5
  yield* Effect.acquireRelease(
6
6
  Effect.tryPromise(
7
7
  () =>
8
8
  new Promise<void>((resolve, reject) => {
9
9
  server.once("error", reject);
10
- server.listen(0, "127.0.0.1", () => {
10
+ server.listen(options.port ?? 0, options.host ?? "127.0.0.1", () => {
11
11
  server.off("error", reject);
12
12
  resolve();
13
13
  });
@@ -0,0 +1,39 @@
1
+ import { Effect } from "effect";
2
+ import { BrowserWindow, CookieSameSiteEnum } from "happy-dom";
3
+ import WindowBrowserContext from "happy-dom/lib/window/WindowBrowserContext.js";
4
+
5
+ export interface DomCookie {
6
+ readonly domain?: string;
7
+ readonly expires?: number;
8
+ readonly httpOnly?: boolean;
9
+ readonly name: string;
10
+ readonly path?: string;
11
+ readonly sameSite?: "Lax" | "Strict" | "None";
12
+ readonly secure?: boolean;
13
+ readonly value: string;
14
+ }
15
+
16
+ const sameSites = { Lax: CookieSameSiteEnum.lax, None: CookieSameSiteEnum.none, Strict: CookieSameSiteEnum.strict };
17
+
18
+ export const seedCookies = Effect.fnUntraced(function* (cookies: readonly DomCookie[]) {
19
+ if (!(window instanceof BrowserWindow)) {
20
+ return yield* Effect.die("Cookie preconditions need a happy-dom window.\nhelp: Use test-story's DOM environment before seeding cookies.");
21
+ }
22
+ const context = new WindowBrowserContext(window).getBrowserContext();
23
+ if (context === null) {
24
+ return yield* Effect.die("Cannot seed cookies after the document has closed.");
25
+ }
26
+ context.cookieContainer.addCookies(
27
+ cookies.map((cookie) => ({
28
+ key: cookie.name,
29
+ "originURL": new URL(window.location.href),
30
+ value: cookie.value,
31
+ ...(cookie.domain === undefined ? {} : { domain: cookie.domain }),
32
+ ...(cookie.path === undefined ? {} : { path: cookie.path }),
33
+ ...(cookie.httpOnly === undefined ? {} : { httpOnly: cookie.httpOnly }),
34
+ ...(cookie.secure === undefined ? {} : { secure: cookie.secure }),
35
+ ...(cookie.sameSite === undefined ? {} : { sameSite: sameSites[cookie.sameSite] }),
36
+ ...(cookie.expires === undefined ? {} : { expires: new Date(cookie.expires * 1000) }),
37
+ })),
38
+ );
39
+ });