12 KiB
ADDED Requirements
Requirement: ServiceFixture contract
The @suaveplan/testing-integration package SHALL export an interface ServiceFixture<TConfig extends Record<string, unknown>> that every plugin fixture MUST implement. The interface defines the universal lifecycle of a service used by integration tests, whether backed by a testcontainers-managed Docker container or by an env-bridge passthrough to an already-running service.
The interface MUST expose the following members:
readonly name: string— globally unique among fixtures registered into oneTestEnvironment.readonly config: TConfig— the resolved configuration afterstart(); beforestart()MAY be a partial draft.readonly dependsOn?: readonly string[]— names of fixtures that MUST start before this one.start(opts: { signal: AbortSignal }): Promise<void>— owns container startup and the health-poll loop; honours abort.stop(opts: { signal: AbortSignal }): Promise<void>— releases all resources; idempotent; no-op in env-bridge passthrough mode.isHealthy(): Promise<boolean>— single health probe; called byHealthPoller; never throws.env(): Record<string, string>— the full set of environment variables the fixture forwards to worker pools.
A utility type InferConfig<F> SHALL extract TConfig from a ServiceFixture implementation; it returns never for non-fixture types.
Scenario: Fixture exposes the full env set
- WHEN a plugin instance is constructed and
env()is read - THEN the returned record contains every environment variable name the plugin's spec declares — no implicit filtering
Scenario: Idempotent stop is safe
- WHEN
stop()is awaited twice on the same fixture instance - THEN the second call resolves without throwing AND does not attempt to interact with already-released resources
Scenario: InferConfig narrows TConfig
- WHEN
InferConfig<RedisFixture>is evaluated whereRedisFixture extends ServiceFixture<{ url: string }> - THEN the resulting type is
{ url: string }
Requirement: TestEnvironment topological lifecycle
TestEnvironment SHALL accept a Record<string, ServiceFixture<…>> plus TestEnvironmentOptions and expose setupAll() / teardownAll(). setupAll() MUST compute a topological ordering from each fixture's dependsOn and start fixtures in parallel within each topological layer. teardownAll() MUST stop fixtures in reverse-topological order. A started flag MUST prevent double-start. A fixture that never started MUST never be stopped. A failing stop MUST NOT short-circuit teardown — teardownAll() MUST drain remaining fixtures and aggregate errors.
Scenario: Parallel start within a layer
- WHEN three fixtures
a,b,chave nodependsOnandsetupAll()is called - THEN all three
start()calls are in flight before any resolves
Scenario: Topological order respects dependsOn
- WHEN fixture
appdeclaresdependsOn: ["oidc"]andsetupAll()runs - THEN
oidc.start()resolves beforeapp.start()is invoked
Scenario: Reverse-topological stop
- WHEN
teardownAll()runs against the same set - THEN
app.stop()is invoked beforeoidc.stop()
Scenario: Failed start does not start dependents and does not block teardown
- WHEN
oidc.start()rejects - THEN
app.start()is never invoked ANDteardownAll()callsoidc.stop()only ifoidc.start()had begun, AND remaining fixtures'stop()calls all run regardless of any single failure
Scenario: Double setup throws
- WHEN
setupAll()is awaited twice on the same instance - THEN the second call rejects with
INTEGRATION.ALREADY_STARTED
Requirement: HealthPoller contract
HealthPoller SHALL accept { intervalMs?: number; timeoutMs?: number; sleep?: (ms: number) => Promise<void> } with defaults intervalMs = 500 and timeoutMs = 120_000. poll(fixture, { signal }) MUST call fixture.isHealthy() repeatedly until it returns true or the deadline elapses. On deadline, the promise rejects with a TestEnvironmentError whose code === "INTEGRATION.HEALTH_TIMEOUT", context.fixtureName, and context.elapsedMs are populated. The poller MUST honour AbortSignal and reject with INTEGRATION.ABORTED on first observed abort.
Scenario: Healthy fixture resolves promptly
- WHEN
fixture.isHealthy()returnstrueon the first call - THEN
poll()resolves before a second call is scheduled
Scenario: Deadline elapsed
- WHEN
fixture.isHealthy()always returnsfalseand 120 s elapse - THEN
poll()rejects withINTEGRATION.HEALTH_TIMEOUTANDcontext.elapsedMs >= 120000
Scenario: Abort honoured
- WHEN the caller-provided
signalaborts mid-poll - THEN
poll()rejects withINTEGRATION.ABORTEDwithin one event-loop tick of the abort
Requirement: skipIfUnavailable() helper
The package SHALL export skipIfUnavailable(fixtureName: string): void that, when called from the body of a Vitest it/test, causes the test to be skipped (not failed) when the named fixture failed to start during globalSetup. The helper MUST read the inject key __suaveplan_integration_failed__ and gracefully no-op when the inject key is absent (e.g. unit-test mode). A bun-test mode SHALL emit the equivalent it.skip behaviour.
Scenario: Skip on partial failure
- WHEN
surrealdb.start()failed during global setup,provide("__suaveplan_integration_failed__", { surrealdb: true })was called, and a test callsskipIfUnavailable("surrealdb") - THEN the test is reported as skipped, not failed
Scenario: No-op when inject key absent
- WHEN the test runner has no global-setup wired and
skipIfUnavailable("anything")is called - THEN the call returns without throwing AND the test continues
Requirement: createIntegrationSetup() factory and Vitest global-setup bridge
createIntegrationSetup(fixtures) SHALL register the supplied fixture map in a module-level registry and return a globalSetup array suitable for a Vitest config. At Vitest startup, the bridge MUST construct a single shared TestEnvironment from the registered fixtures, run setupAll(), and on partial failure call provide("__suaveplan_integration_failed__", failures) where failures is Record<string, true> keyed by fixture name. On full success it MUST provide() an empty record. Every env var returned by every fixture's env() MUST be set on process.env before the worker pool is forked.
Scenario: Successful setup propagates env
- WHEN all fixtures start successfully and their
env()returns disjoint keys - THEN every key is present in
process.envbefore any test worker forks
Scenario: Partial failure is reported via inject key
- WHEN one fixture's
start()rejects - THEN
provide("__suaveplan_integration_failed__", { <failedName>: true })is invoked AND the worker pool still starts so unaffected tests can run
Requirement: Deterministic injection propagation via TestEnvironmentOptions
TestEnvironmentOptions SHALL include clock?: Clock; rng?: Rng; signal?: AbortSignal. When a fixture's config declares acceptsContext: true, TestEnvironment MUST forward the same clock / rng / signal references to that fixture before invoking start(). Fixtures that do not opt in MUST be unaffected. This requirement extends the deterministic-injection contract from openspec/specs/testing/spec.md into the integration substrate.
Scenario: Opt-in fixture sees the shared clock
- WHEN
TestEnvironmentis constructed with{ clock: fakeClock }and a fixture's config declaresacceptsContext: true - THEN that fixture's
config.clock === fakeClockat the timestart()is invoked
Scenario: Non-opt-in fixture is unaffected
- WHEN the same
TestEnvironmentincludes a fixture whose config does NOT declareacceptsContext: true - THEN that fixture's config is left untouched
Requirement: Leak detection on teardown
After teardownAll() resolves, TestEnvironment SHALL inspect the host runtime's active-handle set (e.g. process._getActiveHandles() on Node, the Bun equivalent on Bun) and emit a structured warning with code INTEGRATION.LEAK_DETECTED plus an OTel counter increment testing.integration.leak.count for each handle attributable to a fixture that should have released. The warning MUST identify the fixture by name when attribution is possible.
Scenario: Handle leak detected
- WHEN a fixture's
stop()returns but a TCP socket it owned remains open - THEN
teardownAll()emits anINTEGRATION.LEAK_DETECTEDwarning withcontext.fixtureNameAND incrementstesting.integration.leak.count
Requirement: Flake quarantine
The package SHALL export markFlaky(testFn, { retries, reason }) that wraps a test with bounded retries and emits a testing.integration.flake.count metric increment per retry attempt. Quarantined tests MUST be enumerated in a quarantine.json at the package root listing { test: string; reason: string; addedAt: string } entries. A test that exhausts its retries MUST fail with INTEGRATION.QUARANTINED carrying the reason in context.
Scenario: Flaky test passes within budget
- WHEN
markFlaky(fn, { retries: 3, reason: "TC-123" })is run and the underlying test fails twice then passes - THEN the test is reported as passing AND
testing.integration.flake.countis incremented twice
Scenario: Flaky test exhausts budget
- WHEN the underlying test fails on every retry
- THEN the wrapping test fails with
INTEGRATION.QUARANTINEDANDcontext.reason === "TC-123"
Requirement: Coverage gate
Istanbul statement, branch, function, and line coverage across packages/testing/testing-integration/src/ MUST each be exactly 100 %. The pre-publish gate (bunx turbo test:coverage --filter=@suaveplan/testing-integration) MUST hard-fail below 100 % on any dimension.
Scenario: Coverage gate enforced
- WHEN the pre-publish gate runs
- THEN statement / branch / function / line coverage are each
100 %
Requirement: OTel observability
Every public entrypoint SHALL emit OTel counters (testing.integration.<op>.count), histograms (testing.integration.<op>.duration_ms), and an error counter (testing.integration.<op>.errors{code}). Spans SHALL be created for setupAll, teardownAll, and HealthPoller.poll. When no SDK is registered, all instrumentation MUST be a no-op with overhead below 5 % per op vs an unwrapped baseline.
Scenario: Counter increments on every setup
- WHEN
setupAll()runs N times against a registered SDK - THEN
testing.integration.setup.countrecords N increments
Scenario: Error counter records code attribute
- WHEN
HealthPoller.pollrejects withINTEGRATION.HEALTH_TIMEOUT - THEN
testing.integration.health-poll.errorsrecordscode = "INTEGRATION.HEALTH_TIMEOUT"
Requirement: Banned workspace deps
@suaveplan/testing-integration dependencies, peerDependencies, and devDependencies MAY list @suaveplan/types (the documented types-only exception in CLAUDE.md rule 8) but MUST NOT list any other @suaveplan/* workspace package. testcontainers MUST be a peer dependency.
Scenario: Dep-graph guard
- WHEN
bunx turbo ls --filter=@suaveplan/testing-integration...runs - THEN the only
@suaveplan/*package in the closure is@suaveplan/typesANDtestcontainersresolves via peer dependency
Requirement: Inject-key namespace
The integration substrate SHALL use the inject key __suaveplan_integration_failed__ (renamed from the development-time literal __integration_failed__ used in the implementation plans) for partial-failure propagation. The key MUST NOT collide with any other inject key in the workspace.
Scenario: Renamed inject key is the contract
- WHEN any consumer calls
inject("__suaveplan_integration_failed__") - THEN the key resolves to a
Record<string, true>(possibly empty) populated bycreateIntegrationSetup's global-setup bridge