@nifrajs/testing 1.1.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/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @nifrajs/testing
2
+
3
+ Testing helpers for nifra apps. [`@nifrajs/client`](../client)'s **`testClient`** is already the typed,
4
+ no-network in-process request client (nifra's supertest / `inject`). This package adds what it doesn't: a
5
+ **cookie jar** and a **cookie-persisting `testSession`**, so a login → authenticated-request flow tests as
6
+ easily as a single request.
7
+
8
+ ```ts
9
+ import { testSession } from "@nifrajs/testing"
10
+ import { app } from "../src/app"
11
+
12
+ const { client, cookies } = testSession<typeof app>(app)
13
+
14
+ await client.auth.login.post({ email, password }) // Set-Cookie captured into the jar
15
+ const me = await client.me.get() // Cookie sent automatically
16
+ expect(me.ok && me.data.id).toBeDefined()
17
+ expect(cookies.get("sid")).toBeDefined() // inspect the jar
18
+
19
+ await client.auth.logout.post() // a Max-Age=0 Set-Cookie clears it
20
+ expect(cookies.get("sid")).toBeUndefined()
21
+ ```
22
+
23
+ Same in-process client as `testClient` — the app's own `fetch`, no server/port/network, the full real
24
+ lifecycle (validation, middleware, contracts, auth) and end-to-end types from `App`. The only addition is
25
+ that every call carries and captures cookies via a shared jar.
26
+
27
+ ## API
28
+
29
+ - `testSession<App>(app, { origin?, cookies? })` → `{ client: Treaty<App>, cookies: CookieJar }`.
30
+ - `cookieJar()` → `CookieJar` — `header()` · `applyTo(headers)` · `store(response)` · `set` · `get` · `clear` · `size`.
31
+ Honours removal (`Max-Age=0` / past `Expires`); other cookie attributes are ignored (in-process, same-origin).
32
+
33
+ For a **stateless** request (no cookies), use `testClient` from `@nifrajs/client` directly.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A tiny cookie jar for in-process tests — parses `Set-Cookie` off responses and emits a `Cookie`
3
+ * request header, so a login → authenticated-request flow works without threading headers by hand. It
4
+ * honours removal (`Max-Age=0` / a past `Expires`) so logout clears the cookie; other attributes
5
+ * (Domain/Path/Secure/SameSite) are ignored — everything is same-origin in-process.
6
+ */
7
+ export interface CookieJar {
8
+ /** The current `Cookie` request-header value (`"a=1; b=2"`, or `""` when empty). */
9
+ header(): string;
10
+ /** Set the `Cookie` header on `headers` when the jar is non-empty. */
11
+ applyTo(headers: Headers): void;
12
+ /** Absorb every `Set-Cookie` on a response (setting or, on removal, deleting each). */
13
+ store(response: {
14
+ headers: Headers;
15
+ }): void;
16
+ set(name: string, value: string): void;
17
+ get(name: string): string | undefined;
18
+ clear(): void;
19
+ readonly size: number;
20
+ }
21
+ /** Create an empty cookie jar. */
22
+ export declare function cookieJar(): CookieJar;
23
+ //# sourceMappingURL=cookies.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookies.d.ts","sourceRoot":"","sources":["../src/cookies.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,SAAS;IACxB,oFAAoF;IACpF,MAAM,IAAI,MAAM,CAAA;IAChB,sEAAsE;IACtE,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IAC/B,uFAAuF;IACvF,KAAK,CAAC,QAAQ,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAA;IAC3C,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACtC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;IACrC,KAAK,IAAI,IAAI,CAAA;IACb,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AA8BD,kCAAkC;AAClC,wBAAgB,SAAS,IAAI,SAAS,CA0BrC"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A tiny cookie jar for in-process tests — parses `Set-Cookie` off responses and emits a `Cookie`
3
+ * request header, so a login → authenticated-request flow works without threading headers by hand. It
4
+ * honours removal (`Max-Age=0` / a past `Expires`) so logout clears the cookie; other attributes
5
+ * (Domain/Path/Secure/SameSite) are ignored — everything is same-origin in-process.
6
+ */
7
+ /** Read `Set-Cookie` values portably: `getSetCookie()` (Bun/Node/edge) with a single-header fallback. */
8
+ function setCookies(headers) {
9
+ const withGetter = headers;
10
+ if (typeof withGetter.getSetCookie === "function")
11
+ return withGetter.getSetCookie();
12
+ const single = headers.get("set-cookie");
13
+ return single === null ? [] : [single];
14
+ }
15
+ function isRemoval(attributes) {
16
+ if (/(^|;)\s*max-age=0(\s|;|$)/.test(attributes))
17
+ return true;
18
+ const expires = /expires=([^;]+)/.exec(attributes);
19
+ if (expires === null)
20
+ return false;
21
+ const at = Date.parse(expires[1]);
22
+ return !Number.isNaN(at) && at < Date.now();
23
+ }
24
+ function parseSetCookie(line) {
25
+ const semicolon = line.indexOf(";");
26
+ const pair = semicolon === -1 ? line : line.slice(0, semicolon);
27
+ const equals = pair.indexOf("=");
28
+ if (equals === -1)
29
+ return null;
30
+ const name = pair.slice(0, equals).trim();
31
+ if (name === "")
32
+ return null;
33
+ const value = pair.slice(equals + 1).trim();
34
+ const attributes = semicolon === -1 ? "" : line.slice(semicolon + 1).toLowerCase();
35
+ return { name, value, remove: isRemoval(attributes) };
36
+ }
37
+ /** Create an empty cookie jar. */
38
+ export function cookieJar() {
39
+ const cookies = new Map();
40
+ const header = () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ");
41
+ return {
42
+ header,
43
+ get size() {
44
+ return cookies.size;
45
+ },
46
+ set: (name, value) => {
47
+ cookies.set(name, value);
48
+ },
49
+ get: (name) => cookies.get(name),
50
+ clear: () => cookies.clear(),
51
+ applyTo: (headers) => {
52
+ const current = header();
53
+ if (current !== "")
54
+ headers.set("cookie", current);
55
+ },
56
+ store: (response) => {
57
+ for (const line of setCookies(response.headers)) {
58
+ const parsed = parseSetCookie(line);
59
+ if (parsed === null)
60
+ continue;
61
+ if (parsed.remove)
62
+ cookies.delete(parsed.name);
63
+ else
64
+ cookies.set(parsed.name, parsed.value);
65
+ }
66
+ },
67
+ };
68
+ }
69
+ //# sourceMappingURL=cookies.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cookies.js","sourceRoot":"","sources":["../src/cookies.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @nifrajs/testing — helpers for testing nifra apps in-process. `@nifrajs/client`'s `testClient` is the
3
+ * typed, no-network request client (the supertest/inject equivalent); this adds what it doesn't: a
4
+ * {@link cookieJar} and a cookie-persisting {@link testSession}, so stateful auth/session flows are as
5
+ * easy to test as a single request.
6
+ *
7
+ * import { testSession } from "@nifrajs/testing"
8
+ * const { client, cookies } = testSession<typeof app>(app)
9
+ */
10
+ export { type CookieJar, cookieJar } from "./cookies.js";
11
+ export { type AppLike, type TestSession, type TestSessionOptions, testSession } from "./session.js";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,KAAK,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxD,OAAO,EAAE,KAAK,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,kBAAkB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @nifrajs/testing — helpers for testing nifra apps in-process. `@nifrajs/client`'s `testClient` is the
3
+ * typed, no-network request client (the supertest/inject equivalent); this adds what it doesn't: a
4
+ * {@link cookieJar} and a cookie-persisting {@link testSession}, so stateful auth/session flows are as
5
+ * easy to test as a single request.
6
+ *
7
+ * import { testSession } from "@nifrajs/testing"
8
+ * const { client, cookies } = testSession<typeof app>(app)
9
+ */
10
+ export { cookieJar } from "./cookies.js";
11
+ export { testSession } from "./session.js";
12
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAkB,SAAS,EAAE,MAAM,cAAc,CAAA;AACxD,OAAO,EAA2D,WAAW,EAAE,MAAM,cAAc,CAAA"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A stateful, cookie-aware in-process test client. `@nifrajs/client`'s `testClient` already drives an
3
+ * app's `fetch` with end-to-end types (no server/port/network) — this wraps it with a {@link CookieJar}
4
+ * that carries `Set-Cookie` across calls, so a login → authenticated-request flow just works.
5
+ *
6
+ * import { testSession } from "@nifrajs/testing"
7
+ * import { app } from "../src/app"
8
+ *
9
+ * const { client, cookies } = testSession<typeof app>(app)
10
+ * await client.auth.login.post({ email, password }) // Set-Cookie captured
11
+ * const me = await client.me.get() // Cookie sent automatically
12
+ * expect(me.ok && me.data.id).toBeDefined()
13
+ */
14
+ import { type ClientOptions, type Treaty } from "@nifrajs/client";
15
+ import { type CookieJar } from "./cookies.js";
16
+ /** The minimal shape a nifra `server()` app satisfies — its own `fetch`. */
17
+ export interface AppLike {
18
+ fetch(request: Request): Response | Promise<Response>;
19
+ }
20
+ export interface TestSessionOptions extends Omit<ClientOptions, "fetch"> {
21
+ /** Origin used to resolve relative paths. Default `"http://nifra.internal"`. */
22
+ readonly origin?: string;
23
+ /** Reuse an existing jar (e.g. seed a session cookie, or share one across sessions). */
24
+ readonly cookies?: CookieJar;
25
+ }
26
+ export interface TestSession<App> {
27
+ /** The end-to-end-typed in-process client — every call carries + captures cookies. */
28
+ readonly client: Treaty<App>;
29
+ /** The jar backing this session — inspect (`cookies.get("sid")`), seed, or `clear()` it. */
30
+ readonly cookies: CookieJar;
31
+ }
32
+ /** Create a cookie-persisting in-process test client for `app`. */
33
+ export declare function testSession<App extends AppLike>(app: App, options?: TestSessionOptions): TestSession<App>;
34
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,KAAK,aAAa,EAAwB,KAAK,MAAM,EAAE,MAAM,iBAAiB,CAAA;AACvF,OAAO,EAAE,KAAK,SAAS,EAAa,MAAM,cAAc,CAAA;AAExD,4EAA4E;AAC5E,MAAM,WAAW,OAAO;IACtB,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CACtD;AAED,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,aAAa,EAAE,OAAO,CAAC;IACtE,gFAAgF;IAChF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,wFAAwF;IACxF,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAC7B;AAED,MAAM,WAAW,WAAW,CAAC,GAAG;IAC9B,sFAAsF;IACtF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,CAAC,CAAA;IAC5B,4FAA4F;IAC5F,QAAQ,CAAC,OAAO,EAAE,SAAS,CAAA;CAC5B;AAED,mEAAmE;AACnE,wBAAgB,WAAW,CAAC,GAAG,SAAS,OAAO,EAC7C,GAAG,EAAE,GAAG,EACR,OAAO,GAAE,kBAAuB,GAC/B,WAAW,CAAC,GAAG,CAAC,CAUlB"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A stateful, cookie-aware in-process test client. `@nifrajs/client`'s `testClient` already drives an
3
+ * app's `fetch` with end-to-end types (no server/port/network) — this wraps it with a {@link CookieJar}
4
+ * that carries `Set-Cookie` across calls, so a login → authenticated-request flow just works.
5
+ *
6
+ * import { testSession } from "@nifrajs/testing"
7
+ * import { app } from "../src/app"
8
+ *
9
+ * const { client, cookies } = testSession<typeof app>(app)
10
+ * await client.auth.login.post({ email, password }) // Set-Cookie captured
11
+ * const me = await client.me.get() // Cookie sent automatically
12
+ * expect(me.ok && me.data.id).toBeDefined()
13
+ */
14
+ import { client } from "@nifrajs/client";
15
+ import { cookieJar } from "./cookies.js";
16
+ /** Create a cookie-persisting in-process test client for `app`. */
17
+ export function testSession(app, options = {}) {
18
+ const { origin = "http://nifra.internal", cookies = cookieJar(), ...clientOptions } = options;
19
+ const bridge = async (url, init) => {
20
+ const headers = new Headers(init?.headers);
21
+ cookies.applyTo(headers);
22
+ const response = await app.fetch(new Request(url, { ...init, headers }));
23
+ cookies.store(response);
24
+ return response;
25
+ };
26
+ return { client: client(origin, { ...clientOptions, fetch: bridge }), cookies };
27
+ }
28
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAsB,MAAM,EAA6B,MAAM,iBAAiB,CAAA;AACvF,OAAO,EAAkB,SAAS,EAAE,MAAM,cAAc,CAAA;AAqBxD,mEAAmE;AACnE,MAAM,UAAU,WAAW,CACzB,GAAQ,EACR,UAA8B,EAAE;IAEhC,MAAM,EAAE,MAAM,GAAG,uBAAuB,EAAE,OAAO,GAAG,SAAS,EAAE,EAAE,GAAG,aAAa,EAAE,GAAG,OAAO,CAAA;IAC7F,MAAM,MAAM,GAAY,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC1C,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;QAC1C,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;QACxB,MAAM,QAAQ,GAAG,MAAM,GAAG,CAAC,KAAK,CAAC,IAAI,OAAO,CAAC,GAAG,EAAE,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,CAAC,CAAC,CAAA;QACxE,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAA;QACvB,OAAO,QAAQ,CAAA;IACjB,CAAC,CAAA;IACD,OAAO,EAAE,MAAM,EAAE,MAAM,CAAM,MAAM,EAAE,EAAE,GAAG,aAAa,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,EAAE,OAAO,EAAE,CAAA;AACtF,CAAC"}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@nifrajs/testing",
3
+ "version": "1.1.0",
4
+ "description": "Testing helpers for nifra apps — a cookie jar + a cookie-persisting in-process test session on top of @nifrajs/client's testClient, so stateful auth/session flows test as easily as a single request.",
5
+ "keywords": [
6
+ "bun",
7
+ "typescript",
8
+ "nifra",
9
+ "web-framework",
10
+ "full-stack",
11
+ "testing",
12
+ "ssr"
13
+ ],
14
+ "type": "module",
15
+ "main": "./dist/index.js",
16
+ "module": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "bun": "./src/index.ts",
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "sideEffects": false,
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
32
+ },
33
+ "license": "MIT",
34
+ "dependencies": {
35
+ "@nifrajs/client": "1.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "@nifrajs/core": "workspace:*"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/nifrajs/nifra.git",
46
+ "directory": "packages/testing"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/nifrajs/nifra/issues"
50
+ },
51
+ "homepage": "https://github.com/nifrajs/nifra#readme"
52
+ }
package/src/cookies.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A tiny cookie jar for in-process tests — parses `Set-Cookie` off responses and emits a `Cookie`
3
+ * request header, so a login → authenticated-request flow works without threading headers by hand. It
4
+ * honours removal (`Max-Age=0` / a past `Expires`) so logout clears the cookie; other attributes
5
+ * (Domain/Path/Secure/SameSite) are ignored — everything is same-origin in-process.
6
+ */
7
+
8
+ export interface CookieJar {
9
+ /** The current `Cookie` request-header value (`"a=1; b=2"`, or `""` when empty). */
10
+ header(): string
11
+ /** Set the `Cookie` header on `headers` when the jar is non-empty. */
12
+ applyTo(headers: Headers): void
13
+ /** Absorb every `Set-Cookie` on a response (setting or, on removal, deleting each). */
14
+ store(response: { headers: Headers }): void
15
+ set(name: string, value: string): void
16
+ get(name: string): string | undefined
17
+ clear(): void
18
+ readonly size: number
19
+ }
20
+
21
+ /** Read `Set-Cookie` values portably: `getSetCookie()` (Bun/Node/edge) with a single-header fallback. */
22
+ function setCookies(headers: Headers): string[] {
23
+ const withGetter = headers as Headers & { getSetCookie?: () => string[] }
24
+ if (typeof withGetter.getSetCookie === "function") return withGetter.getSetCookie()
25
+ const single = headers.get("set-cookie")
26
+ return single === null ? [] : [single]
27
+ }
28
+
29
+ function isRemoval(attributes: string): boolean {
30
+ if (/(^|;)\s*max-age=0(\s|;|$)/.test(attributes)) return true
31
+ const expires = /expires=([^;]+)/.exec(attributes)
32
+ if (expires === null) return false
33
+ const at = Date.parse(expires[1] as string)
34
+ return !Number.isNaN(at) && at < Date.now()
35
+ }
36
+
37
+ function parseSetCookie(line: string): { name: string; value: string; remove: boolean } | null {
38
+ const semicolon = line.indexOf(";")
39
+ const pair = semicolon === -1 ? line : line.slice(0, semicolon)
40
+ const equals = pair.indexOf("=")
41
+ if (equals === -1) return null
42
+ const name = pair.slice(0, equals).trim()
43
+ if (name === "") return null
44
+ const value = pair.slice(equals + 1).trim()
45
+ const attributes = semicolon === -1 ? "" : line.slice(semicolon + 1).toLowerCase()
46
+ return { name, value, remove: isRemoval(attributes) }
47
+ }
48
+
49
+ /** Create an empty cookie jar. */
50
+ export function cookieJar(): CookieJar {
51
+ const cookies = new Map<string, string>()
52
+ const header = (): string => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ")
53
+ return {
54
+ header,
55
+ get size() {
56
+ return cookies.size
57
+ },
58
+ set: (name, value) => {
59
+ cookies.set(name, value)
60
+ },
61
+ get: (name) => cookies.get(name),
62
+ clear: () => cookies.clear(),
63
+ applyTo: (headers) => {
64
+ const current = header()
65
+ if (current !== "") headers.set("cookie", current)
66
+ },
67
+ store: (response) => {
68
+ for (const line of setCookies(response.headers)) {
69
+ const parsed = parseSetCookie(line)
70
+ if (parsed === null) continue
71
+ if (parsed.remove) cookies.delete(parsed.name)
72
+ else cookies.set(parsed.name, parsed.value)
73
+ }
74
+ },
75
+ }
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @nifrajs/testing — helpers for testing nifra apps in-process. `@nifrajs/client`'s `testClient` is the
3
+ * typed, no-network request client (the supertest/inject equivalent); this adds what it doesn't: a
4
+ * {@link cookieJar} and a cookie-persisting {@link testSession}, so stateful auth/session flows are as
5
+ * easy to test as a single request.
6
+ *
7
+ * import { testSession } from "@nifrajs/testing"
8
+ * const { client, cookies } = testSession<typeof app>(app)
9
+ */
10
+
11
+ export { type CookieJar, cookieJar } from "./cookies.ts"
12
+ export { type AppLike, type TestSession, type TestSessionOptions, testSession } from "./session.ts"
package/src/session.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * A stateful, cookie-aware in-process test client. `@nifrajs/client`'s `testClient` already drives an
3
+ * app's `fetch` with end-to-end types (no server/port/network) — this wraps it with a {@link CookieJar}
4
+ * that carries `Set-Cookie` across calls, so a login → authenticated-request flow just works.
5
+ *
6
+ * import { testSession } from "@nifrajs/testing"
7
+ * import { app } from "../src/app"
8
+ *
9
+ * const { client, cookies } = testSession<typeof app>(app)
10
+ * await client.auth.login.post({ email, password }) // Set-Cookie captured
11
+ * const me = await client.me.get() // Cookie sent automatically
12
+ * expect(me.ok && me.data.id).toBeDefined()
13
+ */
14
+ import { type ClientOptions, client, type FetchFn, type Treaty } from "@nifrajs/client"
15
+ import { type CookieJar, cookieJar } from "./cookies.ts"
16
+
17
+ /** The minimal shape a nifra `server()` app satisfies — its own `fetch`. */
18
+ export interface AppLike {
19
+ fetch(request: Request): Response | Promise<Response>
20
+ }
21
+
22
+ export interface TestSessionOptions extends Omit<ClientOptions, "fetch"> {
23
+ /** Origin used to resolve relative paths. Default `"http://nifra.internal"`. */
24
+ readonly origin?: string
25
+ /** Reuse an existing jar (e.g. seed a session cookie, or share one across sessions). */
26
+ readonly cookies?: CookieJar
27
+ }
28
+
29
+ export interface TestSession<App> {
30
+ /** The end-to-end-typed in-process client — every call carries + captures cookies. */
31
+ readonly client: Treaty<App>
32
+ /** The jar backing this session — inspect (`cookies.get("sid")`), seed, or `clear()` it. */
33
+ readonly cookies: CookieJar
34
+ }
35
+
36
+ /** Create a cookie-persisting in-process test client for `app`. */
37
+ export function testSession<App extends AppLike>(
38
+ app: App,
39
+ options: TestSessionOptions = {},
40
+ ): TestSession<App> {
41
+ const { origin = "http://nifra.internal", cookies = cookieJar(), ...clientOptions } = options
42
+ const bridge: FetchFn = async (url, init) => {
43
+ const headers = new Headers(init?.headers)
44
+ cookies.applyTo(headers)
45
+ const response = await app.fetch(new Request(url, { ...init, headers }))
46
+ cookies.store(response)
47
+ return response
48
+ }
49
+ return { client: client<App>(origin, { ...clientOptions, fetch: bridge }), cookies }
50
+ }