Files
2026-05-27 23:29:53 +01:00

186 lines
13 KiB
Markdown

# Capability Spec Delta: testing-integration (testing-integration-spec-and-generics-hardening)
**Package**: `@suaveplan/testing-integration`
**Status**: PROPOSED
## ADDED Requirements
### Requirement: ServiceFixture contract
The package SHALL export a `ServiceFixture<TConfig extends Record<string, unknown>>` interface with `name: string`, `start(): Promise<TConfig>`, `stop(): Promise<void>`, and `healthCheck(): Promise<boolean>`. `stop()` MUST be idempotent. `healthCheck()` MUST NOT throw — it returns `false` to indicate "not yet ready" or "no container to probe".
#### Scenario: Implementations satisfy the four-method shape
- **WHEN** a value is typed as `ServiceFixture<{ host: string; port: number }>`
- **THEN** TypeScript enforces `name: string`, `start: () => Promise<{ host: string; port: number }>`, `stop: () => Promise<void>`, `healthCheck: () => Promise<boolean>`
#### Scenario: InferConfig extracts the TConfig type
- **WHEN** a consumer writes `type C = InferConfig<MyFixture>` for `MyFixture extends ServiceFixture<X>`
- **THEN** `C` resolves to `X`
#### Scenario: InferConfig returns never for non-fixture types
- **WHEN** `InferConfig<string>` is evaluated
- **THEN** the result is `never`
**Verified by:** `src/fixture/service-fixture.test.ts::"enforces the ServiceFixture<TConfig> shape"` AND `src/fixture/service-fixture.test.ts::"InferConfig extracts TConfig from a ServiceFixture implementation"` AND `src/fixture/service-fixture.test.ts::"InferConfig returns never for non-ServiceFixture types"`
---
### Requirement: TestEnvironment generic over fixture map
The package SHALL export `TestEnvironment<TFixtures extends DefaultFixtureMap = DefaultFixtureMap>`. `setup()` MUST start every fixture in `TFixtures` in parallel via `Promise.allSettled`, return `{ configs: { [K in keyof TFixtures]?: InferConfig<TFixtures[K]> }; failed: string[] }`, and stop fixtures in reverse start order during `teardown()`. `teardown()` MUST swallow per-fixture `stop()` errors so partial failures do not abort the rest of the teardown. The default generic parameter MUST keep pre-existing erased call sites compiling.
#### Scenario: setup() returns configs keyed by fixture name with precise per-fixture types
- **WHEN** `new TestEnvironment({ redis, postgres }).setup()` is called
- **THEN** the result `configs.redis` is typed as `RedisConfig | undefined` and `configs.postgres` is typed as `PostgresConfig | undefined`
#### Scenario: setup() records names of fixtures whose start() threw
- **WHEN** one fixture's `start()` rejects
- **THEN** `failed` contains its `name` and `configs` does not contain its key
#### Scenario: teardown() stops fixtures in reverse start order
- **WHEN** three fixtures `a`, `b`, `c` start in that order, then `teardown()` runs
- **THEN** their `stop()` methods are called in order `c`, `b`, `a`
#### Scenario: teardown() before setup() is a no-op
- **WHEN** `teardown()` is called on a fresh `TestEnvironment` without prior `setup()`
- **THEN** it resolves without throwing
#### Scenario: teardown() swallows errors from stop()
- **WHEN** a fixture's `stop()` throws
- **THEN** `teardown()` still resolves and the error is not propagated
#### Scenario: setup() runs fixtures in parallel
- **WHEN** two fixtures both block on a promise during `start()`
- **THEN** both `start()` invocations begin before either resolves
#### Scenario: setup() re-runs clear started list to avoid double-stop
- **WHEN** `setup()` is invoked twice in a row, then `teardown()` runs
- **THEN** each fixture's `stop()` is called exactly once
**Verified by:** `src/environment/test-environment.test.ts::"setup() starts all fixtures and returns configs + empty failed list"` AND `src/environment/test-environment.test.ts::"setup() records failed fixture names"` AND `src/environment/test-environment.test.ts::"teardown() stops fixtures in reverse start order"` AND `src/environment/test-environment.test.ts::"teardown() is safe when setup() was never called"` AND `src/environment/test-environment.test.ts::"teardown() swallows errors from stop()"` AND `src/environment/test-environment.test.ts::"setup() runs fixtures in parallel"` AND `src/environment/test-environment.test.ts::"setup() clears started list before re-running so teardown does not double-stop"` AND `src/environment/test-environment.spec-conformance.test.ts::"setup() configs[K] is the precise fixture TConfig"` AND `src/environment/test-environment.spec-conformance.test.ts::"default generic parameter keeps existing call sites compiling"`
---
### Requirement: HealthPoller polls fixtures with shared timeout semantics
The package SHALL export `HealthPoller` with `poll<TConfig extends Record<string, unknown>>(fixture: Pick<ServiceFixture<TConfig>, "name" | "healthCheck">): Promise<void>`. Polling MUST sleep `intervalMs` between probes, MUST throw a `TestEnvironmentError` (whose `name === "TestEnvironmentError"` and which extends `Error`) when wall-clock exceeds `timeoutMs`, and MUST NOT count the initial probe against the timeout (so `timeoutMs === intervalMs` permits at least one retry after the first unhealthy response). Default `timeoutMs` is `120_000`; default `intervalMs` is `1_000`. The `sleep` callback MUST be injectable so unit tests do not wait wall-clock seconds.
#### Scenario: Resolves immediately when first probe is healthy
- **WHEN** `healthCheck()` returns `true` on the first call
- **THEN** `poll()` resolves without sleeping
#### Scenario: Retries on false until healthy
- **WHEN** `healthCheck()` returns `false, false, true`
- **THEN** `poll()` resolves after exactly two `sleep(intervalMs)` calls
#### Scenario: Throws TestEnvironmentError on timeout
- **WHEN** `healthCheck()` keeps returning `false` past `timeoutMs`
- **THEN** the rejection is a `TestEnvironmentError` whose message includes `"Health check timed out"` and the fixture name
#### Scenario: timeoutMs === intervalMs permits one retry
- **WHEN** `timeoutMs === intervalMs` and `healthCheck()` returns `false` then `true`
- **THEN** `poll()` resolves rather than firing the timeout after the first unhealthy response
#### Scenario: poll<TConfig>() preserves precise fixture type
- **WHEN** `HealthPoller.poll<RedisConfig>(redisFixture)` is called
- **THEN** TypeScript accepts the call without widening the fixture to `ServiceFixture<Record<string, unknown>>`
**Verified by:** `src/helpers/health-poller.test.ts::"resolves immediately when first healthCheck returns true"` AND `src/helpers/health-poller.test.ts::"retries on false and resolves when healthCheck eventually returns true"` AND `src/helpers/health-poller.test.ts::"throws TestEnvironmentError when timeout elapses"` AND `src/helpers/health-poller.test.ts::"succeeds when fixture is unhealthy on call 1 but healthy on call 2, with timeoutMs === intervalMs"` AND `src/helpers/health-poller.test.ts::"uses default intervalMs of 1000"` AND `src/helpers/health-poller.test.ts::"uses default timeoutMs of 120000"` AND `src/helpers/health-poller.test.ts::"TestEnvironmentError has name TestEnvironmentError"` AND `src/environment/test-environment.spec-conformance.test.ts::"accepts any ServiceFixture<TConfig> without widening cast"`
---
### Requirement: skipIfUnavailable beforeAll helper
The package SHALL export `skipIfUnavailable<TName extends string = string>(fixtureName: TName): (ctx: { skip: () => void }) => void`. The returned callback MUST read `__integration_failed__` via Vitest's `inject` and MUST call `ctx.skip()` if `fixtureName` is in the list. A `null`/`undefined` return from `inject` MUST be treated as the empty list (no skip). The generic parameter MUST allow consumers paired with a typed `createIntegrationSetup` registry to constrain `fixtureName` to known keys.
#### Scenario: Skips when fixture is in failed list
- **WHEN** the failed list contains `"redis"` and `skipIfUnavailable("redis")()` runs
- **THEN** `ctx.skip()` is invoked exactly once
#### Scenario: Does not skip when fixture absent
- **WHEN** the failed list does not contain the fixture name
- **THEN** `ctx.skip()` is never called
#### Scenario: Treats null inject result as empty list
- **WHEN** the injected list is `null`
- **THEN** `ctx.skip()` is never called
#### Scenario: Generic narrows fixtureName
- **WHEN** `skipIfUnavailable<"redis" | "postgres">("redis")` compiles
- **THEN** TypeScript would reject `skipIfUnavailable<"redis" | "postgres">("typeo")`
**Verified by:** `src/helpers/skip-if-unavailable.test.ts::"calls skip() when fixture name is in failed list"` AND `src/helpers/skip-if-unavailable.test.ts::"does not call skip() when fixture name absent from failed list"` AND `src/helpers/skip-if-unavailable.test.ts::"does not call skip() when inject returns null"` AND `src/helpers/skip-if-unavailable.test.ts::"public skipIfUnavailable() returns a function"` AND `src/helpers/skip-if-unavailable.test.ts::"typed generic narrows fixtureName to known keys"` AND `src/environment/test-environment.spec-conformance.test.ts::"constrains fixtureName via TName generic"`
---
### Requirement: createIntegrationSetup wires fixtures + typed inject()
The package SHALL export `createIntegrationSetup<TFixtures>(fixtures): IntegrationSetupResult<TFixtures>` returning `{ globalSetup: string; inject: <K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]> }`. Calling MUST register every entry in the package-private `fixtureRegistry`. `globalSetup` MUST be an absolute path ending in `global-setup.js`. Multiple calls MUST accumulate into the shared registry. The implementation MAY share the underlying logic via `internal/createIntegrationSetupWithInject` for unit testing, but the `internal/` subpath MUST NOT be listed in `package.json` `exports`.
#### Scenario: Populates registry with provided fixtures
- **WHEN** `createIntegrationSetup({ redis, postgres })` is called
- **THEN** the package-private `fixtureRegistry` contains both entries
#### Scenario: globalSetup path ends with global-setup.js
- **WHEN** `createIntegrationSetup({})` returns
- **THEN** `result.globalSetup` is an absolute path ending with `global-setup.js`
#### Scenario: inject(key) returns the precise per-fixture config type
- **WHEN** `createIntegrationSetup({ redis, postgres }).inject("redis")` is called
- **THEN** the return type is `RedisConfig` (not `unknown` or `Record<string, unknown>`)
#### Scenario: Multiple calls accumulate into registry
- **WHEN** `createIntegrationSetup({ redis })` then `createIntegrationSetup({ postgres })` are called
- **THEN** the registry has both entries
**Verified by:** `src/setup/create-integration-setup.test.ts::"populates fixtureRegistry with provided fixtures"` AND `src/setup/create-integration-setup.test.ts::"returns globalSetup as absolute path ending with global-setup.js"` AND `src/setup/create-integration-setup.test.ts::"inject() reads from __integration_configs__ via injectable injectFn"` AND `src/setup/create-integration-setup.test.ts::"multiple calls accumulate into registry"` AND `src/environment/test-environment.spec-conformance.test.ts::"inject(key) returns the precise per-fixture config type"`
---
### Requirement: Internal subpath is not part of the public surface
The package's `package.json` `exports` map MUST NOT contain `"./internal"` or `"./internal/*"`. The `src/internal/*` modules (`fixtureRegistry`, `createIntegrationSetupWithInject`, `createSkipIfUnavailableWithInject`) are reachable only from within the package's own source tree.
#### Scenario: Public barrel only re-exports the supported surface
- **WHEN** the package barrel `src/index.ts` is read
- **THEN** it exports `TestEnvironment`, `TestEnvironmentError`, `HealthPoller`, `skipIfUnavailable`, `createIntegrationSetup`, types, AND nothing whose name starts with `_` or lives under `internal/`
**Verified by:** `src/setup/create-integration-setup.test.ts::"populates fixtureRegistry with provided fixtures"` (the test imports `fixtureRegistry` directly from `../internal/fixture-registry.js` — proving consumers MUST do likewise to reach it, which they cannot since `internal/` is not exported).
---
### Requirement: vitest is a peer dependency
The core `package.json` MUST list `vitest` in `peerDependencies`, NOT only in `devDependencies`. The runtime import in `src/setup/create-integration-setup.ts` (`import { inject } from "vitest"`) requires the consumer to have a satisfying `vitest` install at runtime.
#### Scenario: peer declaration exists
- **WHEN** the published `package.json` is inspected
- **THEN** `peerDependencies.vitest` is set to `>=3.0.0`
**Verified by:** `src/setup/create-integration-setup.test.ts::"populates fixtureRegistry with provided fixtures"` (this test imports `createIntegrationSetup`, which transitively imports `vitest`; the test's success in CI proves the peer is resolvable).