# 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` 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` 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 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 shape", { timeout: 5000 }, () => { type ExampleConfig = { host: string; port: number }; // A valid implementation must satisfy the interface const fixture: ServiceFixture = { name: "example", start: async () => ({ host: "localhost", port: 1234 }), stop: async () => {}, healthCheck: async () => true, }; expectTypeOf(fixture.name).toEqualTypeOf(); expectTypeOf(fixture.start).toEqualTypeOf<() => Promise>(); expectTypeOf(fixture.stop).toEqualTypeOf<() => Promise>(); expectTypeOf(fixture.healthCheck).toEqualTypeOf<() => Promise>(); }); it("InferConfig extracts TConfig from a ServiceFixture implementation", { timeout: 5000 }, () => { type ExampleConfig = { endpoint: string; apiKey: string }; interface MyFixture extends ServiceFixture {} type Extracted = InferConfig; expectTypeOf().toEqualTypeOf(); }); it("InferConfig returns never for non-ServiceFixture types", { timeout: 5000 }, () => { type Extracted = InferConfig; expectTypeOf().toEqualTypeOf(); }); }); ``` **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 — 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> { /** * 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; /** * Stops the container. No-op in env-bridge passthrough mode. * Safe to call multiple times (idempotent). */ stop(): Promise; /** * 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; } /** * Extracts the TConfig type parameter from a ServiceFixture implementation. * * @example * type GarageS3Fixture = ServiceFixture<{ endpoint: string; bucket: string }>; * type Config = InferConfig; // { endpoint: string; bucket: string } */ export type InferConfig = F extends ServiceFixture ? 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` ## Purpose `ServiceFixture` 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` 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 { readonly name = "redis"; async start(): Promise { // container startup here return { url: "redis://localhost:6379", host: "localhost", port: 6379 }; } async stop(): Promise { // container stop here } async healthCheck(): Promise { // 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; // RedisConfig = { url: string; host: string; port: number } // Compose multiple fixture types into a single config map type AllConfigs = { [K in "redis" | "postgres"]: InferConfig>>; }; ``` ## API Reference ### `ServiceFixture` Interface with four members: | Member | Type | Description | |---|---|---| | `name` | `readonly string` | Stable unique name. Used as key in configs map and skip messages. | | `start()` | `() => Promise` | Starts the service and returns connection config. | | `stop()` | `() => Promise` | Stops the service. No-op in passthrough mode. Idempotent. | | `healthCheck()` | `() => Promise` | Returns `true` when the service accepts connections. Never called in passthrough mode. | ### `InferConfig` Utility type that extracts the `TConfig` from a `ServiceFixture`. 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` 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> { 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; } /** * 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 { 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; 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>): Promise { 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` | `setTimeout`-based | Delay implementation. Override in tests. | #### `poll(fixture)` ```typescript async poll(fixture: ServiceFixture>): Promise ``` 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>; let redisConfig: ReturnType>; 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; } = {}, ): ServiceFixture { 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((r) => { resolveA = r; }); const promiseB = new Promise((r) => { resolveB = r; }); const fixtureA: ServiceFixture = { 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 = { 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 => ({ 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 => ({ 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> = { redis: makeTrackedFixture("redis"), broken: makeFakeFixture("broken", { url: "" }, { startError: new Error("boom") }), }; const env = new TestEnvironment(fixtures as Record>>); // 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>; /** * 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>>, 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 { const entries = Object.entries(this.fixtures); const configs: Record> = {}; 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 { 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>>, options?: TestEnvironmentOptions, ) ``` | Parameter | Type | Description | |---|---|---| | `fixtures` | `Record>` | 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>; 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 ``` 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 { 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>, ); 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>, ); 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>>, > { /** * 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: (key: K) => InferConfig; } ``` ### 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>>(); /** * Internal factory accepting an injectable inject function for unit testing. * @internal */ export function _createIntegrationSetupWithInject< TFixtures extends Record>>, >( fixtures: TFixtures, injectFn: (key: string) => Record>, ): IntegrationSetupResult { 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(key: K): InferConfig { const allConfigs = injectFn("__integration_configs__"); return allConfigs[key as string] as InferConfig; } 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>>, >(fixtures: TFixtures): IntegrationSetupResult { return _createIntegrationSetupWithInject( fixtures, vitestInject as (key: string) => Record>, ); } ``` **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()` wrapper — the key type is narrowed to the fixture keys - `InferConfig` 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>>, >(fixtures: TFixtures): IntegrationSetupResult ``` | Parameter | Type | Description | |---|---|---| | `fixtures` | `Record>` | Map of key → fixture instance. Keys are used as inject keys in test files. | Returns `{ globalSetup, inject }`. ### `_fixtureRegistry` ```typescript const _fixtureRegistry: Map>> ``` 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 { 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 { 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` 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` 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` interface, `TestEnvironment`, `createIntegrationSetup()` factory, `HealthPoller`, and `skipIfUnavailable()` helper. All six service fixture plugins (`testing-integration-garage-s3`, `-surrealdb`, `-redis`, `-postgres`, `-mailpit`, `-typesense`) implement `ServiceFixture` 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` 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.