Skip to content

test(start-api-todo): make the integration suite bootstrap-only#20

Merged
btravers merged 3 commits into
mainfrom
test/todo-integration-boot-bridge
Jul 15, 2026
Merged

test(start-api-todo): make the integration suite bootstrap-only#20
btravers merged 3 commits into
mainfrom
test/todo-integration-boot-bridge

Conversation

@btravers

Copy link
Copy Markdown
Contributor

What

Rework the todos integration test so the suite only bootstraps the application and drives it as a plain HTTP client. No start/demesne detail leaks into the test bodies — if start + demesne were swapped out tomorrow, app.integration.ts would stay identical.

Why

The old test ran every assertion inside runHost's use callback, split each check into separate status/body assertions, and packed all CRUD actions into a single it. That coupled the test to the concrete framework and made failures hard to localize.

The root cause: runHost (and the Layer.scoped it builds on) deliberately has no open/hold/close handle — the scope's lifetime is the use callback (kernel invariant K2). So there was nowhere else to stand while the server was alive.

How

  • New src/testkit/boot.ts — the single seam onto start + demesne. It boots the real app (runHost(AppLayer), exactly as server.ts does) on an ephemeral port and returns a transport-neutral { baseUrl, close }. A two-promise bridge holds the scope open across the suite: use publishes the base URL outward and parks on a release promise; close() fires it so every finalizer runs in reverse. use never asserts. A boot failure is raced against readiness so it throws in beforeAll with the real cause instead of hanging.
  • Rewrite app.integration.ts as transport-only: one it per action (create / list / get / 404 / 400), each asserting the whole response (status and body together). A separate teardown test boots its own app, closes it, and proves the port stops accepting (factor IX: disposability).

runHost itself is untouched — a returned live context outliving use would break the disposability guarantee K2 exists to enforce. The bridge is testing-only.

Test

  • pnpm --filter start-api-todo test:integration — 6 passed
  • typecheck / oxfmt / oxlint / knip clean

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 14, 2026 12:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the start-api-todo integration tests to boot the real app once and interact with it purely via HTTP, pushing all runHost/scope-lifetime mechanics into a dedicated testkit bootstrap helper.

Changes:

  • Added src/testkit/boot.ts to boot runHost(AppLayer) on an ephemeral port and expose { baseUrl, close } while holding the scope open across the suite.
  • Rewrote src/app.integration.ts to be transport-only: one HTTP request per test with combined status+body assertions, plus an explicit teardown/disposability check.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
examples/start-api-todo/src/testkit/boot.ts Introduces the bootstrap-only seam that runs the real host and keeps the scope open until close() is called.
examples/start-api-todo/src/app.integration.ts Converts integration tests to plain-HTTP client style and adds a teardown/disposability verification.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread examples/start-api-todo/src/testkit/boot.ts
Comment thread examples/start-api-todo/src/testkit/boot.ts
Comment thread examples/start-api-todo/src/testkit/boot.ts
Comment thread examples/start-api-todo/src/testkit/boot.ts
Comment thread examples/start-api-todo/src/app.integration.ts Outdated
Comment thread examples/start-api-todo/src/testkit/boot.ts Outdated
Comment thread examples/start-api-todo/src/app.integration.ts Outdated
Comment thread examples/start-api-todo/src/app.integration.ts Outdated
Comment thread examples/start-api-todo/src/app.integration.ts Outdated
Comment thread examples/start-api-todo/src/app.integration.ts Outdated
…error causes

- 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) <noreply@anthropic.com>
@btravers

Copy link
Copy Markdown
Contributor Author

Addressed in dc6613e. Point by point:

@btravers

  • "vitest has util to set the env"boot() now uses vi.stubEnv("PORT"/"LOG_LEVEL", …) instead of mutating process.env, paired with unstubEnvs: true in vitest.integration.config.ts so the env is restored before every test (also resolves Copilot's leak concern robustly — restoration doesn't depend on close() running).
  • "should we create vitest fixtures and give app through vitest context" — done. const it = base.extend<{ app: BootedApp }>(…) boots a fresh app per test and auto-closes it; tests read async ({ app }) => …. This supersedes the beforeAll-returns-cleanup suggestion, so the explicit afterAll is gone.
  • "each test must be autonomous" — because the fixture boots per test, the in-memory repo starts empty each time. The shared createdId is gone; list/get each create their own todo. No ordering coupling.
  • "is there a way to expect(res).toEqual(...)" — not on a raw Response (its body is a one-shot stream and it carries internal fields), so I added a tiny responseOf(res) → { status, body } snapshot and assert the whole thing: expect(await responseOf(res)).toEqual({ status, body }). If you'd prefer a custom matcher (expect(res).toRespondWith(…)) I can add one instead — say the word.

Copilot

  • env leak (×2) → vi.stubEnv + unstubEnvs: true, above.
  • error cause on startup/shutdown (×2) → both now throw new Error(msg, { cause: error }), preserving ConfigError.issues / ListenError.cause.
  • shared createdId ordering → resolved by the per-test fixture (autonomous), above.

One note: the fixture's first arg is ({}, use) — vitest requires an object-destructuring pattern there, so it can't be named; the no-empty-pattern lint is suppressed with a one-line comment explaining why.

pnpm --filter start-api-todo test:integration → 6 passed; lint/format/typecheck clean.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread examples/start-api-todo/src/app.integration.ts
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) <noreply@anthropic.com>
@btravers

Copy link
Copy Markdown
Contributor Author

Fixed in 86c417f. The app fixture now wraps use(app) in try/finally, so app.close() runs even when a test throws (assertion failure or timeout) — no leaked listener into later tests. 6 integration tests still pass; lint/format/typecheck clean.

@btravers
btravers merged commit ac543a5 into main Jul 15, 2026
8 checks passed
@btravers
btravers deleted the test/todo-integration-boot-bridge branch July 16, 2026 09:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants