80 KiB
Implementation Plan: @suaveplan/testing-integration Core Package
Date: 2026-04-24
Branch: genesis
Package path: universal/dev/testing-integration/
Package name: @suaveplan/testing-integration
Version: 0.1.0
Goal
Build the core integration test infrastructure package from scratch. This package provides the ServiceFixture<T> interface, HealthPoller, TestEnvironment, createIntegrationSetup() factory, and skipIfUnavailable() helper. All six service fixture plugin packages (testing-integration-garage-s3, -surrealdb, -redis, -postgres, -mailpit, -typesense) depend on this package for the ServiceFixture interface contract.
The package achieves 100% coverage exclusively through unit tests — no Docker required. All Docker-dependent code paths are exercised via constructor-injected fakes.
Architecture
createIntegrationSetup(fixtures) ← called in consumer vitest.config.ts
│
├── writes each fixture into _fixtureRegistry (module-level Map)
└── returns { globalSetup: "/abs/path/to/dist/setup/global-setup.js", inject }
│
vitest config sets test.globalSetup = globalSetup path
│
Vitest loads global-setup.js in main Node.js process
│
global-setup.ts reads _fixtureRegistry (same ESM module instance)
└── new TestEnvironment(Object.fromEntries(_fixtureRegistry))
│
└── setup() → starts all fixtures in parallel via Promise.all
→ on any failure: tears down started fixtures before throwing
→ provide("__integration_configs__", configs)
→ provide("__integration_failed__", failed)
│
Test workers receive data via inject("__integration_configs__")
skipIfUnavailable("name") reads inject("__integration_failed__")
Why the module-level registry works: Vitest evaluates vitest.config.ts and loads globalSetup in the same main Node.js/Bun process. Both the vitest config (which calls createIntegrationSetup()) and global-setup.js (which imports ./create-integration-setup.js by relative path) resolve to the same filesystem path under dist/. Node.js ESM caches by resolved URL — one instance, shared. Mutations to _fixtureRegistry during config evaluation are visible when global-setup.ts runs during Vitest startup. Test workers are isolated forked processes and receive data only via inject().
Tech Stack
- Runtime: Bun (ESM, native TypeScript execution)
- Build: Vite +
vite-plugin-dts(preserveModules layout,esformat) - Tests: Vitest, pool
forks,provider: 'istanbul' - Lint/Format: Biome
- Dependencies:
testcontainers— external, pinned exact version in workspace rootoverrides(add"testcontainers": "10.22.2")vitest— devDependency,catalog:testing@suaveplan/types—workspace:^(zero-runtime exception per CLAUDE.md Rule 8)
- No other
@suaveplan/*runtime deps
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan.
Pre-flight Checklist
Before writing any code, verify:
ls /mnt/s/web/genesis/universal/dev/— confirmtesting/,testing-dom/,testing-e2e/exist. The new package goes alongside them attesting-integration/.grep "testcontainers" /mnt/s/web/genesis/package.json— if absent, add"testcontainers": "10.22.2"to theoverridesblock (exact version, per Rule 3 of CLAUDE.md).- Add
"testcontainers": "catalog:testing-infra"to thecatalogsinpackage.json'sworkspaces.catalogsblock, OR use an explicit pinned version string directly inpackage.jsondependencies if no catalog entry exists. The simplest approach: add"testcontainers": "10.22.2"toworkspaces.catalog(the flat catalog, not a named one) and reference it as"testcontainers": "catalog:"in the package'spackage.json.
Chunk 1 — Package Scaffold + ServiceFixture Interface
Goal: Create the package skeleton and the ServiceFixture<T> interface with type-level tests. All scaffolding files must be in place before any business logic is written. At the end of this chunk, bunx turbo lint typecheck test --filter=@suaveplan/testing-integration passes green (tests pass because the test file contains only type assertions that compile).
Task 1.1 — Create directory structure
mkdir -p /mnt/s/web/genesis/universal/dev/testing-integration/src/fixture
mkdir -p /mnt/s/web/genesis/universal/dev/testing-integration/src/helpers
mkdir -p /mnt/s/web/genesis/universal/dev/testing-integration/src/environment
mkdir -p /mnt/s/web/genesis/universal/dev/testing-integration/src/setup
Task 1.2 — Create package.json
File: /mnt/s/web/genesis/universal/dev/testing-integration/package.json
{
"name": "@suaveplan/testing-integration",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Core integration test infrastructure: ServiceFixture<T> interface, TestEnvironment, createIntegrationSetup() factory, HealthPoller, skipIfUnavailable() helper",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./fixture/service-fixture": {
"import": {
"types": "./dist/fixture/service-fixture.d.ts",
"default": "./dist/fixture/service-fixture.js"
}
},
"./helpers/health-poller": {
"import": {
"types": "./dist/helpers/health-poller.d.ts",
"default": "./dist/helpers/health-poller.js"
}
},
"./helpers/skip-if-unavailable": {
"import": {
"types": "./dist/helpers/skip-if-unavailable.d.ts",
"default": "./dist/helpers/skip-if-unavailable.js"
}
},
"./environment/test-environment": {
"import": {
"types": "./dist/environment/test-environment.d.ts",
"default": "./dist/environment/test-environment.js"
}
},
"./setup/create-integration-setup": {
"import": {
"types": "./dist/setup/create-integration-setup.d.ts",
"default": "./dist/setup/create-integration-setup.js"
}
},
"./setup/global-setup": {
"import": {
"types": "./dist/setup/global-setup.d.ts",
"default": "./dist/setup/global-setup.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/types": "workspace:^",
"testcontainers": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}
Note on testcontainers catalog entry: If "testcontainers" does not exist in the root package.json workspaces.catalog, add it:
// in /mnt/s/web/genesis/package.json, inside "workspaces": { "catalog": { ... } }
"testcontainers": "10.22.2"
Then reference it as "testcontainers": "catalog:" above.
Task 1.3 — Create tsconfig.lib.json
File: /mnt/s/web/genesis/universal/dev/testing-integration/tsconfig.lib.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"dist",
"node_modules"
]
}
Task 1.4 — Create vite.config.ts
File: /mnt/s/web/genesis/universal/dev/testing-integration/vite.config.ts
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/testing-integration",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: "src/index.ts",
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"vitest",
"vitest/node",
"testcontainers",
/^testcontainers\/.*/,
/^node:/,
/^@suaveplan\/.*/,
],
},
},
});
Task 1.5 — Create vitest.config.ts
File: /mnt/s/web/genesis/universal/dev/testing-integration/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: [
"src/**/*.test.ts",
],
exclude: [
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
// global-setup.ts runs in Vitest's globalSetup context (main process),
// not in the test runner process. It cannot be covered by vitest coverage.
"src/setup/global-setup.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});
Task 1.6 — Write failing test for ServiceFixture (TDD step 1)
Write the test first. This test uses expectTypeOf — it will fail with a compile error until the interface is implemented.
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/fixture/service-fixture.test.ts
import { describe, it, expectTypeOf } from "vitest";
import type { ServiceFixture, InferConfig } from "./service-fixture.js";
describe("ServiceFixture interface", () => {
it("enforces the ServiceFixture<TConfig> shape", { timeout: 5000 }, () => {
type ExampleConfig = { host: string; port: number };
// A valid implementation must satisfy the interface
const fixture: ServiceFixture<ExampleConfig> = {
name: "example",
start: async () => ({ host: "localhost", port: 1234 }),
stop: async () => {},
healthCheck: async () => true,
};
expectTypeOf(fixture.name).toEqualTypeOf<string>();
expectTypeOf(fixture.start).toEqualTypeOf<() => Promise<ExampleConfig>>();
expectTypeOf(fixture.stop).toEqualTypeOf<() => Promise<void>>();
expectTypeOf(fixture.healthCheck).toEqualTypeOf<() => Promise<boolean>>();
});
it("InferConfig extracts TConfig from a ServiceFixture implementation", { timeout: 5000 }, () => {
type ExampleConfig = { endpoint: string; apiKey: string };
interface MyFixture extends ServiceFixture<ExampleConfig> {}
type Extracted = InferConfig<MyFixture>;
expectTypeOf<Extracted>().toEqualTypeOf<ExampleConfig>();
});
it("InferConfig returns never for non-ServiceFixture types", { timeout: 5000 }, () => {
type Extracted = InferConfig<string>;
expectTypeOf<Extracted>().toEqualTypeOf<never>();
});
});
Run (expect compile failure — no implementation yet):
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Expected: TypeScript error — Cannot find module './service-fixture.js'.
Task 1.7 — Implement service-fixture.ts (TDD step 2)
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/fixture/service-fixture.ts
/**
* ServiceFixture<TConfig> — the core interface every integration test fixture must implement.
*
* A fixture manages the lifecycle of one external service (container or env-bridge passthrough).
* The TConfig type parameter describes the connection configuration produced when the service
* is healthy and ready.
*/
/**
* Represents a managed test service fixture.
*
* Implementors must handle two modes:
* 1. Container mode — spin up a Docker container via testcontainers, run health checks,
* and return config once healthy.
* 2. Env-bridge passthrough mode — when all required env vars are set and non-empty,
* return config from env vars immediately without starting a container or running
* healthCheck(). stop() is a no-op in this mode.
*/
export interface ServiceFixture<TConfig extends Record<string, unknown>> {
/**
* Unique name for this fixture. Used in log output, skip messages, and as the key
* in the configs map passed to provide(). Must be stable across runs.
*
* @example "garage-s3"
*/
readonly name: string;
/**
* Starts the service. In container mode, spins up a Docker container, waits for
* healthCheck() to return true (via HealthPoller), then returns the resolved config.
* In env-bridge passthrough mode, constructs config from env vars and returns
* immediately — healthCheck() is NOT called.
*
* @returns The connection configuration for this service.
* @throws {TestEnvironmentError} If the container fails to start or healthCheck times out.
*/
start(): Promise<TConfig>;
/**
* Stops the container. No-op in env-bridge passthrough mode.
* Safe to call multiple times (idempotent).
*/
stop(): Promise<void>;
/**
* Returns true when the service is accepting connections.
* Called periodically by HealthPoller in container mode.
* NEVER called in env-bridge passthrough mode.
*
* @returns true when ready, false when not yet ready.
*/
healthCheck(): Promise<boolean>;
}
/**
* Extracts the TConfig type parameter from a ServiceFixture implementation.
*
* @example
* type GarageS3Fixture = ServiceFixture<{ endpoint: string; bucket: string }>;
* type Config = InferConfig<GarageS3Fixture>; // { endpoint: string; bucket: string }
*/
export type InferConfig<F> = F extends ServiceFixture<infer C> ? C : never;
Task 1.8 — Create src/fixture/index.ts barrel
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/fixture/index.ts
export type { ServiceFixture, InferConfig } from "./service-fixture.js";
Task 1.9 — Create service-fixture.md
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/fixture/service-fixture.md
# `ServiceFixture<TConfig>`
## Purpose
`ServiceFixture<TConfig>` is the core interface every integration test fixture must implement. It abstracts the lifecycle of an external service used during integration testing — whether that service runs in a Docker container managed by testcontainers, or is an already-running service pointed to via environment variable overrides (env-bridge passthrough mode).
The `TConfig` type parameter describes the connection details the fixture produces once the service is healthy. For example, a Garage S3 fixture produces `{ endpoint, adminEndpoint, region, bucket, accessKeyId, secretAccessKey }`.
## Features
- Type-safe config extraction via the `InferConfig<F>` utility type
- Two operating modes: container mode (testcontainers) and env-bridge passthrough mode
- Stable `name` property used as a dictionary key in `TestEnvironment` output
- Explicit separation between `start()` (lifecycle) and `healthCheck()` (readiness probe)
- `stop()` is idempotent and a no-op in passthrough mode
## Usage: Basic Example
```typescript
import type { ServiceFixture } from "@suaveplan/testing-integration/fixture/service-fixture";
interface RedisConfig {
url: string;
host: string;
port: number;
}
class RedisFixture implements ServiceFixture<RedisConfig> {
readonly name = "redis";
async start(): Promise<RedisConfig> {
// container startup here
return { url: "redis://localhost:6379", host: "localhost", port: 6379 };
}
async stop(): Promise<void> {
// container stop here
}
async healthCheck(): Promise<boolean> {
// TCP ping to localhost:port
return true;
}
}
Usage: Advanced Example
import type { ServiceFixture, InferConfig } from "@suaveplan/testing-integration/fixture/service-fixture";
import type { RedisFixture } from "@suaveplan/testing-integration-redis";
// Extract the config type without importing the implementation
type RedisConfig = InferConfig<RedisFixture>;
// RedisConfig = { url: string; host: string; port: number }
// Compose multiple fixture types into a single config map
type AllConfigs = {
[K in "redis" | "postgres"]: InferConfig<ServiceFixture<Record<string, unknown>>>;
};
API Reference
ServiceFixture<TConfig>
Interface with four members:
| Member | Type | Description |
|---|---|---|
name |
readonly string |
Stable unique name. Used as key in configs map and skip messages. |
start() |
() => Promise<TConfig> |
Starts the service and returns connection config. |
stop() |
() => Promise<void> |
Stops the service. No-op in passthrough mode. Idempotent. |
healthCheck() |
() => Promise<boolean> |
Returns true when the service accepts connections. Never called in passthrough mode. |
InferConfig<F>
Utility type that extracts the TConfig from a ServiceFixture<TConfig>. Returns never for non-fixture types.
Implementation Notes
The interface is intentionally minimal. The name property is readonly to prevent accidental mutation after construction. The separation of start() and healthCheck() is deliberate — healthCheck() is intended for polling by HealthPoller, while start() owns the full container startup flow including the poll loop invocation. Fixtures that implement env-bridge passthrough skip the poll loop entirely — the developer who set the env vars is responsible for the service being healthy.
The TConfig extends Record<string, unknown> constraint ensures that InferConfig always produces an object type, never a primitive. This enables downstream code (such as TestEnvironment.setup()) to safely spread configs into a plain object map without runtime type guards.
**Run tests (expect green — type assertions compile now):**
```bash
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Expected output: all 3 type tests pass.
Task 1.10 — Create stub src/index.ts
This file grows as each chunk adds exports. Start with just the fixture barrel.
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/index.ts
// Core interface
export type { ServiceFixture, InferConfig } from "./fixture/index.js";
Chunk 2 — HealthPoller + skipIfUnavailable Helpers
Goal: Implement both helpers with 100% coverage. All tests use injected fakes — no real timers, no real Vitest inject. At the end of this chunk, coverage on helpers/health-poller.ts and helpers/skip-if-unavailable.ts is 100%.
Task 2.1 — Write failing test for HealthPoller (TDD step 1)
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/helpers/health-poller.test.ts
import { describe, it, expect, vi } from "vitest";
import { HealthPoller } from "./health-poller.js";
import type { ServiceFixture } from "../fixture/service-fixture.js";
/** Creates a minimal fake fixture with controllable healthCheck results. */
function makeFakeFixture(
responses: boolean[],
): ServiceFixture<Record<string, unknown>> {
let callCount = 0;
return {
name: "fake-service",
start: async () => ({}),
stop: async () => {},
healthCheck: async () => {
const result = responses[callCount] ?? false;
callCount++;
return result;
},
};
}
describe("HealthPoller", () => {
it("resolves immediately when first healthCheck returns true", { timeout: 5000 }, async () => {
const sleepCalls: number[] = [];
const fakeSleep = async (ms: number) => {
sleepCalls.push(ms);
};
const poller = new HealthPoller({ timeoutMs: 5000, intervalMs: 500, sleep: fakeSleep });
const fixture = makeFakeFixture([true]);
await expect(poller.poll(fixture)).resolves.toBeUndefined();
expect(sleepCalls).toHaveLength(0);
});
it("retries on false and resolves when healthCheck eventually returns true", { timeout: 5000 }, async () => {
const sleepCalls: number[] = [];
const fakeSleep = async (ms: number) => {
sleepCalls.push(ms);
};
const poller = new HealthPoller({ timeoutMs: 10000, intervalMs: 200, sleep: fakeSleep });
const fixture = makeFakeFixture([false, false, true]);
await expect(poller.poll(fixture)).resolves.toBeUndefined();
expect(sleepCalls).toEqual([200, 200]);
});
it("throws TestEnvironmentError when timeoutMs elapses without a true response", { timeout: 5000 }, async () => {
let elapsed = 0;
const fakeSleep = async (ms: number) => {
elapsed += ms;
};
// fixture always returns false
const fixture = makeFakeFixture([]);
// timeoutMs = 500, intervalMs = 200 → after 3 sleeps (600ms) timeout is exceeded
const poller = new HealthPoller({ timeoutMs: 500, intervalMs: 200, sleep: fakeSleep });
await expect(poller.poll(fixture)).rejects.toThrow("Health check timed out");
await expect(poller.poll(fixture)).rejects.toMatchObject({ name: "TestEnvironmentError" });
});
it("uses default intervalMs of 1000 when not specified", { timeout: 5000 }, async () => {
const sleepCalls: number[] = [];
const fakeSleep = async (ms: number) => {
sleepCalls.push(ms);
};
// Returns true on second call — one sleep should fire
const fixture = makeFakeFixture([false, true]);
const poller = new HealthPoller({ timeoutMs: 30000, sleep: fakeSleep });
await poller.poll(fixture);
expect(sleepCalls).toEqual([1000]);
});
it("uses default timeoutMs of 120000 when not specified", { timeout: 5000 }, async () => {
// This just ensures that a fixture returning true immediately does not time out
// even when using default options (no real sleep injected).
const fixture = makeFakeFixture([true]);
const poller = new HealthPoller({ sleep: async () => {} });
await expect(poller.poll(fixture)).resolves.toBeUndefined();
});
});
Run (expect failure — no implementation):
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Task 2.2 — Implement health-poller.ts (TDD step 2)
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/helpers/health-poller.ts
import type { ServiceFixture } from "../fixture/service-fixture.js";
/**
* Configuration options for HealthPoller.
*/
export interface HealthPollerOptions {
/**
* Maximum time to wait for the service to become healthy, in milliseconds.
* @default 120_000
*/
timeoutMs?: number;
/**
* Interval between health check attempts, in milliseconds.
* @default 1_000
*/
intervalMs?: number;
/**
* Injectable sleep function. Defaults to real `setTimeout`-based sleep.
* Override in unit tests to avoid real delays and achieve deterministic coverage.
*
* @example
* const fakeSleep = async (ms: number) => { recorded.push(ms); };
* new HealthPoller({ sleep: fakeSleep });
*/
sleep?: (ms: number) => Promise<void>;
}
/**
* Error thrown when a fixture fails to become healthy within the timeout.
*/
export class TestEnvironmentError extends Error {
override readonly name = "TestEnvironmentError";
constructor(message: string) {
super(message);
}
}
const DEFAULT_TIMEOUT_MS = 120_000;
const DEFAULT_INTERVAL_MS = 1_000;
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Polls a ServiceFixture's healthCheck() method until it returns true or a deadline elapses.
*
* Uses an injectable `sleep` function so unit tests can run without real timers.
* On timeout, throws `TestEnvironmentError` with the fixture name and elapsed time.
*/
export class HealthPoller {
private readonly timeoutMs: number;
private readonly intervalMs: number;
private readonly sleep: (ms: number) => Promise<void>;
constructor(options: HealthPollerOptions = {}) {
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
this.sleep = options.sleep ?? defaultSleep;
}
/**
* Polls `fixture.healthCheck()` at `intervalMs` intervals until it returns `true`
* or `timeoutMs` elapses.
*
* @param fixture - The fixture to poll.
* @throws {TestEnvironmentError} When the timeout elapses before healthCheck() returns true.
*/
async poll(fixture: ServiceFixture<Record<string, unknown>>): Promise<void> {
const deadline = Date.now() + this.timeoutMs;
let elapsed = 0;
while (true) {
const healthy = await fixture.healthCheck();
if (healthy) {
return;
}
elapsed += this.intervalMs;
if (elapsed >= this.timeoutMs) {
throw new TestEnvironmentError(
`Health check timed out for fixture "${fixture.name}" after ${this.timeoutMs}ms`,
);
}
await this.sleep(this.intervalMs);
}
}
}
Run tests (expect green):
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Task 2.3 — Create health-poller.md
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/helpers/health-poller.md
# `HealthPoller`
## Purpose
`HealthPoller` repeatedly calls a `ServiceFixture`'s `healthCheck()` method at a configurable interval until the service reports ready or a deadline elapses. It is used internally by `TestEnvironment.setup()` during container mode startup to prevent tests from running against services that are not yet accepting connections.
The `sleep` parameter is constructor-injectable, allowing unit tests to drive all timing logic without real delays. This design is what enables 100% branch coverage on the poller without slow tests.
## Features
- Configurable timeout (default 120 seconds) and poll interval (default 1 second)
- Injectable `sleep` function for deterministic unit testing with zero real delays
- Throws `TestEnvironmentError` with fixture name and elapsed time on timeout
- No dependencies on real timers in test paths — real `setTimeout` is only the runtime default
## Usage: Basic Example
```typescript
import { HealthPoller } from "@suaveplan/testing-integration/helpers/health-poller";
import type { ServiceFixture } from "@suaveplan/testing-integration/fixture/service-fixture";
const poller = new HealthPoller({ timeoutMs: 30_000, intervalMs: 500 });
await poller.poll(myFixture); // throws TestEnvironmentError if not healthy within 30s
Usage: Advanced Example
import { HealthPoller, TestEnvironmentError } from "@suaveplan/testing-integration/helpers/health-poller";
// Unit test usage with injectable sleep
const sleepLog: number[] = [];
const fakeSleep = async (ms: number) => { sleepLog.push(ms); };
const poller = new HealthPoller({
timeoutMs: 2000,
intervalMs: 500,
sleep: fakeSleep,
});
// healthCheck returns false twice, then true
let calls = 0;
const fixture = {
name: "redis",
start: async () => ({}),
stop: async () => {},
healthCheck: async () => ++calls >= 3,
};
await poller.poll(fixture);
console.log(sleepLog); // [500, 500] — two sleeps fired, no real delay
API Reference
class HealthPoller
Constructor
new HealthPoller(options?: HealthPollerOptions)
| Parameter | Type | Default | Description |
|---|---|---|---|
options.timeoutMs |
number |
120_000 |
Maximum wait time in milliseconds. |
options.intervalMs |
number |
1_000 |
Delay between healthCheck calls in milliseconds. |
options.sleep |
(ms: number) => Promise<void> |
setTimeout-based |
Delay implementation. Override in tests. |
poll(fixture)
async poll(fixture: ServiceFixture<Record<string, unknown>>): Promise<void>
Polls fixture.healthCheck() until it returns true or the timeout elapses. Resolves with undefined on success. Throws TestEnvironmentError on timeout.
class TestEnvironmentError extends Error
Thrown when poll() times out. Properties:
| Property | Value |
|---|---|
name |
"TestEnvironmentError" |
message |
Health check timed out for fixture "name" after Nms |
interface HealthPollerOptions
See constructor parameters above.
Implementation Notes
The timeout is tracked by accumulating sleep intervals rather than by querying Date.now() repeatedly. This ensures that the fake-sleep approach in tests produces exactly the expected number of sleep calls per scenario without floating-point drift. In production the difference is negligible because setTimeout jitter is absorbed into the next poll interval.
TestEnvironmentError is defined in this file (not re-exported from a separate error module) to keep the helper self-contained. TestEnvironment re-exports it from its own module for consumers who need to catch it.
### Task 2.4 — Write failing test for `skipIfUnavailable` (TDD step 1)
File: `/mnt/s/web/genesis/universal/dev/testing-integration/src/helpers/skip-if-unavailable.test.ts`
```typescript
import { describe, it, expect, vi } from "vitest";
// We test skipIfUnavailable by mocking vitest's inject at the module level.
// Because vi.mock() is unreliable with Bun ESM, we use a different approach:
// The implementation accepts an injectable inject function for testing purposes.
// See the implementation for details.
import { skipIfUnavailable, _createSkipIfUnavailableWithInject } from "./skip-if-unavailable.js";
describe("skipIfUnavailable", () => {
it("calls skip() when the fixture name is in the failed list", { timeout: 5000 }, () => {
const fakeInject = (_key: string) => ["garage-s3", "surrealdb"] as string[];
const cb = _createSkipIfUnavailableWithInject("garage-s3", fakeInject);
const skipFn = vi.fn();
cb({ skip: skipFn });
expect(skipFn).toHaveBeenCalledOnce();
});
it("does not call skip() when fixture name is absent from failed list", { timeout: 5000 }, () => {
const fakeInject = (_key: string) => ["surrealdb"] as string[];
const cb = _createSkipIfUnavailableWithInject("garage-s3", fakeInject);
const skipFn = vi.fn();
cb({ skip: skipFn });
expect(skipFn).not.toHaveBeenCalled();
});
it("does not call skip() when failed list is null/undefined (no global setup)", { timeout: 5000 }, () => {
const fakeInject = (_key: string) => null as unknown as string[];
const cb = _createSkipIfUnavailableWithInject("garage-s3", fakeInject);
const skipFn = vi.fn();
cb({ skip: skipFn });
expect(skipFn).not.toHaveBeenCalled();
});
it("returns a function (not void) — caller must pass to beforeAll()", { timeout: 5000 }, () => {
const fakeInject = (_key: string) => [] as string[];
const cb = _createSkipIfUnavailableWithInject("redis", fakeInject);
expect(typeof cb).toBe("function");
});
it("public skipIfUnavailable() returns a function", { timeout: 5000 }, () => {
// skipIfUnavailable() itself is just a thin wrapper — test its return type
const result = skipIfUnavailable("redis");
expect(typeof result).toBe("function");
});
});
Run (expect failure — no implementation):
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Task 2.5 — Implement skip-if-unavailable.ts (TDD step 2)
The public skipIfUnavailable() uses the real Vitest inject. The internal _createSkipIfUnavailableWithInject takes an injectable inject function, enabling unit testing without vi.mock. This follows the DI-over-mocking principle from the project's feedback notes.
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/helpers/skip-if-unavailable.ts
import { inject } from "vitest";
/**
* Internal factory used only for unit testing — accepts an injectable `inject` function.
* Not part of the public API; exported only for test files in this package.
*
* @internal
*/
export function _createSkipIfUnavailableWithInject(
fixtureName: string,
injectFn: (key: string) => string[] | null | undefined,
): (ctx: { skip: () => void }) => void {
return ({ skip }: { skip: () => void }) => {
const failed: string[] = injectFn("__integration_failed__") ?? [];
if (failed.includes(fixtureName)) {
skip();
}
};
}
/**
* Returns a `beforeAll`-compatible callback that skips the entire suite when the named
* fixture failed to start during `TestEnvironment.setup()`.
*
* The returned function must be passed directly to `beforeAll()` — do NOT call it inline.
* Vitest passes the suite context (`{ skip }`) to `beforeAll` callbacks, which is the only
* documented API for skipping a suite from `beforeAll`.
*
* @example
* describe("S3 integration", () => {
* beforeAll(skipIfUnavailable("garage-s3"));
*
* it("uploads a file", async () => { ... });
* });
*
* @param fixtureName - Must match the key used in `createIntegrationSetup({ [key]: fixture })`.
* @returns A callback for use with `beforeAll()`.
*/
export function skipIfUnavailable(
fixtureName: string,
): (ctx: { skip: () => void }) => void {
return _createSkipIfUnavailableWithInject(fixtureName, inject as (key: string) => string[] | null | undefined);
}
Run tests (expect green):
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Task 2.6 — Create skip-if-unavailable.md
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/helpers/skip-if-unavailable.md
# `skipIfUnavailable`
## Purpose
`skipIfUnavailable` returns a `beforeAll`-compatible callback that skips a Vitest test suite when the named integration fixture failed to start. It reads from Vitest's `inject("__integration_failed__")` channel, which is populated by `TestEnvironment.setup()` in the global setup phase.
This helper guards integration test suites from failing with confusing errors when Docker is unavailable or a container fails to become healthy. Instead of a cascade of connection-refused errors, the suite is silently skipped with a clear skip reason.
## Features
- Reads `__integration_failed__` from Vitest's inject channel, set by `global-setup.ts`
- Returns a callback suitable for `beforeAll()` — uses `ctx.skip()`, the only documented suite-skip API
- Safe when no global setup has run (`inject` returns `null` or `undefined`) — treats as empty failed list, so no skip fires
- Internal `_createSkipIfUnavailableWithInject` export enables 100% unit test coverage via DI without `vi.mock`
## Usage: Basic Example
```typescript
import { describe, it, beforeAll } from "vitest";
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
describe("Redis integration tests", () => {
beforeAll(skipIfUnavailable("redis"));
it("stores and retrieves a key", async () => {
// only runs when Redis fixture started successfully
});
});
Usage: Advanced Example
import { describe, it, beforeAll } from "vitest";
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
import { inject } from "../vitest.config.js";
describe("Multi-service integration", () => {
// Skip the whole suite if either dependency is unavailable
beforeAll(skipIfUnavailable("postgres"));
beforeAll(skipIfUnavailable("redis"));
let pgConfig: ReturnType<typeof inject<"postgres">>;
let redisConfig: ReturnType<typeof inject<"redis">>;
beforeAll(() => {
pgConfig = inject("postgres");
redisConfig = inject("redis");
});
it("reads from postgres and caches in redis", async () => {
// uses pgConfig and redisConfig
});
});
API Reference
skipIfUnavailable(fixtureName)
function skipIfUnavailable(fixtureName: string): (ctx: { skip: () => void }) => void
| Parameter | Type | Description |
|---|---|---|
fixtureName |
string |
The fixture name key from createIntegrationSetup(). Must match exactly (case-sensitive). |
Returns a callback to pass to beforeAll(). The callback calls ctx.skip() if fixtureName appears in the failed fixtures list.
_createSkipIfUnavailableWithInject (internal)
function _createSkipIfUnavailableWithInject(
fixtureName: string,
injectFn: (key: string) => string[] | null | undefined,
): (ctx: { skip: () => void }) => void
Internal factory exported only for unit testing. Pass a fake injectFn to avoid vi.mock. Not part of the stable public API.
Implementation Notes
Vitest's ctx.skip() API inside beforeAll is the only officially documented way to skip a suite programmatically. Using test.skip() or describe.skip() at call time would skip statically; using ctx.skip() inside beforeAll skips at runtime after the fixture availability check.
The _createSkipIfUnavailableWithInject pattern avoids vi.mock("vitest"), which is unreliable in Bun's ESM module system. By accepting the inject function as a parameter, both the happy path and the null-guard path are fully coverable with plain function calls.
### Task 2.7 — Create `src/helpers/index.ts` barrel
File: `/mnt/s/web/genesis/universal/dev/testing-integration/src/helpers/index.ts`
```typescript
export { HealthPoller, TestEnvironmentError } from "./health-poller.js";
export type { HealthPollerOptions } from "./health-poller.js";
export { skipIfUnavailable, _createSkipIfUnavailableWithInject } from "./skip-if-unavailable.js";
Task 2.8 — Update src/index.ts
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/index.ts
// Core interface
export type { ServiceFixture, InferConfig } from "./fixture/index.js";
// Helpers
export { HealthPoller, TestEnvironmentError } from "./helpers/index.js";
export type { HealthPollerOptions } from "./helpers/index.js";
export { skipIfUnavailable, _createSkipIfUnavailableWithInject } from "./helpers/index.js";
Run coverage check:
cd /mnt/s/web/genesis && bunx turbo test:coverage --filter=@suaveplan/testing-integration
Expected: 100% on health-poller.ts and skip-if-unavailable.ts.
Chunk 3 — TestEnvironment + createIntegrationSetup + global-setup + Wiring
Goal: Implement TestEnvironment, createIntegrationSetup(), and global-setup.ts. Wire the package root index.ts. Build the package. Run the full pre-publish gate.
Task 3.1 — Write failing test for TestEnvironment (TDD step 1)
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/environment/test-environment.test.ts
import { describe, it, expect, vi } from "vitest";
import { TestEnvironment, TestEnvironmentError } from "./test-environment.js";
import type { ServiceFixture } from "../fixture/service-fixture.js";
type FakeConfig = { url: string };
function makeFakeFixture(
name: string,
config: FakeConfig,
options: {
startError?: Error;
stopError?: Error;
startDelay?: Promise<void>;
} = {},
): ServiceFixture<FakeConfig> {
return {
name,
start: async () => {
if (options.startDelay) await options.startDelay;
if (options.startError) throw options.startError;
return config;
},
stop: async () => {
if (options.stopError) throw options.stopError;
},
healthCheck: async () => true,
};
}
describe("TestEnvironment", () => {
it("setup() starts all fixtures and returns configs + empty failed list", { timeout: 30000 }, async () => {
const fixtures = {
redis: makeFakeFixture("redis", { url: "redis://localhost:6379" }),
postgres: makeFakeFixture("postgres", { url: "pg://localhost:5432/test" }),
};
const env = new TestEnvironment(fixtures);
const result = await env.setup();
expect(result.configs).toEqual({
redis: { url: "redis://localhost:6379" },
postgres: { url: "pg://localhost:5432/test" },
});
expect(result.failed).toEqual([]);
});
it("setup() records failed fixture names in the failed array", { timeout: 30000 }, async () => {
const fixtures = {
redis: makeFakeFixture("redis", { url: "redis://localhost:6379" }),
broken: makeFakeFixture("broken", { url: "" }, { startError: new Error("Container failed") }),
};
const env = new TestEnvironment(fixtures);
const result = await env.setup();
expect(result.failed).toContain("broken");
expect(result.configs).not.toHaveProperty("broken");
expect(result.configs).toHaveProperty("redis");
});
it("setup() runs fixtures in parallel — all start() calls fire before any resolves", { timeout: 30000 }, async () => {
const order: string[] = [];
let resolveA!: () => void;
let resolveB!: () => void;
const promiseA = new Promise<void>((r) => { resolveA = r; });
const promiseB = new Promise<void>((r) => { resolveB = r; });
const fixtureA: ServiceFixture<FakeConfig> = {
name: "a",
start: async () => {
order.push("a-start");
await promiseA;
order.push("a-resolve");
return { url: "a" };
},
stop: async () => {},
healthCheck: async () => true,
};
const fixtureB: ServiceFixture<FakeConfig> = {
name: "b",
start: async () => {
order.push("b-start");
await promiseB;
order.push("b-resolve");
return { url: "b" };
},
stop: async () => {},
healthCheck: async () => true,
};
const setupPromise = new TestEnvironment({ a: fixtureA, b: fixtureB }).setup();
// Both starts fired before either resolved
await Promise.resolve(); // flush microtasks
expect(order).toContain("a-start");
expect(order).toContain("b-start");
resolveA();
resolveB();
await setupPromise;
expect(order).toContain("a-resolve");
expect(order).toContain("b-resolve");
});
it("teardown() calls stop() in reverse start order", { timeout: 30000 }, async () => {
const stopped: string[] = [];
const makeTrackedFixture = (name: string): ServiceFixture<FakeConfig> => ({
name,
start: async () => ({ url: name }),
stop: async () => { stopped.push(name); },
healthCheck: async () => true,
});
const env = new TestEnvironment({
a: makeTrackedFixture("a"),
b: makeTrackedFixture("b"),
c: makeTrackedFixture("c"),
});
await env.setup();
await env.teardown();
// Started in insertion order a,b,c → stopped in reverse c,b,a
expect(stopped).toEqual(["c", "b", "a"]);
});
it("teardown() is a no-op when setup() was never called", { timeout: 30000 }, async () => {
const env = new TestEnvironment({});
await expect(env.teardown()).resolves.toBeUndefined();
});
it("setup() stops already-started fixtures when a later fixture fails", { timeout: 30000 }, async () => {
const stopped: string[] = [];
const makeTrackedFixture = (name: string): ServiceFixture<FakeConfig> => ({
name,
start: async () => ({ url: name }),
stop: async () => { stopped.push(name); },
healthCheck: async () => true,
});
// Note: Object key order is insertion order in V8/Bun
const fixtures: Record<string, ServiceFixture<FakeConfig>> = {
redis: makeTrackedFixture("redis"),
broken: makeFakeFixture("broken", { url: "" }, { startError: new Error("boom") }),
};
const env = new TestEnvironment(fixtures as Record<string, ServiceFixture<Record<string, unknown>>>);
// Should NOT throw — failed fixtures are recorded, not thrown
const result = await env.setup();
expect(result.failed).toContain("broken");
// redis started successfully — still tracked for teardown
await env.teardown();
expect(stopped).toContain("redis");
});
it("TestEnvironmentError is constructable with a message", { timeout: 5000 }, () => {
const err = new TestEnvironmentError("test timeout");
expect(err.message).toBe("test timeout");
expect(err.name).toBe("TestEnvironmentError");
expect(err).toBeInstanceOf(Error);
});
});
Run (expect failure — no implementation):
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Task 3.2 — Create test-environment.types.ts
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/environment/test-environment.types.ts
/**
* Options for TestEnvironment.
*/
export interface TestEnvironmentOptions {
/**
* Per-fixture startup timeout in milliseconds.
* Passed to HealthPoller when the fixture does not time out on its own.
* @default 120_000
*/
timeoutMs?: number;
}
/**
* Result returned by TestEnvironment.setup().
*/
export interface TestEnvironmentSetupResult {
/**
* Map from fixture key (as passed to TestEnvironment constructor) to the config
* returned by that fixture's start() method.
* Failed fixtures are NOT included.
*/
configs: Record<string, Record<string, unknown>>;
/**
* Names of fixtures whose start() method threw. These are the `fixture.name` values
* (not the constructor key — though by convention they should match).
*/
failed: string[];
}
Task 3.3 — Implement test-environment.ts (TDD step 2)
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/environment/test-environment.ts
import type { ServiceFixture } from "../fixture/service-fixture.js";
import type { TestEnvironmentOptions, TestEnvironmentSetupResult } from "./test-environment.types.js";
/**
* Error thrown by TestEnvironment when a fixture fails to start.
* Also thrown by HealthPoller on timeout.
*/
export class TestEnvironmentError extends Error {
override readonly name = "TestEnvironmentError";
constructor(message: string) {
super(message);
}
}
/**
* Orchestrates the lifecycle of multiple ServiceFixture instances.
*
* setup() starts all fixtures in parallel. If any fixture fails, that fixture's
* name is recorded in the `failed` array of the returned result. Successfully
* started fixtures are tracked for teardown. teardown() stops them in reverse
* start order.
*
* setup() does NOT throw on partial fixture failure — callers must inspect
* `result.failed` and decide whether to proceed or abort. The global-setup
* module calls provide() with both configs and failed names so test workers
* can react via skipIfUnavailable().
*/
export class TestEnvironment {
/**
* Names of fixtures that successfully completed start(), in insertion order.
* Used by teardown() for reverse-order cleanup.
*/
protected readonly started: string[] = [];
constructor(
protected readonly fixtures: Record<string, ServiceFixture<Record<string, unknown>>>,
protected readonly options: TestEnvironmentOptions = {},
) {}
/**
* Starts all fixtures in parallel via Promise.all (allSettled).
*
* - Successful fixtures: added to `started`, config stored under the fixture's
* constructor key in `configs`.
* - Failed fixtures: name added to `failed`. teardown() will NOT attempt to stop them.
*
* @returns { configs, failed } — configs map for successful fixtures, failed names list.
*/
async setup(): Promise<TestEnvironmentSetupResult> {
const entries = Object.entries(this.fixtures);
const configs: Record<string, Record<string, unknown>> = {};
const failed: string[] = [];
const results = await Promise.allSettled(
entries.map(async ([key, fixture]) => {
const config = await fixture.start();
return { key, config, name: fixture.name };
}),
);
for (const result of results) {
if (result.status === "fulfilled") {
configs[result.value.key] = result.value.config;
this.started.push(result.value.name);
} else {
// Find the fixture name for this failure.
// We can't get it from the settled result directly, so we match by index.
const index = results.indexOf(result);
const [, fixture] = entries[index];
failed.push(fixture.name);
}
}
return { configs, failed };
}
/**
* Stops all successfully started fixtures in reverse start order.
* Safe to call when setup() was never called (started is empty).
* Errors from individual stop() calls are swallowed to ensure all
* fixtures get a stop attempt.
*/
async teardown(): Promise<void> {
const toStop = [...this.started].reverse();
for (const name of toStop) {
// Find fixture by name
const fixture = Object.values(this.fixtures).find((f) => f.name === name);
if (fixture) {
try {
await fixture.stop();
} catch {
// Swallow stop errors — best-effort cleanup
}
}
}
}
}
Run tests (expect green):
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Task 3.4 — Create test-environment.md
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/environment/test-environment.md
# `TestEnvironment`
## Purpose
`TestEnvironment` orchestrates the parallel startup and sequential teardown of multiple `ServiceFixture` instances during integration test runs. It is the bridge between the fixture implementations and Vitest's global setup mechanism: `global-setup.ts` creates a `TestEnvironment`, calls `setup()`, and passes the results to test workers via Vitest's `provide()` API.
`TestEnvironment` uses `Promise.allSettled` to start all fixtures in parallel and tolerates partial failure — failed fixtures are recorded in the `failed` array rather than causing the entire setup to throw. This design means that integration test suites guarded by `skipIfUnavailable()` can be skipped selectively when Docker is partially available or a single container fails to start.
## Features
- Parallel fixture startup via `Promise.allSettled`
- Partial-failure tolerance: failed fixture names collected without aborting the run
- Reverse-order teardown: fixtures stopped in the reverse of their successful start order
- `teardown()` is idempotent when called before `setup()` (no-op on empty started list)
- Stop errors are swallowed during teardown to ensure all fixtures get a stop attempt
- `TestEnvironmentError` re-exported for consumers who need to catch it
## Usage: Basic Example
```typescript
import { TestEnvironment } from "@suaveplan/testing-integration/environment/test-environment";
const env = new TestEnvironment({
redis: redisFixture,
postgres: postgresFixture,
});
const { configs, failed } = await env.setup();
// configs.redis = { url: "redis://localhost:..." }
// configs.postgres = { url: "postgresql://..." }
// failed = [] (or ["postgres"] if postgres failed)
await env.teardown();
Usage: Advanced Example
import { TestEnvironment, TestEnvironmentError } from "@suaveplan/testing-integration/environment/test-environment";
// Used by global-setup.ts — you typically do not call TestEnvironment directly.
// createIntegrationSetup() handles this for you.
let env: TestEnvironment;
export async function setup() {
env = new TestEnvironment({ redis: new RedisFixture(), s3: new GarageS3Fixture() });
const { configs, failed } = await env.setup();
provide("__integration_configs__", configs);
provide("__integration_failed__", failed);
}
export async function teardown() {
await env?.teardown();
}
API Reference
class TestEnvironment
Constructor
new TestEnvironment(
fixtures: Record<string, ServiceFixture<Record<string, unknown>>>,
options?: TestEnvironmentOptions,
)
| Parameter | Type | Description |
|---|---|---|
fixtures |
Record<string, ServiceFixture<...>> |
Map of fixture key → fixture instance. Keys become the config map keys in setup() output. |
options.timeoutMs |
number | undefined |
Reserved for per-fixture timeout (default 120_000). |
setup()
async setup(): Promise<{ configs: Record<string, Record<string, unknown>>; failed: string[] }>
Starts all fixtures in parallel. Returns { configs, failed } where configs contains only the successfully started fixtures and failed lists the names of fixtures that threw during start().
teardown()
async teardown(): Promise<void>
Stops all successfully started fixtures in reverse start order. Swallows individual stop errors. Safe to call on an environment that was never set up.
class TestEnvironmentError extends Error
| Property | Value |
|---|---|
name |
"TestEnvironmentError" |
Thrown by HealthPoller on timeout. Re-exported here for consumer convenience.
Implementation Notes
Promise.allSettled is used instead of Promise.all to guarantee that every fixture gets a start attempt even when one fails. This is important for the partial-availability case: if PostgreSQL is unavailable but Redis starts fine, the Redis test suite should still run.
Teardown uses reverse iteration over this.started (which only contains successfully started fixture names). Fixtures that failed to start are excluded because they never completed start() and may be in an undefined intermediate state.
The constructor key and fixture.name are treated as the same string by convention. The started array stores fixture.name values, and teardown resolves the fixture instance by scanning Object.values(this.fixtures) for a name match.
### Task 3.5 — Create `src/environment/index.ts` barrel
File: `/mnt/s/web/genesis/universal/dev/testing-integration/src/environment/index.ts`
```typescript
export { TestEnvironment, TestEnvironmentError } from "./test-environment.js";
export type { TestEnvironmentOptions, TestEnvironmentSetupResult } from "./test-environment.types.js";
Task 3.6 — Write failing test for createIntegrationSetup (TDD step 1)
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/setup/create-integration-setup.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import {
_fixtureRegistry,
createIntegrationSetup,
_createIntegrationSetupWithInject,
} from "./create-integration-setup.js";
import type { ServiceFixture } from "../fixture/service-fixture.js";
type FakeConfig = { url: string };
function makeFakeFixture(name: string, config: FakeConfig): ServiceFixture<FakeConfig> {
return {
name,
start: async () => config,
stop: async () => {},
healthCheck: async () => true,
};
}
describe("createIntegrationSetup", () => {
beforeEach(() => {
// Clean up registry between tests
_fixtureRegistry.clear();
});
it("populates _fixtureRegistry with provided fixtures", { timeout: 5000 }, () => {
const redisFixture = makeFakeFixture("redis", { url: "redis://localhost" });
const pgFixture = makeFakeFixture("postgres", { url: "pg://localhost" });
createIntegrationSetup({ redis: redisFixture, pg: pgFixture });
expect(_fixtureRegistry.get("redis")).toBe(redisFixture);
expect(_fixtureRegistry.get("pg")).toBe(pgFixture);
});
it("returns globalSetup as an absolute path string ending with global-setup.js", { timeout: 5000 }, () => {
const { globalSetup } = createIntegrationSetup({});
expect(typeof globalSetup).toBe("string");
expect(globalSetup).toMatch(/global-setup\.js$/);
// Must be absolute
expect(globalSetup.startsWith("/")).toBe(true);
});
it("inject() reads from __integration_configs__ and returns typed config", { timeout: 5000 }, () => {
const redisFixture = makeFakeFixture("redis", { url: "redis://localhost" });
const fakeInject = (_key: string) => ({
redis: { url: "redis://injected" },
});
const { inject } = _createIntegrationSetupWithInject(
{ redis: redisFixture },
fakeInject as (key: string) => Record<string, Record<string, unknown>>,
);
const config = inject("redis");
expect(config).toEqual({ url: "redis://injected" });
});
it("inject() returns the config keyed by the fixture name passed to createIntegrationSetup", { timeout: 5000 }, () => {
const fakeInject = (_key: string) => ({
s3: { endpoint: "http://localhost:9000", bucket: "test" },
});
const s3Fixture = makeFakeFixture("garage-s3", { url: "" }) as unknown as ServiceFixture<{ endpoint: string; bucket: string }>;
const { inject } = _createIntegrationSetupWithInject(
{ s3: s3Fixture },
fakeInject as (key: string) => Record<string, Record<string, unknown>>,
);
const config = inject("s3");
expect(config).toEqual({ endpoint: "http://localhost:9000", bucket: "test" });
});
it("multiple calls to createIntegrationSetup accumulate into _fixtureRegistry", { timeout: 5000 }, () => {
createIntegrationSetup({ redis: makeFakeFixture("redis", { url: "r" }) });
createIntegrationSetup({ postgres: makeFakeFixture("postgres", { url: "p" }) });
expect(_fixtureRegistry.size).toBe(2);
expect(_fixtureRegistry.has("redis")).toBe(true);
expect(_fixtureRegistry.has("postgres")).toBe(true);
});
});
Run (expect failure — no implementation):
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Task 3.7 — Create create-integration-setup.types.ts
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/setup/create-integration-setup.types.ts
import type { ServiceFixture, InferConfig } from "../fixture/service-fixture.js";
/**
* The object returned by createIntegrationSetup().
*/
export interface IntegrationSetupResult<
TFixtures extends Record<string, ServiceFixture<Record<string, unknown>>>,
> {
/**
* Absolute file path to the compiled global-setup.js module.
* Pass this directly to vitest config's `test.globalSetup`.
*
* @example
* const { globalSetup } = createIntegrationSetup({ s3: new GarageS3Fixture() });
* export default defineConfig({ test: { globalSetup } });
*/
globalSetup: string;
/**
* Typed wrapper around Vitest's inject(). Reads from "__integration_configs__"
* (set by global-setup.ts) and returns the config for the named fixture with
* the correct TypeScript type.
*
* Must be called inside a test file (test worker context), not in globalSetup.
*
* @example
* const { endpoint, bucket } = inject("s3");
*/
inject: <K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]>;
}
Task 3.8 — Implement create-integration-setup.ts (TDD step 2)
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/setup/create-integration-setup.ts
import { inject as vitestInject } from "vitest";
import type { ServiceFixture, InferConfig } from "../fixture/service-fixture.js";
import type { IntegrationSetupResult } from "./create-integration-setup.types.js";
/**
* Module-level fixture registry. Populated by createIntegrationSetup() when the
* vitest.config.ts is evaluated. Read by global-setup.ts when Vitest initialises
* the test run in the same main process.
*
* Both the vitest config module and global-setup.js resolve to the same ESM
* module URL (dist/setup/create-integration-setup.js), so they share this Map instance.
*
* @internal — exported for global-setup.ts and unit tests only.
*/
export const _fixtureRegistry = new Map<string, ServiceFixture<Record<string, unknown>>>();
/**
* Internal factory accepting an injectable inject function for unit testing.
* @internal
*/
export function _createIntegrationSetupWithInject<
TFixtures extends Record<string, ServiceFixture<Record<string, unknown>>>,
>(
fixtures: TFixtures,
injectFn: (key: string) => Record<string, Record<string, unknown>>,
): IntegrationSetupResult<TFixtures> {
for (const [key, fixture] of Object.entries(fixtures)) {
_fixtureRegistry.set(key, fixture);
}
// Resolve the absolute path to the compiled global-setup.js.
// import.meta.url is the URL of THIS file. global-setup.js lives alongside it.
const globalSetupUrl = new URL("./global-setup.js", import.meta.url);
const globalSetup = globalSetupUrl.pathname;
function inject<K extends keyof TFixtures>(key: K): InferConfig<TFixtures[K]> {
const allConfigs = injectFn("__integration_configs__");
return allConfigs[key as string] as InferConfig<TFixtures[K]>;
}
return { globalSetup, inject };
}
/**
* Registers integration test fixtures and returns a Vitest globalSetup path
* and a typed inject wrapper for use in test files.
*
* Call this in your `vitest.config.ts` and export the returned `inject` so test
* files can import it. Pass `globalSetup` to `test.globalSetup` in the Vitest config.
*
* @example
* // vitest.config.ts
* import { defineConfig } from "vitest/config";
* import { createIntegrationSetup } from "@suaveplan/testing-integration";
* import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
*
* const { globalSetup, inject } = createIntegrationSetup({
* s3: new GarageS3Fixture({ bucket: "test-bucket" }),
* });
*
* export { inject };
* export default defineConfig({ test: { globalSetup } });
*
* // a-test-file.test.ts
* import { inject } from "../vitest.config.js";
* const { endpoint, bucket } = inject("s3");
*
* @param fixtures - Record mapping string keys to ServiceFixture instances.
* @returns { globalSetup, inject } — path string and typed inject wrapper.
*/
export function createIntegrationSetup<
TFixtures extends Record<string, ServiceFixture<Record<string, unknown>>>,
>(fixtures: TFixtures): IntegrationSetupResult<TFixtures> {
return _createIntegrationSetupWithInject(
fixtures,
vitestInject as (key: string) => Record<string, Record<string, unknown>>,
);
}
Run tests (expect green):
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/testing-integration
Task 3.9 — Create create-integration-setup.md
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/setup/create-integration-setup.md
# `createIntegrationSetup`
## Purpose
`createIntegrationSetup()` is the primary entry point for wiring integration test fixtures into a Vitest configuration. It registers fixture instances into a module-level registry (`_fixtureRegistry`), resolves the absolute path to the package's compiled `global-setup.js`, and returns a typed `inject` wrapper so test files can retrieve per-fixture connection configs with full TypeScript inference.
The pattern is designed around Vitest's globalSetup + provide/inject mechanism: fixtures are registered during config evaluation (main process), the global setup runs in the same process and reads from the shared registry, and test workers receive config values via `inject()`.
## Features
- Populates a module-level `Map` visible to both config evaluation and globalSetup (same ESM module cache)
- Returns the absolute path to `global-setup.js` for direct use in `test.globalSetup`
- Returns a fully typed `inject<K>()` wrapper — the key type is narrowed to the fixture keys
- `InferConfig<TFixtures[K]>` ensures the returned config type matches the fixture's `start()` return type
- `_createIntegrationSetupWithInject` export allows unit testing without `vi.mock("vitest")`
## Usage: Basic Example
```typescript
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { createIntegrationSetup } from "@suaveplan/testing-integration";
import { RedisFixture } from "@suaveplan/testing-integration-redis";
const { globalSetup, inject } = createIntegrationSetup({
redis: new RedisFixture(),
});
export { inject };
export default defineConfig({
test: {
globalSetup,
pool: "forks",
},
});
// my-feature.test.ts
import { describe, it, expect, beforeAll } from "vitest";
import { skipIfUnavailable } from "@suaveplan/testing-integration";
import { inject } from "../vitest.config.js";
describe("Redis feature", () => {
beforeAll(skipIfUnavailable("redis"));
it("connects to Redis", () => {
const { url } = inject("redis");
expect(url).toMatch(/^redis:\/\//);
});
});
Usage: Advanced Example
// vitest.config.ts — multiple fixtures
import { createIntegrationSetup } from "@suaveplan/testing-integration";
import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
const { globalSetup, inject } = createIntegrationSetup({
s3: new GarageS3Fixture({ bucket: "my-test-bucket" }),
db: new SurrealDBFixture({ namespace: "test", database: "test" }),
});
export { inject };
export default defineConfig({ test: { globalSetup } });
// In a test:
const s3Config = inject("s3"); // type: GarageS3Config
const dbConfig = inject("db"); // type: SurrealDBConfig
API Reference
createIntegrationSetup(fixtures)
function createIntegrationSetup<
TFixtures extends Record<string, ServiceFixture<Record<string, unknown>>>,
>(fixtures: TFixtures): IntegrationSetupResult<TFixtures>
| Parameter | Type | Description |
|---|---|---|
fixtures |
Record<string, ServiceFixture<...>> |
Map of key → fixture instance. Keys are used as inject keys in test files. |
Returns { globalSetup, inject }.
_fixtureRegistry
const _fixtureRegistry: Map<string, ServiceFixture<Record<string, unknown>>>
Module-level registry. Exported for global-setup.ts and unit tests. Not part of the stable public API for consumers.
_createIntegrationSetupWithInject (internal)
Accepts a fake injectFn for unit testing. Same signature as createIntegrationSetup plus the inject function parameter.
Implementation Notes
The globalSetup path is resolved via new URL("./global-setup.js", import.meta.url).pathname. This works because when the package is built with preserveModules, create-integration-setup.js and global-setup.js are emitted as siblings in dist/setup/. The import.meta.url of the built module correctly points to dist/setup/create-integration-setup.js at runtime, so the resolved sibling URL is dist/setup/global-setup.js.
The _fixtureRegistry accumulates across multiple createIntegrationSetup() calls. This is intentional: a consumer may call createIntegrationSetup once per fixture group and the results compose.
### Task 3.10 — Implement `global-setup.ts`
This file does NOT have a test (it is excluded from coverage). It is also excluded from the TypeScript compilation that runs under vitest because it imports from `vitest/node`, which is only available in the main process.
File: `/mnt/s/web/genesis/universal/dev/testing-integration/src/setup/global-setup.ts`
```typescript
/**
* Static global setup module shipped with @suaveplan/testing-integration.
*
* This file is loaded by Vitest in the main process as a globalSetup module.
* It reads fixtures registered by createIntegrationSetup() into _fixtureRegistry,
* starts them via TestEnvironment, and provides the results to test workers via
* Vitest's provide() API.
*
* This file is excluded from coverage (it runs in Vitest's globalSetup context,
* not inside the test runner process, so istanbul cannot instrument it).
*
* DO NOT import from "vitest" (test worker API) here — only "vitest/node" is available.
*/
import { provide } from "vitest/node";
import { _fixtureRegistry } from "./create-integration-setup.js";
import { TestEnvironment } from "../environment/test-environment.js";
let env: TestEnvironment | undefined;
/**
* Called by Vitest before any test files run.
* Starts all registered fixtures and provides configs + failed names to test workers.
*/
export async function setup(): Promise<void> {
const fixtures = Object.fromEntries(_fixtureRegistry);
env = new TestEnvironment(fixtures);
const { configs, failed } = await env.setup();
provide("__integration_configs__", configs);
provide("__integration_failed__", failed);
}
/**
* Called by Vitest after all test files complete.
* Tears down all successfully started fixtures.
*/
export async function teardown(): Promise<void> {
await env?.teardown();
}
Task 3.11 — Create global-setup.md
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/setup/global-setup.md
# `global-setup.ts`
## Purpose
`global-setup.ts` is a static module shipped with `@suaveplan/testing-integration` that serves as the Vitest `globalSetup` entry point. It is never imported directly by consumers — instead, `createIntegrationSetup()` returns its compiled path so consumers can pass it to `test.globalSetup` in their Vitest config.
This module runs in the Vitest main process (not in test worker forks). It reads the fixture registry populated by `createIntegrationSetup()`, starts all fixtures via `TestEnvironment`, and passes the results to test workers via Vitest's `provide()` API.
## Features
- Reads from `_fixtureRegistry` (shared module instance with `create-integration-setup.js`)
- Calls `TestEnvironment.setup()` to start all fixtures in parallel
- Provides `__integration_configs__` and `__integration_failed__` to test workers
- Calls `TestEnvironment.teardown()` in the `teardown` export after all tests complete
- Safe when `setup()` was never called (`env` is guarded with optional chaining in teardown)
## Usage: Basic Example
Consumers never call this file directly. `createIntegrationSetup()` handles the wiring:
```typescript
// vitest.config.ts
import { createIntegrationSetup } from "@suaveplan/testing-integration";
const { globalSetup } = createIntegrationSetup({ redis: new RedisFixture() });
// globalSetup = "/abs/path/to/node_modules/@suaveplan/testing-integration/dist/setup/global-setup.js"
export default defineConfig({ test: { globalSetup } });
Usage: Advanced Example
If you need to inspect the global setup path for debugging:
import { resolve } from "node:path";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const pkgPath = require.resolve("@suaveplan/testing-integration/setup/global-setup");
console.log(pkgPath); // absolute path to dist/setup/global-setup.js
API Reference
setup(): Promise<void>
Vitest global setup hook. Called once before any test files run in the current process.
- Reads
_fixtureRegistry(populated during config evaluation). - Creates
new TestEnvironment(fixtures). - Calls
env.setup()to start all fixtures in parallel. - Calls
provide("__integration_configs__", configs). - Calls
provide("__integration_failed__", failed).
teardown(): Promise<void>
Vitest global teardown hook. Called once after all test files complete.
- Calls
env.teardown()to stop all started fixtures in reverse order. - No-op when
setup()was never called.
Implementation Notes
This file imports from "vitest/node" (the main-process API) rather than "vitest" (the test worker API). The two are not interchangeable — provide() is only available in vitest/node, and inject() is only available in "vitest" inside test workers.
This file is excluded from Vitest's coverage instrumentation (coverage.exclude in vitest.config.ts) because istanbul's instrumentation hooks are only active inside test worker processes. Code running in the globalSetup context is outside the coverage domain.
### Task 3.12 — Create `src/setup/index.ts` barrel
File: `/mnt/s/web/genesis/universal/dev/testing-integration/src/setup/index.ts`
```typescript
export { createIntegrationSetup, _fixtureRegistry, _createIntegrationSetupWithInject } from "./create-integration-setup.js";
export type { IntegrationSetupResult } from "./create-integration-setup.types.js";
Task 3.13 — Final src/index.ts
File: /mnt/s/web/genesis/universal/dev/testing-integration/src/index.ts
// Core interface
export type { ServiceFixture, InferConfig } from "./fixture/index.js";
// Helpers
export { HealthPoller, TestEnvironmentError } from "./helpers/index.js";
export type { HealthPollerOptions } from "./helpers/index.js";
export { skipIfUnavailable, _createSkipIfUnavailableWithInject } from "./helpers/index.js";
// Environment
export { TestEnvironment } from "./environment/index.js";
export type { TestEnvironmentOptions, TestEnvironmentSetupResult } from "./environment/index.js";
// Setup factory
export { createIntegrationSetup, _fixtureRegistry, _createIntegrationSetupWithInject } from "./setup/index.js";
export type { IntegrationSetupResult } from "./setup/index.js";
Task 3.14 — Create README.md
File: /mnt/s/web/genesis/universal/dev/testing-integration/README.md
# `@suaveplan/testing-integration`
Core integration test infrastructure for the Suaveplan monorepo. Provides the `ServiceFixture<T>` interface, `TestEnvironment`, `createIntegrationSetup()` factory, `HealthPoller`, and `skipIfUnavailable()` helper.
All six service fixture plugins (`testing-integration-garage-s3`, `-surrealdb`, `-redis`, `-postgres`, `-mailpit`, `-typesense`) implement `ServiceFixture<T>` from this package.
## Installation
```bash
bun add @suaveplan/testing-integration
Quick Start
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { createIntegrationSetup } from "@suaveplan/testing-integration";
import { RedisFixture } from "@suaveplan/testing-integration-redis";
import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
const { globalSetup, inject } = createIntegrationSetup({
redis: new RedisFixture(),
s3: new GarageS3Fixture({ bucket: "test-bucket" }),
});
export { inject };
export default defineConfig({
test: {
globalSetup,
pool: "forks",
},
});
// my-feature.test.ts
import { describe, it, beforeAll } from "vitest";
import { skipIfUnavailable } from "@suaveplan/testing-integration";
import { inject } from "../vitest.config.js";
describe("S3 upload", () => {
beforeAll(skipIfUnavailable("s3"));
it("uploads and retrieves an object", async () => {
const { endpoint, bucket, accessKeyId, secretAccessKey } = inject("s3");
// use endpoint, bucket, etc.
});
});
Module Reference
| Module | Description |
|---|---|
src/fixture/service-fixture.md |
ServiceFixture<TConfig> interface + InferConfig utility type |
src/helpers/health-poller.md |
HealthPoller — polls healthCheck() with injectable timer |
src/helpers/skip-if-unavailable.md |
skipIfUnavailable() — beforeAll callback to skip suites when a fixture failed |
src/environment/test-environment.md |
TestEnvironment — parallel start, reverse teardown, partial-failure tolerance |
src/setup/create-integration-setup.md |
createIntegrationSetup() factory + _fixtureRegistry |
src/setup/global-setup.md |
Static global-setup.ts — Vitest globalSetup hook |
Environment Variable Bridge
Each plugin fixture checks for env-var overrides before spinning a container. When all required vars for a fixture are set and non-empty, the fixture enters passthrough mode: start() returns config from env vars, healthCheck() is not called, and stop() is a no-op.
See the plugin package README files for the exact env var names per service.
Coverage
100% statement/branch/function/line coverage on all source files except global-setup.ts (excluded — runs in Vitest's globalSetup context outside the coverage domain).
### Task 3.15 — Run the full pre-publish gate
```bash
cd /mnt/s/web/genesis && bun install
cd /mnt/s/web/genesis && bunx turbo build --filter=@suaveplan/testing-integration
cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/testing-integration
Expected: green on all three.
cd /mnt/s/web/genesis && bunx turbo test:coverage --filter=@suaveplan/testing-integration
Expected: 100% statements/branches/functions/lines on all covered files.
If any step fails:
- Lint failures: Run
bunx biome check --write --unsafe src/from the package root to auto-fix, then inspect remaining issues. - Type failures: The most common issue is
vitest/nodenot being resolvable fromtsconfig.lib.json. If so, add"vitest/node"to theexternalarray invite.config.ts(it is already there), and ensuretsconfig.lib.jsondoes not includeglobal-setup.tsin itsincludeglob (it already excludes.test.tsfiles;global-setup.tsis not a test file but also does not need to be type-checked by the library tsconfig because it usesvitest/nodetypes not available in the lib target).- Fix: add
"src/setup/global-setup.ts"to theexcludearray intsconfig.lib.json.
- Fix: add
- Coverage below 100%: Inspect the HTML report at
coverage/index.html. Every uncovered branch intest-environment.tsorcreate-integration-setup.tsneeds a corresponding test case.
Task 3.16 — Verify build output
ls /mnt/s/web/genesis/universal/dev/testing-integration/dist/setup/
Expected output must include:
global-setup.jscreate-integration-setup.jsindex.js
The globalSetup path returned by createIntegrationSetup() resolves to dist/setup/global-setup.js relative to the built module. Confirm the URL resolution works:
cd /mnt/s/web/genesis && node --input-type=module << 'EOF'
import { createIntegrationSetup } from "./universal/dev/testing-integration/dist/index.js";
const { globalSetup } = createIntegrationSetup({});
console.log("globalSetup path:", globalSetup);
import { existsSync } from "node:fs";
console.log("file exists:", existsSync(globalSetup));
EOF
Expected: path printed, file exists: true.
Post-Implementation Checklist
Before marking this chunk complete, verify each item:
- All tests pass:
bunx turbo test --filter=@suaveplan/testing-integration - Coverage 100%:
bunx turbo test:coverage --filter=@suaveplan/testing-integration - Lint green:
bunx turbo lint --filter=@suaveplan/testing-integration - Types green:
bunx turbo typecheck --filter=@suaveplan/testing-integration - Build succeeds:
bunx turbo build --filter=@suaveplan/testing-integration dist/setup/global-setup.jsexists in build outputglobalSetuppath resolves to an existing file at runtime- All
.tsimplementation files have a sibling.mdwith ≥200 words - No
README.mdinsidesrc/subdirectories src/setup/global-setup.tsis incoverage.excludeinvitest.config.ts@suaveplan/testing-integrationadded toCLAUDE.mdpackages listingtestcontainersadded to rootpackage.jsonworkspaces.catalog
File Tree Summary
universal/dev/testing-integration/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
index.ts
fixture/
index.ts
service-fixture.ts
service-fixture.test.ts ← type-level tests via expectTypeOf
service-fixture.md
helpers/
index.ts
health-poller.ts
health-poller.test.ts ← fake sleep, fake fixture
health-poller.md
skip-if-unavailable.ts
skip-if-unavailable.test.ts ← fake inject via _createSkipIfUnavailableWithInject
skip-if-unavailable.md
environment/
index.ts
test-environment.ts
test-environment.types.ts
test-environment.test.ts ← fake fixtures, no Docker
test-environment.md
setup/
index.ts
create-integration-setup.ts
create-integration-setup.types.ts
create-integration-setup.test.ts ← fake inject via _createIntegrationSetupWithInject
create-integration-setup.md
global-setup.ts ← excluded from coverage, excluded from tsconfig
global-setup.md
Total implementation files requiring .md: 6 (service-fixture.ts, health-poller.ts, skip-if-unavailable.ts, test-environment.ts, create-integration-setup.ts, global-setup.ts). All 6 have sibling .md files in this plan.
Files excluded from coverage: global-setup.ts (listed in coverage.exclude).
Files excluded from type checking: global-setup.ts should be added to tsconfig.lib.json exclude list to avoid vitest/node type resolution errors in the library build target.