Files
suaveplan/docs/superpowers/plans/2026-04-24-testing-integration-core.md
2026-05-27 23:29:53 +01:00

2255 lines
80 KiB
Markdown

# 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
```text
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, `es` format)
- **Tests:** Vitest, pool `forks`, `provider: 'istanbul'`
- **Lint/Format:** Biome
- **Dependencies:**
- `testcontainers` — external, pinned exact version in workspace root `overrides` (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:
1. `ls /mnt/s/web/genesis/universal/dev/` — confirm `testing/`, `testing-dom/`, `testing-e2e/` exist. The new package goes alongside them at `testing-integration/`.
2. `grep "testcontainers" /mnt/s/web/genesis/package.json` — if absent, add `"testcontainers": "10.22.2"` to the `overrides` block (exact version, per Rule 3 of CLAUDE.md).
3. Add `"testcontainers": "catalog:testing-infra"` to the `catalogs` in `package.json`'s `workspaces.catalogs` block, OR use an explicit pinned version string directly in `package.json` dependencies if no catalog entry exists. The simplest approach: add `"testcontainers": "10.22.2"` to `workspaces.catalog` (the flat catalog, not a named one) and reference it as `"testcontainers": "catalog:"` in the package's `package.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
```bash
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`
```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:
```json
// 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`
```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`
```typescript
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`
```typescript
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`
```typescript
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):**
```bash
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`
```typescript
/**
* 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`
```typescript
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`
```markdown
# `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
```typescript
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.
```text
**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`
```typescript
// 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`
```typescript
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):**
```bash
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`
```typescript
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):**
```bash
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`
```markdown
# `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
```typescript
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
```typescript
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)`
```typescript
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.
```text
### 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):**
```bash
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`
```typescript
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):**
```bash
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`
```markdown
# `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
```typescript
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)`
```typescript
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)
```typescript
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.
```text
### 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`
```typescript
// 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:**
```bash
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`
```typescript
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):**
```bash
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`
```typescript
/**
* 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`
```typescript
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):**
```bash
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`
```markdown
# `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
```typescript
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
```typescript
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()`
```typescript
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()`
```typescript
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.
```text
### 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`
```typescript
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):**
```bash
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`
```typescript
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`
```typescript
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):**
```bash
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`
```markdown
# `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",
},
});
```
```typescript
// 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
```typescript
// 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)`
```typescript
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`
```typescript
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.
```text
### 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`
```markdown
# `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:
```typescript
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.
1. Reads `_fixtureRegistry` (populated during config evaluation).
2. Creates `new TestEnvironment(fixtures)`.
3. Calls `env.setup()` to start all fixtures in parallel.
4. Calls `provide("__integration_configs__", configs)`.
5. Calls `provide("__integration_failed__", failed)`.
### `teardown(): Promise<void>`
Vitest global teardown hook. Called once after all test files complete.
1. Calls `env.teardown()` to stop all started fixtures in reverse order.
2. 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.
```text
### 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`
```typescript
// 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`
```markdown
# `@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
```typescript
// 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",
},
});
```
```typescript
// 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`](src/fixture/service-fixture.md) | `ServiceFixture<TConfig>` interface + `InferConfig` utility type |
| [`src/helpers/health-poller.md`](src/helpers/health-poller.md) | `HealthPoller` — polls `healthCheck()` with injectable timer |
| [`src/helpers/skip-if-unavailable.md`](src/helpers/skip-if-unavailable.md) | `skipIfUnavailable()` — `beforeAll` callback to skip suites when a fixture failed |
| [`src/environment/test-environment.md`](src/environment/test-environment.md) | `TestEnvironment` — parallel start, reverse teardown, partial-failure tolerance |
| [`src/setup/create-integration-setup.md`](src/setup/create-integration-setup.md) | `createIntegrationSetup()` factory + `_fixtureRegistry` |
| [`src/setup/global-setup.md`](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).
```text
### 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.
```bash
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/node` not being resolvable from `tsconfig.lib.json`. If so, add `"vitest/node"` to the `external` array in `vite.config.ts` (it is already there), and ensure `tsconfig.lib.json` does not include `global-setup.ts` in its `include` glob (it already excludes `.test.ts` files; `global-setup.ts` is not a test file but also does not need to be type-checked by the library tsconfig because it uses `vitest/node` types not available in the lib target).
- Fix: add `"src/setup/global-setup.ts"` to the `exclude` array in `tsconfig.lib.json`.
- **Coverage below 100%:** Inspect the HTML report at `coverage/index.html`. Every uncovered branch in `test-environment.ts` or `create-integration-setup.ts` needs a corresponding test case.
### Task 3.16 — Verify build output
```bash
ls /mnt/s/web/genesis/universal/dev/testing-integration/dist/setup/
```
Expected output must include:
- `global-setup.js`
- `create-integration-setup.js`
- `index.js`
The `globalSetup` path returned by `createIntegrationSetup()` resolves to `dist/setup/global-setup.js` relative to the built module. Confirm the URL resolution works:
```bash
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.js` exists in build output
- [ ] `globalSetup` path resolves to an existing file at runtime
- [ ] All `.ts` implementation files have a sibling `.md` with ≥200 words
- [ ] No `README.md` inside `src/` subdirectories
- [ ] `src/setup/global-setup.ts` is in `coverage.exclude` in `vitest.config.ts`
- [ ] `@suaveplan/testing-integration` added to `CLAUDE.md` packages listing
- [ ] `testcontainers` added to root `package.json` `workspaces.catalog`
---
## File Tree Summary
```text
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.