From 89ba1543fc080ea2467ba72febba8091f7b10937 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Tue, 14 Jul 2026 14:20:46 +0200 Subject: [PATCH 1/3] test(start-api-todo): make the integration suite bootstrap-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the todos integration test so the suite only boots the application and drives it as a plain HTTP client — no start/demesne detail leaks into the test bodies. - Add `src/testkit/boot.ts`: the single seam onto start + demesne. It boots the real app (`runHost(AppLayer)`, as `server.ts` does) on an ephemeral port and returns a transport-neutral `{ baseUrl, close }`. A promise bridge holds the scope open across the suite (kernel invariant K2: the scope's lifetime IS `use`, with no open/hold/close handle), so `use` never asserts. - Rewrite `app.integration.ts` as transport-only: one `it` per action, each asserting the whole response (status and body together). It never mentions `runHost`, demesne, or a context, so it survives swapping the framework. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../start-api-todo/src/app.integration.ts | 124 ++++++++++-------- examples/start-api-todo/src/testkit/boot.ts | 78 +++++++++++ 2 files changed, 147 insertions(+), 55 deletions(-) create mode 100644 examples/start-api-todo/src/testkit/boot.ts diff --git a/examples/start-api-todo/src/app.integration.ts b/examples/start-api-todo/src/app.integration.ts index 8752a47..6846a90 100644 --- a/examples/start-api-todo/src/app.integration.ts +++ b/examples/start-api-todo/src/app.integration.ts @@ -1,61 +1,75 @@ -// Integration: boot the REAL server — `runHost(AppLayer)` exactly as `server.ts` does — on an -// ephemeral port (PORT=0), drive it over an actual socket with fetch, then let the scope close -// and prove teardown (the port stops accepting). No containers needed: this example's only -// infrastructure is the HTTP listener itself. -import { HttpServer } from "@btravstack/start-api"; -import { runHost } from "@btravstack/start-kernel"; -import { fromSafePromise } from "unthrown"; -import { describe, expect, it } from "vitest"; - -describe("start-api-todo (full boot)", () => { - it("serves CRUD over a real socket and tears the listener down on scope close", async () => { - process.env["PORT"] = "0"; - process.env["LOG_LEVEL"] = "warn"; - const { AppLayer } = await import("./app.js"); - - let baseUrl = ""; - const outcome = await runHost(AppLayer, { - use: (ctx) => - fromSafePromise( - (async () => { - baseUrl = `http://localhost:${ctx.get(HttpServer).port}`; - - const created = await fetch(`${baseUrl}/todos`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ title: "buy milk" }), - }); - expect(created.status).toBe(200); - const todo = (await created.json()) as { id: string; title: string }; - expect(todo).toMatchObject({ title: "buy milk" }); - - const listed = await fetch(`${baseUrl}/todos`); - expect(listed.status).toBe(200); - expect(await listed.json()).toEqual([expect.objectContaining({ title: "buy milk" })]); - - const got = await fetch(`${baseUrl}/todos/${todo.id}`); - expect(got.status).toBe(200); - - const missing = await fetch(`${baseUrl}/todos/does-not-exist`); - expect(missing.status).toBe(404); - - const invalid = await fetch(`${baseUrl}/todos`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ title: "" }), - }); - expect(invalid.status).toBe(400); - })(), - ), +// Integration — drive the running todos API over a real socket with plain `fetch`: one request per +// test, each asserting the whole response (status and body together). Nothing here mentions +// `runHost`, demesne, or a context — all of that is quarantined in `./testkit/boot`. This file is +// transport-only, so it would stay byte-for-byte identical if start + demesne were swapped out. +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { boot, type BootedApp } from "./testkit/boot.js"; + +const post = (baseUrl: string, body: unknown): Promise => + fetch(`${baseUrl}/todos`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + +describe("start-api-todo over HTTP", () => { + let app: BootedApp; + // The in-memory repo persists across the suite, so the CRUD tests share the todo created first. + let createdId: string; + + beforeAll(async () => { + app = await boot(); + }); + afterAll(() => app.close()); + + it("creates a todo", async () => { + const res = await post(app.baseUrl, { title: "buy milk" }); + const body = (await res.json()) as { id: string }; + createdId = body.id; + expect({ status: res.status, body }).toEqual({ + status: 200, + body: { id: expect.any(String), title: "buy milk", completed: false }, }); + }); - outcome.match({ - ok: () => undefined, - err: (error) => expect.unreachable(`startup failed: ${error._tag}`), - defect: (cause) => expect.unreachable(`panic: ${String(cause)}`), + it("lists the todos", async () => { + const res = await fetch(`${app.baseUrl}/todos`); + expect({ status: res.status, body: await res.json() }).toEqual({ + status: 200, + body: [{ id: createdId, title: "buy milk", completed: false }], }); + }); + + it("gets a todo by id", async () => { + const res = await fetch(`${app.baseUrl}/todos/${createdId}`); + expect({ status: res.status, body: await res.json() }).toEqual({ + status: 200, + body: { id: createdId, title: "buy milk", completed: false }, + }); + }); + + it("maps a missing todo to 404", async () => { + const res = await fetch(`${app.baseUrl}/todos/does-not-exist`); + expect({ status: res.status, body: await res.json() }).toEqual({ + status: 404, + body: { error: "todo not found" }, + }); + }); + + it("rejects invalid input with 400", async () => { + const res = await post(app.baseUrl, { title: "" }); + expect({ status: res.status, body: await res.json() }).toEqual({ + status: 400, + body: expect.objectContaining({ error: "invalid input" }), + }); + }); +}); - // The scope has closed — the listener must be gone (factor IX: disposability). - await expect(fetch(`${baseUrl}/todos`)).rejects.toThrow(); +describe("start-api-todo teardown", () => { + it("stops accepting once the scope closes (factor IX: disposability)", async () => { + const app = await boot(); + await app.close(); + await expect(fetch(`${app.baseUrl}/todos`)).rejects.toThrow(); }); }); diff --git a/examples/start-api-todo/src/testkit/boot.ts b/examples/start-api-todo/src/testkit/boot.ts new file mode 100644 index 0000000..ee0f171 --- /dev/null +++ b/examples/start-api-todo/src/testkit/boot.ts @@ -0,0 +1,78 @@ +// The integration suite's ONE seam onto btravstack start + demesne. It boots the real app — +// `runHost(AppLayer)` exactly as `server.ts` does — on an ephemeral port (PORT=0), holds the scope +// open for the whole suite, and hands back a transport-neutral handle: a `baseUrl` to drive with +// plain `fetch`, and `close()` to tear the listener down. The spec that consumes this knows only +// HTTP, so swapping start/demesne for anything else would touch this file and nothing else. +// +// Why a bridge is needed: `runHost`'s scope lifetime IS its `use` callback (kernel invariant K2 — +// there is no open/hold/close handle). To span a test framework's `beforeAll`/`afterAll` we pin the +// scope open with two promises: `use` resolves `ready` outward once the app is up, then awaits +// `release`; `close()` fires `release`, letting `use` return so every finalizer runs in reverse +// (the listener stops accepting). All of that lives here — never in a test body. +import { HttpServer } from "@btravstack/start-api"; +import { runHost } from "@btravstack/start-kernel"; +import { fromSafePromise } from "unthrown"; + +export type BootedApp = { + /** Base URL of the live listener — drive it with plain `fetch`. */ + readonly baseUrl: string; + /** Close the scope: run every finalizer in reverse, after which the port stops accepting. */ + readonly close: () => Promise; +}; + +export const boot = async (): Promise => { + process.env["PORT"] = "0"; + process.env["LOG_LEVEL"] = "warn"; + const { AppLayer } = await import("../app.js"); + + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + + let ready!: (baseUrl: string) => void; + const readyUrl = new Promise((resolve) => { + ready = resolve; + }); + + // `use` never asserts — it only publishes the base URL outward and then parks on `release`, + // keeping the scope (and the listener) alive until `close()` is called. + const outcome = runHost(AppLayer, { + use: (ctx) => { + ready(`http://localhost:${ctx.get(HttpServer).port}`); + return fromSafePromise(released); + }, + }); + + // A boot failure (config or listen error) settles `outcome` before `use` ever runs, so `readyUrl` + // would hang. Race the two: a non-ok boot throws here, failing `beforeAll` with the real cause; a + // successful boot resolves `readyUrl` while this branch stays pending until `close()`. + const bootFailure = outcome.then((result) => + result.match({ + ok: () => new Promise(() => undefined), // reached only at close — not a boot failure + err: (error) => { + throw new Error(`startup failed: ${error._tag}`); + }, + defect: (cause) => { + throw cause instanceof Error ? cause : new Error(`panic: ${String(cause)}`); + }, + }), + ); + + const baseUrl = await Promise.race([readyUrl, bootFailure]); + + const close = async (): Promise => { + release(); + (await outcome).match({ + ok: () => undefined, + err: (error) => { + throw new Error(`shutdown failed: ${error._tag}`); + }, + defect: (cause) => { + throw cause instanceof Error ? cause : new Error(`panic: ${String(cause)}`); + }, + }); + }; + + return { baseUrl, close }; +}; From dc6613ef656064cc3f239c052aa7bcbd2b5ed84a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Tue, 14 Jul 2026 15:59:21 +0200 Subject: [PATCH 2/3] =?UTF-8?q?test(start-api-todo):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20autonomous=20tests,=20vi.stubEnv,=20error=20causes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Inject the booted app through a vitest `test.extend` fixture (per test), so the in-memory repo starts empty every test: each test is autonomous and order-independent. Drops the shared `createdId` seed. - Set env via `vi.stubEnv` + `unstubEnvs: true` (config) instead of mutating `process.env`, so PORT/LOG_LEVEL are restored before every test and never leak across tests or files. - Preserve the underlying error as `cause` on startup/shutdown failures, so a boot or teardown failure keeps ConfigError.issues / ListenError.cause. - Assert whole responses through a `responseOf` snapshot helper (a Response can't be compared with `toEqual` directly — one-shot body stream). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../start-api-todo/src/app.integration.ts | 75 +++++++++++-------- examples/start-api-todo/src/testkit/boot.ts | 35 +++++---- .../vitest.integration.config.ts | 3 + 3 files changed, 66 insertions(+), 47 deletions(-) diff --git a/examples/start-api-todo/src/app.integration.ts b/examples/start-api-todo/src/app.integration.ts index 6846a90..38d6984 100644 --- a/examples/start-api-todo/src/app.integration.ts +++ b/examples/start-api-todo/src/app.integration.ts @@ -1,65 +1,74 @@ // Integration — drive the running todos API over a real socket with plain `fetch`: one request per // test, each asserting the whole response (status and body together). Nothing here mentions // `runHost`, demesne, or a context — all of that is quarantined in `./testkit/boot`. This file is -// transport-only, so it would stay byte-for-byte identical if start + demesne were swapped out. -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +// transport-only, so it would stay identical if start + demesne were swapped out. +// +// Each test gets its own freshly-booted app through the `app` fixture, so the in-memory repo starts +// empty every time: tests are autonomous and order-independent (no shared seed row). +import { it as base, describe, expect } from "vitest"; import { boot, type BootedApp } from "./testkit/boot.js"; -const post = (baseUrl: string, body: unknown): Promise => +const it = base.extend<{ app: BootedApp }>({ + // vitest requires the fixture's first arg to be an object-destructuring pattern; this fixture + // pulls no other fixtures, so it is empty. + // oxlint-disable-next-line no-empty-pattern + app: async ({}, use) => { + const app = await boot(); + await use(app); + await app.close(); + }, +}); + +// A Response can't be compared with `toEqual` directly (its body is a one-shot stream and it +// carries internal fields), so collapse it to an assertable snapshot and match the whole thing. +const responseOf = async (res: Response): Promise<{ status: number; body: unknown }> => ({ + status: res.status, + body: await res.json(), +}); + +const postTodo = (baseUrl: string, title: string): Promise => fetch(`${baseUrl}/todos`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify({ title }), }); describe("start-api-todo over HTTP", () => { - let app: BootedApp; - // The in-memory repo persists across the suite, so the CRUD tests share the todo created first. - let createdId: string; - - beforeAll(async () => { - app = await boot(); - }); - afterAll(() => app.close()); - - it("creates a todo", async () => { - const res = await post(app.baseUrl, { title: "buy milk" }); - const body = (await res.json()) as { id: string }; - createdId = body.id; - expect({ status: res.status, body }).toEqual({ + it("creates a todo", async ({ app }) => { + expect(await responseOf(await postTodo(app.baseUrl, "buy milk"))).toEqual({ status: 200, body: { id: expect.any(String), title: "buy milk", completed: false }, }); }); - it("lists the todos", async () => { - const res = await fetch(`${app.baseUrl}/todos`); - expect({ status: res.status, body: await res.json() }).toEqual({ + it("lists the todos", async ({ app }) => { + const created = (await (await postTodo(app.baseUrl, "buy milk")).json()) as { id: string }; + + expect(await responseOf(await fetch(`${app.baseUrl}/todos`))).toEqual({ status: 200, - body: [{ id: createdId, title: "buy milk", completed: false }], + body: [{ id: created.id, title: "buy milk", completed: false }], }); }); - it("gets a todo by id", async () => { - const res = await fetch(`${app.baseUrl}/todos/${createdId}`); - expect({ status: res.status, body: await res.json() }).toEqual({ + it("gets a todo by id", async ({ app }) => { + const created = (await (await postTodo(app.baseUrl, "buy milk")).json()) as { id: string }; + + expect(await responseOf(await fetch(`${app.baseUrl}/todos/${created.id}`))).toEqual({ status: 200, - body: { id: createdId, title: "buy milk", completed: false }, + body: { id: created.id, title: "buy milk", completed: false }, }); }); - it("maps a missing todo to 404", async () => { - const res = await fetch(`${app.baseUrl}/todos/does-not-exist`); - expect({ status: res.status, body: await res.json() }).toEqual({ + it("maps a missing todo to 404", async ({ app }) => { + expect(await responseOf(await fetch(`${app.baseUrl}/todos/does-not-exist`))).toEqual({ status: 404, body: { error: "todo not found" }, }); }); - it("rejects invalid input with 400", async () => { - const res = await post(app.baseUrl, { title: "" }); - expect({ status: res.status, body: await res.json() }).toEqual({ + it("rejects invalid input with 400", async ({ app }) => { + expect(await responseOf(await postTodo(app.baseUrl, ""))).toEqual({ status: 400, body: expect.objectContaining({ error: "invalid input" }), }); @@ -67,6 +76,8 @@ describe("start-api-todo over HTTP", () => { }); describe("start-api-todo teardown", () => { + // Boots its own app (the `app` fixture is lazy and never triggered here) so it can assert on the + // world *after* the scope has closed — something the auto-closing fixture can't express. it("stops accepting once the scope closes (factor IX: disposability)", async () => { const app = await boot(); await app.close(); diff --git a/examples/start-api-todo/src/testkit/boot.ts b/examples/start-api-todo/src/testkit/boot.ts index ee0f171..1df2c08 100644 --- a/examples/start-api-todo/src/testkit/boot.ts +++ b/examples/start-api-todo/src/testkit/boot.ts @@ -1,17 +1,18 @@ // The integration suite's ONE seam onto btravstack start + demesne. It boots the real app — // `runHost(AppLayer)` exactly as `server.ts` does — on an ephemeral port (PORT=0), holds the scope -// open for the whole suite, and hands back a transport-neutral handle: a `baseUrl` to drive with -// plain `fetch`, and `close()` to tear the listener down. The spec that consumes this knows only -// HTTP, so swapping start/demesne for anything else would touch this file and nothing else. +// open for the caller, and hands back a transport-neutral handle: a `baseUrl` to drive with plain +// `fetch`, and `close()` to tear the listener down. The spec that consumes this knows only HTTP, so +// swapping start/demesne for anything else would touch this file and nothing else. // // Why a bridge is needed: `runHost`'s scope lifetime IS its `use` callback (kernel invariant K2 — -// there is no open/hold/close handle). To span a test framework's `beforeAll`/`afterAll` we pin the -// scope open with two promises: `use` resolves `ready` outward once the app is up, then awaits -// `release`; `close()` fires `release`, letting `use` return so every finalizer runs in reverse -// (the listener stops accepting). All of that lives here — never in a test body. +// there is no open/hold/close handle). To span a test's lifetime we pin the scope open with two +// promises: `use` resolves `ready` outward once the app is up, then awaits `release`; `close()` +// fires `release`, letting `use` return so every finalizer runs in reverse (the listener stops +// accepting). All of that lives here — never in a test body. import { HttpServer } from "@btravstack/start-api"; import { runHost } from "@btravstack/start-kernel"; import { fromSafePromise } from "unthrown"; +import { vi } from "vitest"; export type BootedApp = { /** Base URL of the live listener — drive it with plain `fetch`. */ @@ -21,8 +22,11 @@ export type BootedApp = { }; export const boot = async (): Promise => { - process.env["PORT"] = "0"; - process.env["LOG_LEVEL"] = "warn"; + // `vi.stubEnv` (paired with `unstubEnvs: true` in the config) sets the config seam without + // mutating `process.env` permanently — the worker's env is restored before every test, so a boot + // here can never leak PORT/LOG_LEVEL into a later file or make failures order-dependent. + vi.stubEnv("PORT", "0"); + vi.stubEnv("LOG_LEVEL", "warn"); const { AppLayer } = await import("../app.js"); let release!: () => void; @@ -45,16 +49,17 @@ export const boot = async (): Promise => { }); // A boot failure (config or listen error) settles `outcome` before `use` ever runs, so `readyUrl` - // would hang. Race the two: a non-ok boot throws here, failing `beforeAll` with the real cause; a - // successful boot resolves `readyUrl` while this branch stays pending until `close()`. + // would hang. Race the two: a non-ok boot throws here, failing setup with the underlying error + // preserved as `cause`; a successful boot resolves `readyUrl` while this branch stays pending + // until `close()`. const bootFailure = outcome.then((result) => result.match({ ok: () => new Promise(() => undefined), // reached only at close — not a boot failure err: (error) => { - throw new Error(`startup failed: ${error._tag}`); + throw new Error(`startup failed: ${error._tag}`, { cause: error }); }, defect: (cause) => { - throw cause instanceof Error ? cause : new Error(`panic: ${String(cause)}`); + throw new Error("startup panicked", { cause }); }, }), ); @@ -66,10 +71,10 @@ export const boot = async (): Promise => { (await outcome).match({ ok: () => undefined, err: (error) => { - throw new Error(`shutdown failed: ${error._tag}`); + throw new Error(`shutdown failed: ${error._tag}`, { cause: error }); }, defect: (cause) => { - throw cause instanceof Error ? cause : new Error(`panic: ${String(cause)}`); + throw new Error("shutdown panicked", { cause }); }, }); }; diff --git a/examples/start-api-todo/vitest.integration.config.ts b/examples/start-api-todo/vitest.integration.config.ts index 28e4acb..9d7ca30 100644 --- a/examples/start-api-todo/vitest.integration.config.ts +++ b/examples/start-api-todo/vitest.integration.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ test: { environment: "node", include: ["src/**/*.integration.ts"], + // Each boot stubs PORT/LOG_LEVEL via `vi.stubEnv`; restore them before every test so config + // never leaks across tests or files and failures stay order-independent. + unstubEnvs: true, pool: "forks", testTimeout: 240_000, hookTimeout: 300_000, From 86c417fa46d7a0b9fb18790327a66677d8c7493f Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Tue, 14 Jul 2026 17:28:08 +0200 Subject: [PATCH 3/3] test(start-api-todo): always tear down the app fixture on test failure Wrap the fixture's `use(app)` in try/finally so `app.close()` runs even when a test throws (assertion failure or timeout); otherwise the listener would leak into later tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/start-api-todo/src/app.integration.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/examples/start-api-todo/src/app.integration.ts b/examples/start-api-todo/src/app.integration.ts index 38d6984..f135bc7 100644 --- a/examples/start-api-todo/src/app.integration.ts +++ b/examples/start-api-todo/src/app.integration.ts @@ -15,8 +15,13 @@ const it = base.extend<{ app: BootedApp }>({ // oxlint-disable-next-line no-empty-pattern app: async ({}, use) => { const app = await boot(); - await use(app); - await app.close(); + // `finally` so the listener is always torn down — even if the test (i.e. `use`) throws on an + // assertion failure or timeout, which would otherwise leak a running server into later tests. + try { + await use(app); + } finally { + await app.close(); + } }, });