2255 lines
80 KiB
Markdown
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.
|