Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
# @suaveplan/testing-integration - Core integration test infrastructure: ServiceFixture<T> interface, TestEnvironment, createIntegrationSetup() factory, HealthPoller, skipIfUnavailable() helper
## Summary
Core integration test infrastructure for Vitest: fixture lifecycle management, health polling, and the global-setup bridge.
<!-- begin:auto-docs -->
## Documentation tree
- [environment](./src/environment/test-environment.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [test-environment](./src/environment/test-environment.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [service-fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [health-poller](./src/helpers/health-poller.md) — `HealthPoller` repeatedly calls a `ServiceFixture`'s `healthCheck()` method at a configurable interval until the servic…
- [skip-if-unavailable](./src/helpers/skip-if-unavailable.md) — `skipIfUnavailable` returns a `beforeAll`-compatible callback that marks the current test as skipped when the named ser…
- [create-integration-setup-internal](./src/internal/create-integration-setup-internal.md) — This module exposes `createIntegrationSetupWithInject()` — the dependency-injected core of the public `createIntegratio…
- [fixture-registry](./src/internal/fixture-registry.md) — This module exposes the module-level `fixtureRegistry` `Map<string, ServiceFixture<Record<string, unknown>>>` that brid…
- [skip-if-unavailable-internal](./src/internal/skip-if-unavailable-internal.md) — This module exposes `createSkipIfUnavailableWithInject()` — the dependency-injected core of the public `skipIfUnavailab…
- [create-integration-setup](./src/setup/create-integration-setup.md) — `createIntegrationSetup` is the primary entry point for wiring integration test fixtures into the Vitest global-setup l…
- [global-setup](./src/setup/global-setup.md) — `global-setup.ts` is the Vitest global-setup module that bridges the fixture registry populated by `createIntegrationSe…
<!-- end:auto-docs --> - [test-environment](./src/environment/test-environment.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-poller](./src/helpers/health-poller.md) — `HealthPoller` repeatedly calls a `ServiceFixture`'s `healthCheck()` method at a configurable interval until the servic…
- [skip-if-unavailable](./src/helpers/skip-if-unavailable.md) — `skipIfUnavailable` returns a `beforeAll`-compatible callback that marks the current test as skipped when the named ser…
- [create-integration-setup-internal](./src/internal/create-integration-setup-internal.md) — This module exposes `createIntegrationSetupWithInject()` — the dependency-injected core of the public `createIntegratio…
- [fixture-registry](./src/internal/fixture-registry.md) — This module exposes the module-level `fixtureRegistry` `Map<string, ServiceFixture<Record<string, unknown>>>` that brid…
- [skip-if-unavailable-internal](./src/internal/skip-if-unavailable-internal.md) — This module exposes `createSkipIfUnavailableWithInject()` — the dependency-injected core of the public `skipIfUnavailab…
- [create-integration-setup](./src/setup/create-integration-setup.md) — `createIntegrationSetup` is the primary entry point for wiring integration test fixtures into the Vitest global-setup l…
- [global-setup](./src/setup/global-setup.md) — `global-setup.ts` is the Vitest global-setup module that bridges the fixture registry populated by `createIntegrationSe…
- [test-environment](./src/environment/test-environment.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-poller](./src/helpers/health-poller.md) — `HealthPoller` repeatedly calls a `ServiceFixture`'s `healthCheck()` method at a configurable interval until the servic…
- [skip-if-unavailable](./src/helpers/skip-if-unavailable.md) — `skipIfUnavailable` returns a `beforeAll`-compatible callback that marks the current test as skipped when the named ser…
- [create-integration-setup-internal](./src/internal/create-integration-setup-internal.md) — This module exposes `createIntegrationSetupWithInject()` — the dependency-injected core of the public `createIntegratio…
- [fixture-registry](./src/internal/fixture-registry.md) — This module exposes the module-level `fixtureRegistry` `Map<string, ServiceFixture<Record<string, unknown>>>` that brid…
- [skip-if-unavailable-internal](./src/internal/skip-if-unavailable-internal.md) — This module exposes `createSkipIfUnavailableWithInject()` — the dependency-injected core of the public `skipIfUnavailab…
- [create-integration-setup](./src/setup/create-integration-setup.md) — `createIntegrationSetup` is the primary entry point for wiring integration test fixtures into the Vitest global-setup l…
- [global-setup](./src/setup/global-setup.md) — `global-setup.ts` is the Vitest global-setup module that bridges the fixture registry populated by `createIntegrationSe…
- [test-environment](./src/environment/test-environment.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [service-fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-poller](./src/helpers/health-poller.md) — `HealthPoller` repeatedly calls a `ServiceFixture`'s `healthCheck()` method at a configurable interval until the servic…
- [skip-if-unavailable](./src/helpers/skip-if-unavailable.md) — `skipIfUnavailable` returns a `beforeAll`-compatible callback that marks the current test as skipped when the named ser…
- [create-integration-setup-internal](./src/internal/create-integration-setup-internal.md) — This module exposes `createIntegrationSetupWithInject()` — the dependency-injected core of the public `createIntegratio…
- [fixture-registry](./src/internal/fixture-registry.md) — This module exposes the module-level `fixtureRegistry` `Map<string, ServiceFixture<Record<string, unknown>>>` that brid…
- [skip-if-unavailable-internal](./src/internal/skip-if-unavailable-internal.md) — This module exposes `createSkipIfUnavailableWithInject()` — the dependency-injected core of the public `skipIfUnavailab…
- [create-integration-setup](./src/setup/create-integration-setup.md) — `createIntegrationSetup` is the primary entry point for wiring integration test fixtures into the Vitest global-setup l…
- [global-setup](./src/setup/global-setup.md) — `global-setup.ts` is the Vitest global-setup module that bridges the fixture registry populated by `createIntegrationSe…
- [test-environment](./src/environment/test-environment.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ - [service-fixture](./src/fixture/service-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-poller](./src/helpers/health-poller.md) — `HealthPoller` repeatedly calls a `ServiceFixture`'s `healthCheck()` method at a configurable interval until the servic…
- [skip-if-unavailable](./src/helpers/skip-if-unavailable.md) — `skipIfUnavailable` returns a `beforeAll`-compatible callback that marks the current test as skipped when the named ser…
- [create-integration-setup-internal](./src/internal/create-integration-setup-internal.md) — This module exposes `createIntegrationSetupWithInject()` — the dependency-injected core of the public `createIntegratio…
- [fixture-registry](./src/internal/fixture-registry.md) — This module exposes the module-level `fixtureRegistry` `Map<string, ServiceFixture<Record<string, unknown>>>` that brid…
- [skip-if-unavailable-internal](./src/internal/skip-if-unavailable-internal.md) — This module exposes `createSkipIfUnavailableWithInject()` — the dependency-injected core of the public `skipIfUnavailab…
- [create-integration-setup](./src/setup/create-integration-setup.md) — `createIntegrationSetup` is the primary entry point for wiring integration test fixtures into the Vitest global-setup l…
- [global-setup](./src/setup/global-setup.md) — `global-setup.ts` is the Vitest global-setup module that bridges the fixture registry populated by `createIntegrationSe…
## Quick Start
```ts
// fixtures/redis.ts
import type { ServiceFixture } from "@suaveplan/testing-integration/fixture/service-fixture";
export const redisFixture: ServiceFixture<{ url: string }> = {
name: "redis",
async start() {
// start your real Redis container or in-process server
return { url: "redis://localhost:6379" };
},
async stop() { /* stop it */ },
async healthCheck() { return true; },
};
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
import { redisFixture } from "./fixtures/redis.js";
const { globalSetup, inject } = createIntegrationSetup({ redis: redisFixture });
export default defineConfig({ test: { globalSetup: [globalSetup] } });
export { inject };
// redis.test.ts
import { beforeEach } from "vitest";
import { inject } from "../vitest.config.js";
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
beforeEach(skipIfUnavailable("redis"));
it("reads from redis", async () => {
const { url } = inject("redis"); // typed as { url: string }
// ...
});
```
## Module Reference
| Module | Purpose | Docs |
|---|---|---|
| `./fixture/service-fixture` | `ServiceFixture<TConfig>` interface + `InferConfig<F>` | [service-fixture.md](src/fixture/service-fixture.md) |
| `./helpers/health-poller` | `HealthPoller` + `TestEnvironmentError` | [health-poller.md](src/helpers/health-poller.md) |
| `./helpers/skip-if-unavailable` | `skipIfUnavailable()` + `_createSkipIfUnavailableWithInject()` | [skip-if-unavailable.md](src/helpers/skip-if-unavailable.md) |
| `./environment/test-environment` | `TestEnvironment` parallel fixture orchestrator | [test-environment.md](src/environment/test-environment.md) |
| `./setup/create-integration-setup` | `createIntegrationSetup()` factory + `_fixtureRegistry` | [create-integration-setup.md](src/setup/create-integration-setup.md) |
| `./setup/global-setup` | Vitest `setup`/`teardown` hooks (global-setup module) | [global-setup.md](src/setup/global-setup.md) |
## Environment Bridge
The package uses two `provide`/`inject` keys as an internal implementation detail:
| Key | Type | Description |
|---|---|---|
| `__integration_configs__` | `Record<string, Record<string, unknown>>` | Config objects keyed by fixture key, populated in global-setup. |
| `__integration_failed__` | `string[]` | Names of fixtures whose `start()` threw, used by `skipIfUnavailable`. |
Do not read these keys directly — use `inject(key)` from `createIntegrationSetup` for configs and `skipIfUnavailable(name)` for skip guards.
## Adding a New `testing-integration-<service>` Plugin
When creating a sibling fixture package (e.g. `@suaveplan/testing-integration-clickhouse`):
1. Mirror the `vite.config.ts` of an existing fixture (e.g. `testing-integration-redis`). The `build.rollupOptions.output` block MUST contain all three of `preserveModules: true`, `preserveModulesRoot: "src"`, **and `entryFileNames: "[name].js"`**. Without the third line, Vite's lib mode derives the entry filename from the package name and the consumer-facing `dist/index.js` is never written — the package will appear to build but fail to resolve from any sibling workspace.
2. Run `bun run check:fixture-resolves` from the repo root to verify the new package resolves. The repo-level CI gate `//#check:fixture-resolves` (in `turbo.json`) will fail otherwise.

View File

@@ -0,0 +1,104 @@
{
"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"
}
}
},
"//exports-internal-note": "The 'internal' subpath is intentionally NOT in 'exports' — it is a build-system-private surface for intra-package wiring (global-setup ↔ create-integration-setup) and unit tests. Consumers MUST NOT import from it.",
"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:^"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"testcontainers": "catalog:containers",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"peerDependencies": {
"testcontainers": ">=11.0.0",
"vitest": ">=3.0.0"
},
"peerDependenciesMeta": {
"testcontainers": {
"optional": true
},
"vitest": {
"optional": false
}
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,5 @@
export { TestEnvironment, TestEnvironmentError } from "./test-environment.js";
export type {
TestEnvironmentOptions,
TestEnvironmentSetupResult,
} from "./test-environment.types.js";

View File

@@ -0,0 +1,112 @@
# TestEnvironment
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TestEnvironment` is the orchestrator for integration test suites that need several external services running concurrently. It accepts a named map of `ServiceFixture` instances, starts them all in parallel during `setup()`, records which ones failed to start, and stops the successful ones in reverse order during `teardown()`. This reverse-teardown order prevents cascading failures in services that depend on each other (e.g. stopping a connection pool before closing its database).
## Features
- **Parallel startup** — all fixtures are started with `Promise.allSettled`, so a slow service does not block faster ones.
- **Graceful failure handling** — if a fixture's `start()` throws, its name is added to the `failed` list rather than propagating the error; tests can then use `skipIfUnavailable` to opt out.
- **Reverse-order teardown** — fixtures are stopped in the reverse of their start-success order, respecting implicit dependency chains.
- **Swallowed stop errors** — `stop()` errors are caught and discarded so a single broken fixture does not prevent the rest from cleaning up.
- **Safe empty teardown** — calling `teardown()` before `setup()` (or when all fixtures failed) is a no-op.
- **Protected internals** — `fixtures`, `options`, and `started` are `protected` to allow subclassing.
## Usage: Basic Example
```ts
import { TestEnvironment } from "@suaveplan/testing-integration/environment/test-environment";
const env = new TestEnvironment({
redis: redisFixture,
postgres: postgresFixture,
});
const { configs, failed } = await env.setup();
if (failed.length > 0) {
console.warn("Some fixtures failed to start:", failed);
}
console.log(configs.redis); // { url: "redis://localhost:6380" }
console.log(configs.postgres); // { host: "localhost", port: 5433 }
await env.teardown();
```
## Usage: Advanced Example
Using `TestEnvironment` with `skipIfUnavailable` via the global-setup bridge:
```ts
// vitest.config.ts
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
import { redisFixture } from "./fixtures/redis.js";
import { postgresFixture } from "./fixtures/postgres.js";
const { globalSetup, inject } = createIntegrationSetup({
redis: redisFixture,
postgres: postgresFixture,
});
export default defineConfig({
test: {
globalSetup: [globalSetup],
},
});
// my.test.ts
import { beforeEach } from "vitest";
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
beforeEach(skipIfUnavailable("redis"));
it("stores a value in redis", async () => {
// guaranteed: redis was available when setup ran
});
```
## API Reference
### `TestEnvironment`
**Constructor:** `new TestEnvironment(fixtures, options?)`
| Parameter | Type | Description |
|---|---|---|
| `fixtures` | `Record<string, ServiceFixture<…>>` | Named map of fixtures to manage. |
| `options` | `TestEnvironmentOptions` | Optional configuration (currently `timeoutMs` reserved). |
**Methods:**
| Method | Signature | Description |
|---|---|---|
| `setup` | `() => Promise<TestEnvironmentSetupResult>` | Starts all fixtures in parallel. Returns configs and failed list. |
| `teardown` | `() => Promise<void>` | Stops all started fixtures in reverse order. |
### `TestEnvironmentSetupResult`
| Property | Type | Description |
|---|---|---|
| `configs` | `Record<string, Record<string, unknown>>` | Config objects keyed by fixture key for successful fixtures. |
| `failed` | `string[]` | Names of fixtures whose `start()` threw. |
### `TestEnvironmentOptions`
| Property | Type | Description |
|---|---|---|
| `timeoutMs` | `number?` | Reserved for future per-fixture start timeout. |
### `TestEnvironmentError`
Re-exported from `health-poller`. Use `instanceof TestEnvironmentError` to discriminate environment errors.
## Implementation Notes
`Promise.allSettled` is used instead of `Promise.all` so that one failing fixture does not prevent others from starting. The index of each settled result maps 1:1 with the `entries` array, so the correct fixture name can be attributed to each failure.
The `started` array tracks fixture **names** (not keys) in insertion order. During teardown, names are reversed and matched against `Object.values(this.fixtures)` by name. This means duplicate names across different keys would cause a teardown ambiguity — callers should ensure fixture names are unique within a `TestEnvironment` instance.

View File

@@ -0,0 +1,110 @@
/**
* Spec-conformance — type-level pins for the generic threading on
* {@link TestEnvironment}, {@link HealthPoller}, and the public
* {@link createIntegrationSetup}/{@link skipIfUnavailable} façade.
*
* These tests exist purely to fail at compile time if the generics
* regress. They emit no runtime assertions other than a single
* `expectTypeOf(...).toEqualTypeOf<…>()` per scenario.
*/
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { ServiceFixture } from "../fixture/service-fixture.js";
import { HealthPoller } from "../helpers/health-poller.js";
import { skipIfUnavailable } from "../helpers/skip-if-unavailable.js";
import { createIntegrationSetup } from "../setup/create-integration-setup.js";
import { TestEnvironment } from "./test-environment.js";
interface RedisConfig extends Record<string, unknown> {
url: string;
host: string;
port: number;
}
interface PostgresConfig extends Record<string, unknown> {
url: string;
database: string;
}
const fakeRedis: ServiceFixture<RedisConfig> = {
name: "redis",
start: async () => ({ url: "r", host: "h", port: 1 }),
stop: async () => {},
healthCheck: async () => true,
};
const fakePg: ServiceFixture<PostgresConfig> = {
name: "postgres",
start: async () => ({ url: "p", database: "d" }),
stop: async () => {},
healthCheck: async () => true,
};
const fixtures = { redis: fakeRedis, postgres: fakePg };
describe("TestEnvironment generic conformance", () => {
it("setup() configs[K] is the precise fixture TConfig", {
timeout: 5000,
}, async () => {
const env = new TestEnvironment(fixtures);
const result = await env.setup();
expectTypeOf(result.configs.redis).toEqualTypeOf<
RedisConfig | undefined
>();
expectTypeOf(result.configs.postgres).toEqualTypeOf<
PostgresConfig | undefined
>();
expectTypeOf(result.failed).toEqualTypeOf<string[]>();
await env.teardown();
});
it("default generic parameter keeps existing call sites compiling", {
timeout: 5000,
}, () => {
// Equivalent to pre-generic call sites; should still typecheck.
const env: TestEnvironment = new TestEnvironment(
{} as Record<string, ServiceFixture<Record<string, unknown>>>
);
expectTypeOf(env).toMatchTypeOf<TestEnvironment>();
});
});
describe("HealthPoller.poll generic conformance", () => {
it("accepts any ServiceFixture<TConfig> without widening cast", {
timeout: 5000,
}, async () => {
const poller = new HealthPoller({
timeoutMs: 1,
intervalMs: 1,
sleep: async () => {},
});
// Compile-time check: precise TConfig is preserved.
const fn = poller.poll<RedisConfig>;
expectTypeOf(fn).parameters.toMatchTypeOf<
[Pick<ServiceFixture<RedisConfig>, "name" | "healthCheck">]
>();
await poller.poll(fakeRedis);
});
});
describe("createIntegrationSetup typed inject() conformance", () => {
it("inject(key) returns the precise per-fixture config type", {
timeout: 5000,
}, () => {
const { inject } = createIntegrationSetup(fixtures);
expectTypeOf(inject).parameter(0).toEqualTypeOf<"redis" | "postgres">();
expectTypeOf<
ReturnType<typeof inject<"redis">>
>().toEqualTypeOf<RedisConfig>();
expectTypeOf<
ReturnType<typeof inject<"postgres">>
>().toEqualTypeOf<PostgresConfig>();
});
});
describe("skipIfUnavailable typed-name conformance", () => {
it("constrains fixtureName via TName generic", { timeout: 5000 }, () => {
type Names = "redis" | "postgres";
const cb = skipIfUnavailable<Names>("redis");
expectTypeOf(cb).toMatchTypeOf<(ctx: { skip: () => void }) => void>();
});
});

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ServiceFixture } from "../fixture/service-fixture.js";
import { TestEnvironment, TestEnvironmentError } from "./test-environment.js";
type FakeConfig = { url: string };
function makeFakeFixture(
name: string,
config: FakeConfig,
opts: { startError?: Error } = {}
): ServiceFixture<FakeConfig> {
return {
name,
start: async () => {
if (opts.startError) throw opts.startError;
return config;
},
stop: async () => {},
healthCheck: async () => true,
};
}
describe("TestEnvironment", () => {
it("setup() starts all fixtures and returns configs + empty failed list", {
timeout: 30000,
}, async () => {
const env = new TestEnvironment({
redis: makeFakeFixture("redis", { url: "redis://localhost:6379" }),
postgres: makeFakeFixture("postgres", {
url: "pg://localhost:5432",
}),
});
const result = await env.setup();
expect(result.configs).toEqual({
redis: { url: "redis://localhost:6379" },
postgres: { url: "pg://localhost:5432" },
});
expect(result.failed).toEqual([]);
});
it("setup() records failed fixture names", { timeout: 30000 }, async () => {
const env = new TestEnvironment({
redis: makeFakeFixture("redis", { url: "redis://localhost" }),
broken: makeFakeFixture(
"broken",
{ url: "" },
{ startError: new Error("boom") }
),
});
const result = await env.setup();
expect(result.failed).toContain("broken");
expect(result.configs).not.toHaveProperty("broken");
expect(result.configs).toHaveProperty("redis");
});
it("teardown() stops fixtures in reverse start order", {
timeout: 30000,
}, async () => {
const stopped: string[] = [];
const makeTracked = (name: string): ServiceFixture<FakeConfig> => ({
name,
start: async () => ({ url: name }),
stop: async () => {
stopped.push(name);
},
healthCheck: async () => true,
});
const env = new TestEnvironment({
a: makeTracked("a"),
b: makeTracked("b"),
c: makeTracked("c"),
});
await env.setup();
await env.teardown();
expect(stopped).toEqual(["c", "b", "a"]);
});
it("teardown() is safe when setup() was never called", {
timeout: 30000,
}, async () => {
await expect(
new TestEnvironment({}).teardown()
).resolves.toBeUndefined();
});
it("setup() runs fixtures in parallel", { timeout: 30000 }, async () => {
const order: string[] = [];
let resolveA!: () => void;
let resolveB!: () => void;
const pA = new Promise<void>((r) => {
resolveA = r;
});
const pB = new Promise<void>((r) => {
resolveB = r;
});
const fixtureA: ServiceFixture<FakeConfig> = {
name: "a",
start: async () => {
order.push("a-start");
await pA;
return { url: "a" };
},
stop: async () => {},
healthCheck: async () => true,
};
const fixtureB: ServiceFixture<FakeConfig> = {
name: "b",
start: async () => {
order.push("b-start");
await pB;
return { url: "b" };
},
stop: async () => {},
healthCheck: async () => true,
};
const setupPromise = new TestEnvironment({
a: fixtureA,
b: fixtureB,
}).setup();
await Promise.resolve();
expect(order).toContain("a-start");
expect(order).toContain("b-start");
resolveA();
resolveB();
await setupPromise;
});
it("TestEnvironmentError has correct name", { timeout: 5000 }, () => {
const err = new TestEnvironmentError("msg");
expect(err.name).toBe("TestEnvironmentError");
expect(err).toBeInstanceOf(Error);
});
it("setup() clears started list before re-running so teardown does not double-stop", {
timeout: 30000,
}, async () => {
const stopCalls: string[] = [];
const makeTracked = (name: string): ServiceFixture<FakeConfig> => ({
name,
start: async () => ({ url: name }),
stop: async () => {
stopCalls.push(name);
},
healthCheck: async () => true,
});
const env = new TestEnvironment({ a: makeTracked("a") });
await env.setup();
// Second call: started is cleared first, so teardown only sees one entry
await env.setup();
await env.teardown();
expect(stopCalls).toEqual(["a"]);
});
it("teardown() swallows errors from stop()", {
timeout: 30000,
}, async () => {
const throwingFixture: ServiceFixture<FakeConfig> = {
name: "throws",
start: async () => ({ url: "x" }),
stop: async () => {
throw new Error("stop failed");
},
healthCheck: async () => true,
};
const env = new TestEnvironment({ throws: throwingFixture });
await env.setup();
await expect(env.teardown()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,105 @@
/**
* TestEnvironment — parallel fixture startup with reverse-order teardown.
*
* @module
*/
import type { ServiceFixture } from "../fixture/service-fixture.js";
import type {
DefaultFixtureMap,
TestEnvironmentOptions,
TestEnvironmentSetupResult,
} from "./test-environment.types.js";
export { TestEnvironmentError } from "../helpers/health-poller.js";
/**
* Manages a named collection of {@link ServiceFixture} instances:
* starts them in parallel, tracks which ones succeeded, and stops them
* in reverse start order during teardown.
*
* Generic over `TFixtures` so `setup()` returns a `configs` map with
* precise per-fixture config types (inferred via `InferConfig`). The
* generic parameter defaults to the erased `DefaultFixtureMap`, so
* pre-existing call sites without an explicit generic argument keep
* compiling unchanged.
*
* @typeParam TFixtures - Map of fixture name → `ServiceFixture` instance.
*
* @example
* ```ts
* const fixtures = { redis: redisFixture, pg: postgresFixture };
* const env = new TestEnvironment(fixtures);
* const { configs, failed } = await env.setup();
* // configs.redis is RedisConfig | undefined
* // configs.pg is PostgresConfig | undefined
* await env.teardown();
* ```
*/
export class TestEnvironment<
TFixtures extends DefaultFixtureMap = DefaultFixtureMap,
> {
protected readonly started: string[] = [];
constructor(
protected readonly fixtures: TFixtures,
protected readonly options: TestEnvironmentOptions = {}
) {}
/**
* Start all fixtures in parallel.
*
* @returns An object with `configs` (key → config for each successful fixture)
* and `failed` (names of fixtures whose `start()` threw).
*/
async setup(): Promise<TestEnvironmentSetupResult<TFixtures>> {
this.started.length = 0;
const entries = Object.entries(this.fixtures) as Array<
[keyof TFixtures & string, ServiceFixture<Record<string, unknown>>]
>;
const configs: TestEnvironmentSetupResult<TFixtures>["configs"] = {};
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 (let i = 0; i < results.length; i++) {
const result = results[i]!;
const entry = entries[i]!;
if (result.status === "fulfilled") {
(configs as Record<string, Record<string, unknown>>)[
result.value.key as string
] = result.value.config;
this.started.push(result.value.name);
} else {
failed.push(entry[1].name);
}
}
return { configs, failed };
}
/**
* Stop all successfully started fixtures in reverse start order.
* Errors thrown by individual `stop()` calls are swallowed to ensure
* teardown continues for remaining fixtures.
*/
async teardown(): Promise<void> {
const toStop = [...this.started].reverse();
for (const name of toStop) {
const fixture = Object.values(this.fixtures).find(
(f) =>
(f as ServiceFixture<Record<string, unknown>>).name === name
) as ServiceFixture<Record<string, unknown>> | undefined;
try {
await fixture?.stop();
} catch {
// swallow — teardown must not abort on partial failures
}
}
}
}

View File

@@ -0,0 +1,44 @@
/**
* Types for {@link TestEnvironment}.
*/
import type {
InferConfig,
ServiceFixture,
} from "../fixture/service-fixture.js";
/**
* Default fixture-map shape — equivalent to the pre-generic erased contract.
*
* Used as the default for `TestEnvironment`'s `TFixtures` type parameter so
* existing call sites (`new TestEnvironment(fixtures)` without an explicit
* generic argument) continue to compile.
*/
export type DefaultFixtureMap = Record<
string,
ServiceFixture<Record<string, unknown>>
>;
/**
* Options accepted by the {@link TestEnvironment} constructor.
*/
export interface TestEnvironmentOptions {
/** Maximum milliseconds to wait for each fixture to start. Reserved for future use. */
timeoutMs?: number;
}
/**
* Result returned by {@link TestEnvironment.setup}.
*
* Generic over `TFixtures` so `configs[K]` is precisely typed to the
* `TConfig` of `TFixtures[K]` rather than the erased `Record<string, unknown>`.
*
* @typeParam TFixtures - The map of fixture instances passed to the constructor.
*/
export interface TestEnvironmentSetupResult<
TFixtures extends DefaultFixtureMap = DefaultFixtureMap,
> {
/** Map of fixture key → config object for every fixture that started successfully. */
configs: { [K in keyof TFixtures]?: InferConfig<TFixtures[K]> };
/** Names of fixtures whose `start()` threw, in the order they were attempted. */
failed: string[];
}

View File

@@ -0,0 +1 @@
export type { InferConfig, ServiceFixture } from "./service-fixture.js";

View File

@@ -0,0 +1,101 @@
# ServiceFixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ServiceFixture<TConfig>` is the foundational contract for integration test service fixtures in Suaveplan. It defines a three-phase lifecycle — `start`, `healthCheck`, and `stop` — that allows `TestEnvironment` to manage any number of external services (containers, mock servers, in-process stores, etc.) uniformly. By coding against this interface rather than concrete implementations, consumers can swap fixture strategies without changing test setup code.
## Features
- **Generic configuration output** — `start()` returns a caller-defined `TConfig` (e.g. `{ url: string; port: number }`) so consuming tests receive strongly-typed connection details.
- **Three-phase lifecycle** — explicit `start` / `stop` / `healthCheck` methods map directly to `TestEnvironment`'s parallel startup and reverse-order teardown algorithms.
- **`InferConfig<F>` utility type** — extracts the `TConfig` type parameter from any `ServiceFixture` implementation at compile time, enabling generic helper functions.
- **Zero runtime code** — the module is types-only; no JavaScript is emitted for this file, so it contributes nothing to bundle size.
- **Idempotent stop contract** — implementations are required to tolerate repeated `stop()` calls without throwing.
## Usage: Basic Example
```ts
import type { ServiceFixture } from "@suaveplan/testing-integration/fixture/service-fixture";
interface RedisConfig {
url: string;
port: number;
}
class MemoryRedisFixture implements ServiceFixture<RedisConfig> {
readonly name = "memory-redis";
async start(): Promise<RedisConfig> {
// spin up an in-process redis-compatible store
return { url: "redis://localhost", port: 6380 };
}
async stop(): Promise<void> {
// flush and close the store
}
async healthCheck(): Promise<boolean> {
return true; // in-process is always ready
}
}
```
## Usage: Advanced Example
Using `InferConfig` to write generic helpers that work with any fixture:
```ts
import type {
InferConfig,
ServiceFixture,
} from "@suaveplan/testing-integration/fixture/service-fixture";
interface PostgresConfig {
host: string;
port: number;
database: string;
}
interface PostgresFixture extends ServiceFixture<PostgresConfig> {
readonly runMigrations: () => Promise<void>;
}
// InferConfig pulls PostgresConfig out of PostgresFixture at compile time
type Config = InferConfig<PostgresFixture>; // { host: string; port: number; database: string }
function buildConnectionString<F extends ServiceFixture<Record<string, unknown>>>(
config: InferConfig<F> & { host: string; port: number },
): string {
return `${config.host}:${config.port}`;
}
```
## API Reference
### `ServiceFixture<TConfig>`
| Member | Type | Description |
|---|---|---|
| `name` | `readonly string` | Human-readable identifier used in error messages and the fixture registry. |
| `start()` | `() => Promise<TConfig>` | Starts the service and returns connection config. Called by `TestEnvironment.setup()`. |
| `stop()` | `() => Promise<void>` | Stops the service and releases resources. Must be idempotent. |
| `healthCheck()` | `() => Promise<boolean>` | Returns `true` when the service is ready; `false` otherwise. Must not throw. |
### `InferConfig<F>`
Extracts `TConfig` from `ServiceFixture<TConfig>`. Returns `never` for any type that is not a `ServiceFixture` subtype.
**Type parameters:**
- `F` — the concrete fixture type to inspect
## Implementation Notes
`TConfig` is constrained to `Record<string, unknown>` rather than a plain `object` to ensure callers always return a plain serialisable map — not a class instance or primitive. This constraint is enforced at the type level only; no runtime validation is performed here (validation lives in `TestEnvironment`).
The `healthCheck` method is intentionally separate from `start` to support fixtures that need to start a process first and then wait for it to become healthy (e.g. a Docker container). `HealthPoller` uses this method in a polling loop with configurable timeout and interval.
`InferConfig` is implemented as a conditional type (`F extends ServiceFixture<infer C> ? C : never`) rather than a mapped type to keep inference simple and to return `never` for non-fixture types — a common pattern when building generic test helpers.

View File

@@ -0,0 +1,38 @@
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { InferConfig, ServiceFixture } from "./service-fixture.js";
describe("ServiceFixture interface", () => {
it("enforces the ServiceFixture<TConfig> shape", { timeout: 5000 }, () => {
type ExampleConfig = { host: string; port: number };
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>();
});
});

View File

@@ -0,0 +1,54 @@
/**
* ServiceFixture<TConfig> — contract for integration test service fixtures.
*
* @module
*/
/**
* A self-contained integration test fixture that manages the lifecycle of a
* single external service (e.g. a database container, mock server, or in-process
* service). Fixtures are started in parallel by {@link TestEnvironment} and
* stopped in reverse order during teardown.
*
* @typeParam TConfig - The connection/configuration object produced by {@link start}.
*/
export interface ServiceFixture<TConfig extends Record<string, unknown>> {
/** Human-readable identifier used in error messages and registry keys. */
readonly name: string;
/**
* Start the service and return its runtime configuration.
*
* @returns A promise that resolves to the configuration object consumers use
* to connect to or interact with this service.
*/
start(): Promise<TConfig>;
/**
* Stop the service and release any held resources.
*
* Implementations should be idempotent — calling stop() on an already-stopped
* fixture must not throw.
*/
stop(): Promise<void>;
/**
* Probe whether the service is ready to accept connections.
*
* @returns `true` when healthy, `false` when not yet ready. Must not throw.
*/
healthCheck(): Promise<boolean>;
}
/**
* Extracts the `TConfig` type parameter from a {@link ServiceFixture} implementation.
*
* Returns `never` for any type that does not extend `ServiceFixture`.
*
* @example
* ```ts
* interface RedisFixture extends ServiceFixture<{ url: string }> {}
* type Config = InferConfig<RedisFixture>; // { url: string }
* ```
*/
export type InferConfig<F> = F extends ServiceFixture<infer C> ? C : never;

View File

@@ -0,0 +1,104 @@
# HealthPoller
## Purpose
`HealthPoller` repeatedly calls a `ServiceFixture`'s `healthCheck()` method at a configurable interval until the service reports healthy or a timeout elapses. It is used internally by `TestEnvironment` during the setup phase, but can also be used standalone when you need fine-grained control over the polling strategy for a single fixture. The injectable `sleep` function makes it fully deterministic in unit tests — no real timers are required.
## Features
- **Configurable timeout** — `timeoutMs` (default 120 000 ms) controls the maximum wait window.
- **Configurable interval** — `intervalMs` (default 1 000 ms) controls the delay between successive probes.
- **Injectable sleep** — supply a fake `sleep` function in tests to avoid real delays and assert exact call counts.
- **Structured error** — throws `TestEnvironmentError` (not a generic `Error`) so callers can discriminate on the error type.
- **Zero dependencies** — relies only on `ServiceFixture` from the same package; no external runtime dependencies.
- **Protected members** — `timeoutMs`, `intervalMs`, and `sleep` are `protected` so subclasses can override behavior.
## Usage: Basic Example
```ts
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 });
// myFixture.healthCheck() is called every 500 ms until true or 30 s elapses
await poller.poll(myFixture);
```
## Usage: Advanced Example
Inject a fake sleep function in tests so polling logic can be verified without real delays:
```ts
import { describe, it, expect } from "vitest";
import { HealthPoller, TestEnvironmentError } from "@suaveplan/testing-integration/helpers/health-poller";
import type { ServiceFixture } from "@suaveplan/testing-integration/fixture/service-fixture";
function makeFixture(responses: boolean[]): ServiceFixture<Record<string, unknown>> {
let i = 0;
return {
name: "db",
start: async () => ({}),
stop: async () => {},
healthCheck: async () => responses[i++] ?? false,
};
}
it("retries until healthy", async () => {
const sleepCalls: number[] = [];
const poller = new HealthPoller({
timeoutMs: 5_000,
intervalMs: 200,
sleep: async (ms) => { sleepCalls.push(ms); },
});
await poller.poll(makeFixture([false, false, true]));
expect(sleepCalls).toEqual([200, 200]);
});
it("throws TestEnvironmentError on timeout", async () => {
const poller = new HealthPoller({
timeoutMs: 400,
intervalMs: 200,
sleep: async () => {},
});
await expect(poller.poll(makeFixture([]))).rejects.toBeInstanceOf(TestEnvironmentError);
});
```
## API Reference
### `HealthPoller`
**Constructor:** `new HealthPoller(options?: HealthPollerOptions)`
| Option | Type | Default | Description |
|---|---|---|---|
| `timeoutMs` | `number` | `120_000` | Maximum milliseconds to wait before throwing. |
| `intervalMs` | `number` | `1_000` | Milliseconds between `healthCheck()` calls. |
| `sleep` | `(ms: number) => Promise<void>` | `setTimeout`-based | Injectable sleep for deterministic testing. |
**Methods:**
| Method | Signature | Description |
|---|---|---|
| `poll` | `(fixture: ServiceFixture<…>) => Promise<void>` | Polls the fixture until healthy or timeout. Throws `TestEnvironmentError` on timeout. |
### `TestEnvironmentError`
Extends `Error`. The `name` property is always `"TestEnvironmentError"`. Thrown by `HealthPoller.poll()` when the timeout elapses before the fixture becomes healthy.
### `HealthPollerOptions`
| Property | Type | Required | Description |
|---|---|---|---|
| `timeoutMs` | `number` | No | Maximum wait time in milliseconds. |
| `intervalMs` | `number` | No | Polling interval in milliseconds. |
| `sleep` | `(ms: number) => Promise<void>` | No | Sleep implementation (injectable for tests). |
## Implementation Notes
The polling loop tracks `elapsed` time by accumulating `intervalMs` on each iteration rather than reading a real wall clock. This makes the poller's behavior fully deterministic when a fake `sleep` is injected — the timeout fires exactly when `elapsed > timeoutMs`, regardless of real time. In production use (default `setTimeout` sleep), elapsed time approximates real time closely enough for integration test purposes.
The timeout check is performed *after* sleeping rather than before to avoid a race where the first call returns unhealthy but elapsed immediately hits the timeout. With the check after sleep, there is always at least one full `intervalMs` of wait before a timeout is declared. The strict `>` comparison (rather than `>=`) means a fixture whose response latency equals exactly `timeoutMs` still gets one final health check after its sleep window, ensuring the timeout budget is truly exhausted before giving up.

View File

@@ -0,0 +1,157 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ServiceFixture } from "../fixture/service-fixture.js";
import { HealthPoller, TestEnvironmentError } from "./health-poller.js";
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,
});
await expect(
poller.poll(makeFakeFixture([true]))
).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,
});
await expect(
poller.poll(makeFakeFixture([false, false, true]))
).resolves.toBeUndefined();
expect(sleepCalls).toEqual([200, 200]);
});
it("throws TestEnvironmentError when timeout elapses", {
timeout: 5000,
}, async () => {
let _elapsed = 0;
const fakeSleep = async (ms: number) => {
_elapsed += ms;
};
const poller = new HealthPoller({
timeoutMs: 500,
intervalMs: 200,
sleep: fakeSleep,
});
await expect(poller.poll(makeFakeFixture([]))).rejects.toThrow(
"Health check timed out"
);
await expect(poller.poll(makeFakeFixture([]))).rejects.toMatchObject({
name: "TestEnvironmentError",
});
});
it("uses default intervalMs of 1000", { timeout: 5000 }, async () => {
const sleepCalls: number[] = [];
const fakeSleep = async (ms: number) => {
sleepCalls.push(ms);
};
const poller = new HealthPoller({ timeoutMs: 30000, sleep: fakeSleep });
await poller.poll(makeFakeFixture([false, true]));
expect(sleepCalls).toEqual([1000]);
});
it("uses default timeoutMs of 120000", { timeout: 5000 }, async () => {
const poller = new HealthPoller({ sleep: async () => {} });
await expect(
poller.poll(makeFakeFixture([true]))
).resolves.toBeUndefined();
});
it("TestEnvironmentError has name TestEnvironmentError", {
timeout: 5000,
}, () => {
const err = new TestEnvironmentError("test");
expect(err.name).toBe("TestEnvironmentError");
expect(err).toBeInstanceOf(Error);
});
it("uses real setTimeout-based sleep when no sleep injected", {
timeout: 30000,
}, async () => {
// uses the default sleep (real setTimeout) — fixture returns true on first call so no sleep
const poller = new HealthPoller({ timeoutMs: 5000 });
await expect(
poller.poll(makeFakeFixture([true]))
).resolves.toBeUndefined();
});
it("default sleep actually delays when called with real setTimeout", {
timeout: 30000,
}, async () => {
// trigger one real sleep of 50ms to exercise defaultSleep code path
const poller = new HealthPoller({ timeoutMs: 5000, intervalMs: 50 });
await expect(
poller.poll(makeFakeFixture([false, true]))
).resolves.toBeUndefined();
});
it("works with zero-argument constructor (all defaults)", {
timeout: 30000,
}, async () => {
// Exercises the `options = {}` default parameter branch in the constructor
const poller = new HealthPoller();
await expect(
poller.poll(makeFakeFixture([true]))
).resolves.toBeUndefined();
});
it("succeeds when fixture is unhealthy on call 1 but healthy on call 2, with timeoutMs === intervalMs", {
timeout: 5000,
}, async () => {
// Regression: the original code incremented elapsed BEFORE sleeping and
// used >= for the timeout check, so with timeoutMs === intervalMs the
// timeout fired after the very first unhealthy response — before the
// fixture ever got a sleep window. The fix moves the sleep first
// (sleep → elapsed += → timeout check with >) so that elapsed is only
// counted after the fixture has had its full intervalMs, and the >
// comparison means an elapsed value exactly equal to timeoutMs still
// permits one more health check iteration.
const sleepCalls: number[] = [];
const poller = new HealthPoller({
timeoutMs: 200,
intervalMs: 200,
sleep: async (ms) => {
sleepCalls.push(ms);
},
});
await expect(
poller.poll(makeFakeFixture([false, true]))
).resolves.toBeUndefined();
expect(sleepCalls).toEqual([200]);
});
});

View File

@@ -0,0 +1,89 @@
/**
* HealthPoller — polls a {@link ServiceFixture} until healthy or timeout.
*
* @module
*/
import type { ServiceFixture } from "../fixture/service-fixture.js";
/**
* Configuration options for {@link HealthPoller}.
*/
export interface HealthPollerOptions {
/** Maximum wall-clock time in milliseconds to wait before giving up. Defaults to 120 000. */
timeoutMs?: number;
/** Delay in milliseconds between successive health checks. Defaults to 1 000. */
intervalMs?: number;
/**
* Injectable sleep function. Defaults to `setTimeout`-based implementation.
* Provide a custom implementation in tests to avoid real delays.
*/
sleep?: (ms: number) => Promise<void>;
}
/**
* Thrown by {@link HealthPoller} when a fixture fails to become healthy within
* the configured timeout window, and re-exported from {@link TestEnvironment}
* for use in error-handling code.
*/
export class TestEnvironmentError extends Error {
override readonly name = "TestEnvironmentError";
}
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 {@link ServiceFixture}'s `healthCheck()` method at a fixed interval
* until the service reports healthy or the timeout elapses.
*
* @example
* ```ts
* const poller = new HealthPoller({ timeoutMs: 30_000, intervalMs: 500 });
* await poller.poll(redisFixture);
* ```
*/
export class HealthPoller {
protected readonly timeoutMs: number;
protected readonly intervalMs: number;
protected 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;
}
/**
* Poll the given fixture until its `healthCheck()` returns `true` or the
* configured timeout elapses.
*
* Generic over `TConfig` so callers do not lose their precise fixture
* type when handing it to the poller. Only the `name` and `healthCheck`
* surface is touched, so any `ServiceFixture<…>` is accepted without a
* widening cast.
*
* @typeParam TConfig - The `TConfig` of the supplied `ServiceFixture`.
* @param fixture - The fixture to poll.
* @throws {TestEnvironmentError} When the fixture is not healthy within `timeoutMs`.
*/
async poll<TConfig extends Record<string, unknown>>(
fixture: Pick<ServiceFixture<TConfig>, "name" | "healthCheck">
): Promise<void> {
let elapsed = 0;
while (true) {
const healthy = await fixture.healthCheck();
if (healthy) return;
await this.sleep(this.intervalMs);
elapsed += this.intervalMs;
if (elapsed > this.timeoutMs) {
throw new TestEnvironmentError(
`Health check timed out for fixture "${fixture.name}" after ${this.timeoutMs}ms`
);
}
}
}
}

View File

@@ -0,0 +1,3 @@
export type { HealthPollerOptions } from "./health-poller.js";
export { HealthPoller, TestEnvironmentError } from "./health-poller.js";
export { skipIfUnavailable } from "./skip-if-unavailable.js";

View File

@@ -0,0 +1,78 @@
# skipIfUnavailable
## Purpose
`skipIfUnavailable` returns a `beforeAll`-compatible callback that marks the current test as skipped when the named service fixture failed to start. It bridges the global-setup phase — where fixture health is determined — to individual test files, so a network problem with one service never causes unrelated tests to fail with cryptic connection errors. Instead, those tests are reported as skipped with a clear signal.
## Features
- **Vitest-native skip** — calls Vitest's built-in `ctx.skip()` so the test runner reports the test as skipped rather than failed.
- **No throw on absent key** — if the `__integration_failed__` provide value is `null` or `undefined` (e.g. when running outside a global-setup context), the helper is a no-op and all tests run normally.
- **Injectable test double** — `_createSkipIfUnavailableWithInject` exposes the internal factory with a replaceable `injectFn`, making the behaviour fully unit-testable without Vitest's global `provide`/`inject` infrastructure.
- **Zero dependencies** — relies only on `vitest`'s `inject` export, which is already a dev dependency of every test file.
- **Single responsibility** — does exactly one thing: check whether a fixture name appears in the failed list, and skip if so.
## Usage: Basic Example
```ts
import { beforeAll } from "vitest";
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
// Any test in this file is skipped when the "redis" fixture failed to start.
beforeAll(skipIfUnavailable("redis"));
```
## Usage: Advanced Example
Guarding multiple fixtures in one file, and composing with other `beforeAll` hooks:
```ts
import { beforeAll, describe } from "vitest";
import {
skipIfUnavailable,
_createSkipIfUnavailableWithInject,
} from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
// Skip if either dependency is unavailable
beforeAll(skipIfUnavailable("redis"));
beforeAll(skipIfUnavailable("postgres"));
describe("integration suite", () => {
// Unit-testing the helper itself:
it("skips when fixture is in failed list", { timeout: 5000 }, () => {
const cb = _createSkipIfUnavailableWithInject("redis", () => ["redis"]);
const skipFn = vi.fn();
cb({ skip: skipFn });
expect(skipFn).toHaveBeenCalledOnce();
});
});
```
## API Reference
### `skipIfUnavailable(fixtureName: string)`
**Parameters:**
- `fixtureName: string` — the key of the fixture to check against the `__integration_failed__` list.
**Returns:** `(ctx: { skip: () => void }) => void` — a callback suitable for `beforeAll`.
**Behaviour:** reads `__integration_failed__` from Vitest's `inject`. If the array includes `fixtureName`, calls `ctx.skip()`. Otherwise does nothing.
### `_createSkipIfUnavailableWithInject(fixtureName, injectFn)`
Internal factory exposed for unit testing.
**Parameters:**
- `fixtureName: string` — fixture key to check.
- `injectFn: (key: string) => string[] | null | undefined` — replacement for `inject`.
**Returns:** `(ctx: { skip: () => void }) => void`
## Implementation Notes
The `__integration_failed__` key is a convention shared between `global-setup.ts` (which calls `provide("__integration_failed__", failed)`) and `skipIfUnavailable`. The double-underscore prefix signals that this key is an internal implementation detail of the `@suaveplan/testing-integration` package and should not be read directly by consumer code.
The `null ?? []` fallback on the injected value ensures the helper is safe to use even when Vitest's global-setup mechanism is not configured — for example, in unit test suites that import test utilities but do not configure `globalSetup`. In that scenario, `inject("__integration_failed__")` returns `undefined`, the fallback produces an empty array, and no tests are skipped.

View File

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { createSkipIfUnavailableWithInject } from "../internal/skip-if-unavailable-internal.js";
import { skipIfUnavailable } from "./skip-if-unavailable.js";
describe("skipIfUnavailable", () => {
it("calls skip() when fixture name is in failed list", {
timeout: 5000,
}, () => {
const cb = createSkipIfUnavailableWithInject("garage-s3", () => [
"garage-s3",
"surrealdb",
]);
const skipFn = vi.fn();
cb({ skip: skipFn });
expect(skipFn).toHaveBeenCalledOnce();
});
it("does not call skip() when fixture name absent from failed list", {
timeout: 5000,
}, () => {
const cb = createSkipIfUnavailableWithInject("garage-s3", () => [
"surrealdb",
]);
const skipFn = vi.fn();
cb({ skip: skipFn });
expect(skipFn).not.toHaveBeenCalled();
});
it("does not call skip() when inject returns null", {
timeout: 5000,
}, () => {
const cb = createSkipIfUnavailableWithInject(
"garage-s3",
() => null as unknown as string[]
);
const skipFn = vi.fn();
cb({ skip: skipFn });
expect(skipFn).not.toHaveBeenCalled();
});
it("returns a function", { timeout: 5000 }, () => {
const cb = createSkipIfUnavailableWithInject("redis", () => []);
expect(typeof cb).toBe("function");
});
it("public skipIfUnavailable() returns a function", {
timeout: 5000,
}, () => {
expect(typeof skipIfUnavailable("redis")).toBe("function");
});
it("typed generic narrows fixtureName to known keys", {
timeout: 5000,
}, () => {
type Names = "redis" | "postgres";
const cb = skipIfUnavailable<Names>("redis");
expect(typeof cb).toBe("function");
});
});

View File

@@ -0,0 +1,43 @@
/**
* skipIfUnavailable — Vitest `beforeAll` helper to skip tests when a
* required service fixture failed to start.
*
* @module
*/
import { inject } from "@suaveplan/testing/runner";
import { createSkipIfUnavailableWithInject } from "../internal/skip-if-unavailable-internal.js";
/**
* Returns a `beforeAll`-compatible callback that skips the current test when
* the named fixture is listed in the `__integration_failed__` Vitest provide
* key (populated by `global-setup.ts`).
*
* Generic over `TName extends string`. When paired with a typed registry from
* `createIntegrationSetup`, callers can pin `TName` to the registry keys to
* get autocompletion and compile-time errors on typos:
*
* ```ts
* type Names = keyof typeof registry; // "redis" | "postgres"
* beforeAll(skipIfUnavailable<Names>("redis"));
* ```
*
* @example
* ```ts
* import { beforeAll } from "@suaveplan/testing/runner";
* import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
*
* beforeAll(skipIfUnavailable("redis"));
* ```
*
* @typeParam TName - The string-literal type of the fixture name (defaults to `string`).
* @param fixtureName - The key of the fixture to check.
* @returns A callback suitable for `beforeAll`.
*/
export function skipIfUnavailable<TName extends string = string>(
fixtureName: TName
): (ctx: { skip: () => void }) => void {
return createSkipIfUnavailableWithInject<TName>(
fixtureName,
inject as (key: string) => string[] | null | undefined
);
}

View File

@@ -0,0 +1,24 @@
export type {
TestEnvironmentOptions,
TestEnvironmentSetupResult,
} from "./environment/index.js";
export { TestEnvironment } from "./environment/index.js";
export type { InferConfig, ServiceFixture } from "./fixture/index.js";
export type { HealthPollerOptions } from "./helpers/index.js";
export {
HealthPoller,
skipIfUnavailable,
TestEnvironmentError,
} from "./helpers/index.js";
export type { WaitStrategyOptions } from "./internal/apply-wait-strategy.js";
export { applyWaitStrategy } from "./internal/apply-wait-strategy.js";
export type { IntegrationSetupResult } from "./setup/index.js";
export { createIntegrationSetup } from "./setup/index.js";
// Auto-install the Bun-NAPI shim on FIRST import of this package, regardless
// of entry path (test workers, standalone scripts, etc.). The global-setup
// module also installs it for vitest's global-setup process; this side-effect
// covers consumers that bypass global-setup. Idempotent.
import { installBunNapiShim } from "./internal/bun-napi-shim.js";
installBunNapiShim();

View File

@@ -0,0 +1,107 @@
import type { GenericContainer } from "testcontainers";
import { describe, expect, it, vi } from "vitest";
import {
applyWaitStrategy,
type WaitFactoryLike,
} from "./apply-wait-strategy.js";
interface FakeBuilder {
withWaitStrategy: ReturnType<typeof vi.fn>;
}
function makeFakeBuilder(): FakeBuilder {
const builder: FakeBuilder = {
withWaitStrategy: vi.fn(() => builder),
};
return builder;
}
function asGeneric(b: FakeBuilder): GenericContainer {
return b as unknown as GenericContainer;
}
function makeFakeWait(): {
factory: WaitFactoryLike;
spy: ReturnType<typeof vi.fn>;
} {
const spy = vi.fn(
(message: string | RegExp, times?: number) =>
({
kind: "fake-strategy",
message,
times,
}) as unknown
);
return {
factory: { forLogMessage: spy },
spy,
};
}
describe("applyWaitStrategy", () => {
it("calls withWaitStrategy on the builder once", async () => {
const builder = makeFakeBuilder();
const { factory } = makeFakeWait();
await applyWaitStrategy(asGeneric(builder), {
readyLog: "ready",
waitFactory: factory,
});
expect(builder.withWaitStrategy).toHaveBeenCalledTimes(1);
});
it("returns the builder reference back to the caller", async () => {
const builder = makeFakeBuilder();
const { factory } = makeFakeWait();
const result = await applyWaitStrategy(asGeneric(builder), {
readyLog: "ready",
waitFactory: factory,
});
expect(result).toBe(builder);
});
it("passes the readyLog through to forLogMessage", async () => {
const builder = makeFakeBuilder();
const { factory, spy } = makeFakeWait();
await applyWaitStrategy(asGeneric(builder), {
readyLog: "Server started successfully",
waitFactory: factory,
});
expect(spy).toHaveBeenCalledWith("Server started successfully", 1);
});
it("defaults times to 1 when not provided", async () => {
const builder = makeFakeBuilder();
const { factory, spy } = makeFakeWait();
await applyWaitStrategy(asGeneric(builder), {
readyLog: "ready",
waitFactory: factory,
});
expect(spy).toHaveBeenCalledWith("ready", 1);
});
it("respects an explicit times count", async () => {
const builder = makeFakeBuilder();
const { factory, spy } = makeFakeWait();
await applyWaitStrategy(asGeneric(builder), {
readyLog: "ready",
times: 3,
waitFactory: factory,
});
expect(spy).toHaveBeenCalledWith("ready", 3);
});
it("forwards the resulting wait-strategy to withWaitStrategy", async () => {
const builder = makeFakeBuilder();
const { factory } = makeFakeWait();
await applyWaitStrategy(asGeneric(builder), {
readyLog: "ready",
waitFactory: factory,
});
const arg = builder.withWaitStrategy.mock.calls[0]?.[0];
expect(arg).toMatchObject({
kind: "fake-strategy",
message: "ready",
times: 1,
});
});
});

View File

@@ -0,0 +1,67 @@
/**
* applyWaitStrategy — attach a Bun-friendly log-message wait strategy to a
* testcontainers `GenericContainer` builder.
*
* Why: testcontainers' default `Wait.forListeningPorts()` runs port probes
* inside Bun's net stack and never resolves on macOS/Linux (Bun issue
* oven-sh/bun#21342, open since 2025-07-24). The community workaround is
* `Wait.forLogMessage(<known-ready-line>)`, which uses Bun-safe log polling.
*
* `Wait` is dynamically imported so the value-side import only triggers
* after the bun-napi-shim has had a chance to install (the shim is loaded
* by `src/setup/global-setup.ts` before any fixture's `start()` runs).
*
* @module
*/
import type { GenericContainer, WaitStrategy } from "testcontainers";
/**
* Minimal structural type covering the subset of `testcontainers.Wait` we
* use here. Lets tests inject a fake without pulling in testcontainers'
* runtime (which transitively loads `undici` and crashes on missing globals
* in some test environments).
*/
export interface WaitFactoryLike {
forLogMessage: (message: string | RegExp, times?: number) => WaitStrategy;
}
export interface WaitStrategyOptions {
/** The exact log line testcontainers should wait for. */
readyLog: string;
/**
* Number of occurrences of `readyLog` to wait for. Some services emit
* the ready line multiple times during a single startup (e.g. Postgres);
* set this to match a single startup's count so restart-loop containers
* are not falsely accepted. Defaults to `1`.
*/
times?: number;
/**
* Optional override for `testcontainers.Wait`. When provided, used
* instead of dynamically importing testcontainers. Used by unit tests
* to avoid loading the real testcontainers runtime.
*/
waitFactory?: WaitFactoryLike;
}
/**
* Returns the input `builder` with `withWaitStrategy(Wait.forLogMessage(...))`
* applied. The function is async so it can lazy-import `testcontainers.Wait`.
*
* Type-only import of `GenericContainer` keeps this file's static dependency
* graph free of testcontainers' runtime.
*
* @param builder - The `GenericContainer` instance to attach the strategy to.
* @param options - The ready-log line, occurrence count, and optional Wait factory.
* @returns The same builder, with the wait strategy attached.
*/
export async function applyWaitStrategy(
builder: GenericContainer,
options: WaitStrategyOptions
): Promise<GenericContainer> {
const wait =
options.waitFactory ??
/* istanbul ignore next */ (await import("testcontainers")).Wait;
return builder.withWaitStrategy(
wait.forLogMessage(options.readyLog, options.times ?? 1)
);
}

View File

@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { _resetForTests, installBunNapiShim } from "./bun-napi-shim.js";
interface FakeModule {
_resolveFilename: (request: string, ...rest: unknown[]) => string;
}
function makeFakeModule(): FakeModule {
return {
_resolveFilename: vi.fn((request: string) => `/resolved/${request}`),
};
}
describe("installBunNapiShim", () => {
beforeEach(() => {
_resetForTests();
});
it("returns false when called with no args and bun version is unset (default options branch)", () => {
// Exercises the `options = {}` default-parameter branch on the
// production path. Under Bun we temporarily clear `process.versions.bun`
// so the function bails before touching the real Module loader.
const versions = process.versions as Record<string, string | undefined>;
const originalBun = versions.bun;
versions.bun = undefined;
try {
const result = installBunNapiShim();
expect(result).toBe(false);
} finally {
if (originalBun !== undefined) versions.bun = originalBun;
}
});
it("returns false when not running under Bun", () => {
const fakeModule = makeFakeModule();
const result = installBunNapiShim({
moduleRef: fakeModule,
processRef: { versions: {} },
});
expect(result).toBe(false);
});
it("returns false when process is missing versions field", () => {
const fakeModule = makeFakeModule();
const result = installBunNapiShim({
moduleRef: fakeModule,
processRef: {},
});
expect(result).toBe(false);
});
it("installs the patch when running under Bun", () => {
const fakeModule = makeFakeModule();
const before = fakeModule._resolveFilename;
const result = installBunNapiShim({
moduleRef: fakeModule,
processRef: { versions: { bun: "1.3.13" } },
});
expect(result).toBe(true);
expect(fakeModule._resolveFilename).not.toBe(before);
});
it("is idempotent — second call returns true without re-patching", () => {
const fakeModule = makeFakeModule();
installBunNapiShim({
moduleRef: fakeModule,
processRef: { versions: { bun: "1.3.13" } },
});
const afterFirst = fakeModule._resolveFilename;
const result = installBunNapiShim({
moduleRef: fakeModule,
processRef: { versions: { bun: "1.3.13" } },
});
expect(result).toBe(true);
expect(fakeModule._resolveFilename).toBe(afterFirst);
});
it("throws JS error for blocked /sshcrypto.node load", () => {
const fakeModule = makeFakeModule();
installBunNapiShim({
moduleRef: fakeModule,
processRef: { versions: { bun: "1.3.13" } },
});
expect(() =>
fakeModule._resolveFilename("/path/to/sshcrypto.node")
).toThrow(/blocked native module load/);
});
it("throws JS error for blocked /cpufeatures.node load", () => {
const fakeModule = makeFakeModule();
installBunNapiShim({
moduleRef: fakeModule,
processRef: { versions: { bun: "1.3.13" } },
});
expect(() =>
fakeModule._resolveFilename("/path/to/cpufeatures.node")
).toThrow(/blocked native module load/);
});
it("delegates non-blocked requests to the original resolver", () => {
const fakeModule = makeFakeModule();
const original = fakeModule._resolveFilename as ReturnType<
typeof vi.fn
>;
installBunNapiShim({
moduleRef: fakeModule,
processRef: { versions: { bun: "1.3.13" } },
});
const result = fakeModule._resolveFilename("node:fs");
expect(result).toBe("/resolved/node:fs");
expect(original).toHaveBeenCalledWith("node:fs");
});
it("preserves additional positional arguments to the resolver", () => {
const fakeModule = makeFakeModule();
const original = fakeModule._resolveFilename as ReturnType<
typeof vi.fn
>;
installBunNapiShim({
moduleRef: fakeModule,
processRef: { versions: { bun: "1.3.13" } },
});
fakeModule._resolveFilename("node:path", "extra1", "extra2");
expect(original).toHaveBeenCalledWith("node:path", "extra1", "extra2");
});
});

View File

@@ -0,0 +1,86 @@
/**
* bun-napi-shim — intercept native-module loads that would otherwise crash
* Bun's NAPI loader before testcontainers' fallback paths run.
*
* Background: `dockerode` (which testcontainers uses for the Docker socket)
* pulls `ssh2` and `cpu-features`. Both packages load `.node` binaries via
* a wrapped `require()` with a `try { require(...) } catch {}` fallback to
* pure-JS implementations. Under Bun, however, the underlying NAPI loader
* panics on `uv_version_string` (oven-sh/bun#18546) BEFORE the catch block
* runs, killing the runner.
*
* This shim intercepts `Module._resolveFilename` for the two known offending
* `.node` paths and throws a plain JS error instead. ssh2/cpu-features then
* catch it and fall back to pure-JS code paths. Safe no-op under Node.
*
* Loaded as a side-effect by `src/setup/global-setup.ts` so every consumer
* who wires `createIntegrationSetup`'s returned `globalSetup` path gets the
* shim automatically; no per-consumer wiring needed.
*
* @module
*/
import { createRequire } from "node:module";
interface NodeModule {
_resolveFilename: (request: string, ...rest: unknown[]) => string;
}
const BLOCKED_NATIVE_SUFFIXES = [
"/sshcrypto.node",
"/cpufeatures.node",
] as const;
let shimInstalled = false;
/**
* Install the Module._resolveFilename interceptor when running under Bun.
*
* Idempotent: subsequent calls are no-ops. Returns `true` if the shim was
* installed (either now or previously), `false` if not running under Bun.
*
* @param options.moduleRef - Optional override for `node:module`. Used by
* tests to inject a fake `Module` whose `_resolveFilename` can be inspected.
* @param options.processRef - Optional override for the global `process`.
* Used by tests to simulate Bun vs Node detection without actually running
* under Bun.
*/
export function installBunNapiShim(
options: {
moduleRef?: NodeModule;
processRef?: { versions?: { bun?: string } };
} = {}
): boolean {
const proc =
options.processRef ??
/* istanbul ignore next */ (typeof process !== "undefined"
? (process as unknown as { versions?: { bun?: string } })
: undefined);
if (proc?.versions?.bun === undefined) return false;
if (shimInstalled) return true;
const mod =
options.moduleRef ??
/* istanbul ignore next */ (createRequire(import.meta.url)(
"node:module"
) as NodeModule);
const original = mod._resolveFilename;
mod._resolveFilename = function patched(
this: unknown,
request: string,
...rest: unknown[]
): string {
if (BLOCKED_NATIVE_SUFFIXES.some((s) => request.endsWith(s))) {
throw new Error(
`[bun-napi-shim] blocked native module load (${request}); pure-JS fallback engaged`
);
}
return original.call(this, request, ...rest);
};
shimInstalled = true;
return true;
}
/** Test-only: reset the install flag so a second `installBunNapiShim` call patches again. */
export function _resetForTests(): void {
shimInstalled = false;
}

View File

@@ -0,0 +1,49 @@
# `internal/create-integration-setup-internal`
## Purpose
This module exposes `createIntegrationSetupWithInject()` — the dependency-injected core of the public `createIntegrationSetup()` factory. Splitting the implementation into a wired core plus a thin public facade keeps the `vitest` runtime import isolated to one file (`../setup/create-integration-setup.ts`) and lets the unit tests cover the registration / inject hand-off without booting Vitest's global-setup machinery.
It is deliberately under `src/internal/` and not listed in `package.json` `exports`. Consumers MUST NOT import it; the public contract is `createIntegrationSetup()` from the package barrel. Tests in this package import this module directly because they own the implementation; downstream packages do not.
## Features
- Generic over the fixture map; preserves precise per-fixture config types end-to-end via `InferConfig`.
- Pluggable `injectFn` so unit tests can verify the indirection through Vitest's `inject` without importing `vitest` from the test file under inspection.
- Side-effect: mutates `fixtureRegistry` so the Vitest global-setup module sees the fixtures at boot.
## Usage: Basic Example
```ts
// PRIVATE — shown for documentation only. Use createIntegrationSetup() in app code.
import { createIntegrationSetupWithInject } from "./internal/create-integration-setup-internal.js";
const { globalSetup, inject } = createIntegrationSetupWithInject(
{ redis: redisFixture },
(key) => ({ redis: { url: "redis://injected" } })
);
```
## Usage: Advanced Example
The public `createIntegrationSetup` (in `../setup/create-integration-setup.ts`) is exactly this with Vitest's real `inject` wired in:
```ts
import { inject as vitestInject } from "vitest";
import { createIntegrationSetupWithInject } from "../internal/create-integration-setup-internal.js";
export function createIntegrationSetup<TFixtures>(fixtures: TFixtures) {
return createIntegrationSetupWithInject(
fixtures,
vitestInject as (key: string) => Record<string, Record<string, unknown>>
);
}
```
## API Reference
- `createIntegrationSetupWithInject<TFixtures>(fixtures, injectFn)` — returns `{ globalSetup: string; inject: <K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]> }`. Writes every entry of `fixtures` into the shared `fixtureRegistry`.
## Implementation Notes
Resolves the global-setup path via `new URL("../setup/global-setup.js", import.meta.url).pathname`. This path is what consumers feed into `test.globalSetup` in their `vitest.config.ts`; using `import.meta.url` makes the reference robust to workspace layout differences.

View File

@@ -0,0 +1,52 @@
/**
* Internal factory — `createIntegrationSetup` with an injectable `injectFn`.
*
* **NOT a public surface.** Reachable only via `./internal/*`. The public
* `createIntegrationSetup` in `../setup/create-integration-setup.ts` wraps
* this factory and passes Vitest's real `inject` function.
*
* @module
*/
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type {
InferConfig,
ServiceFixture,
} from "../fixture/service-fixture.js";
import type { IntegrationSetupResult } from "../setup/create-integration-setup.types.js";
import { fixtureRegistry } from "./fixture-registry.js";
// Resolve the sibling global-setup.js path without `new URL(..., import.meta.url)`,
// which Vite's lib mode treats as an asset reference and inlines as a `data:` URL
// during build. `fileURLToPath` + `path.join` is opaque to Vite's asset scanner,
// so the published artifact returns a real on-disk path that Vitest can load.
const HERE = dirname(fileURLToPath(import.meta.url));
const GLOBAL_SETUP_PATH = join(HERE, "..", "setup", "global-setup.js");
/**
* Build an `IntegrationSetupResult` whose `inject()` reads from the supplied
* `injectFn`. Used by both the public `createIntegrationSetup` (passing
* Vitest's real `inject`) and unit tests (passing a fake).
*
* @param fixtures - Named map of fixtures to register.
* @param injectFn - Replacement for Vitest's `inject`.
*/
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);
}
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: GLOBAL_SETUP_PATH, inject };
}

View File

@@ -0,0 +1,45 @@
# `internal/fixture-registry`
## Purpose
This module exposes the module-level `fixtureRegistry` `Map<string, ServiceFixture<Record<string, unknown>>>` that bridges `createIntegrationSetup()` and the Vitest `global-setup.ts` entry point. It exists because Vitest's global-setup hook cannot accept arguments across the configuration boundary — so the registration step (called from `vitest.config.ts`) and the consumption step (called by Vitest before any test file loads) must communicate via a shared, in-process singleton.
The registry is deliberately placed under `src/internal/` and is **not** listed in `package.json` `exports`. It is private build-system plumbing. Consumers MUST NOT import it; doing so means relying on an unstable surface that the package may rearrange without a SemVer breaking change. The public surface remains `createIntegrationSetup()` + `globalSetup` + `inject()`; everything in `internal/` is implementation detail.
## Features
- Singleton `Map` keyed by user-chosen fixture names.
- Stores the `ServiceFixture<Record<string, unknown>>` instances; the variant fixtures (`@suaveplan/testing-integration-redis`, etc.) all conform to this contract.
- Mutated by `createIntegrationSetup()` (writes) and by `global-setup.ts` (reads + clear).
## Usage: Basic Example
```ts
// PRIVATE — internal/* is not a published subpath. Shown for documentation only.
import { fixtureRegistry } from "./internal/fixture-registry.js";
console.log(fixtureRegistry.size);
```
## Usage: Advanced Example
`global-setup.ts` snapshot the registry into a plain object so the `TestEnvironment` constructor receives a stable view, then clears it so re-runs in watch mode start fresh:
```ts
import { fixtureRegistry } from "./internal/fixture-registry.js";
import { TestEnvironment } from "../environment/test-environment.js";
const fixtures = Object.fromEntries(fixtureRegistry);
const env = new TestEnvironment(fixtures);
const { configs, failed } = await env.setup();
context.provide("__integration_configs__", configs);
context.provide("__integration_failed__", failed);
```
## API Reference
- `fixtureRegistry: Map<string, ServiceFixture<Record<string, unknown>>>` — mutable singleton, lifetime equal to the Node process. Reads and writes are not synchronised because Vitest serialises setup vs test execution.
## Implementation Notes
The registry uses `Record<string, unknown>` rather than the precise per-fixture config because the Vitest bridge erases the type at the cross-module boundary. The strongly-typed projection is restored at the `inject()` call site via the `InferConfig<TFixtures[K]>` mapping in `IntegrationSetupResult`.

View File

@@ -0,0 +1,25 @@
/**
* Internal — module-level fixture registry shared between
* `createIntegrationSetup()` and the Vitest `global-setup.ts` bridge.
*
* **NOT a public surface.** This module is reachable only via the
* non-exported `./internal/*` subpath; consumers MUST NOT import it. It
* exists because the Vitest global-setup mechanism cannot pass arguments
* across the boundary, so the only viable hand-off is a module-level
* singleton.
*
* @module
*/
import type { ServiceFixture } from "../fixture/service-fixture.js";
/**
* Shared registry — keys are user-supplied fixture names, values are the
* concrete `ServiceFixture` instances passed to `createIntegrationSetup`.
*
* Mutating this map outside `createIntegrationSetup` / `global-setup` is
* unsupported and will desynchronise the Vitest bridge.
*/
export const fixtureRegistry = new Map<
string,
ServiceFixture<Record<string, unknown>>
>();

View File

@@ -0,0 +1,3 @@
export { createIntegrationSetupWithInject } from "./create-integration-setup-internal.js";
export { fixtureRegistry } from "./fixture-registry.js";
export { createSkipIfUnavailableWithInject } from "./skip-if-unavailable-internal.js";

View File

@@ -0,0 +1,41 @@
# `internal/skip-if-unavailable-internal`
## Purpose
This module exposes `createSkipIfUnavailableWithInject()` — the dependency-injected core of the public `skipIfUnavailable()` helper. Splitting the implementation lets the public façade (`../helpers/skip-if-unavailable.ts`) hold the only `import { inject } from "vitest"` runtime call site, while unit tests exercise this factory with a fake injector and verify the behaviour without booting Vitest's `provide`/`inject` infrastructure.
It lives under `src/internal/` and is deliberately omitted from `package.json` `exports`. Consumers MUST NOT import it. The public surface is `skipIfUnavailable()` from the package barrel.
## Features
- Generic over `TName extends string` so consumers using a typed `createIntegrationSetup` registry get key auto-completion and compile-time errors when they typo a fixture name.
- Pluggable `injectFn` so unit tests can pass a fake without `vitest`.
- Treats `null` / `undefined` returns from `injectFn` as the empty failed-list (no skip).
## Usage: Basic Example
```ts
// PRIVATE — shown for documentation only. Use skipIfUnavailable() in app code.
import { createSkipIfUnavailableWithInject } from "./internal/skip-if-unavailable-internal.js";
const cb = createSkipIfUnavailableWithInject("redis", (key) => ["redis"]);
cb({ skip: () => console.log("skipped") });
```
## Usage: Advanced Example
The public `skipIfUnavailable()` uses Vitest's `inject` internally. When paired with a typed `createIntegrationSetup` registry, the generic preserves key safety:
```ts
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
beforeAll(skipIfUnavailable<"redis" | "postgres">("redis"));
// skipIfUnavailable<"redis" | "postgres">("typeo") // compile error — typo caught
```
## API Reference
- `createSkipIfUnavailableWithInject<TName extends string>(fixtureName, injectFn)` — returns `(ctx: { skip: () => void }) => void`. The returned callback reads the failed-fixture list via `injectFn("__integration_failed__")` and calls `ctx.skip()` if `fixtureName` is in it.
## Implementation Notes
The cast `null as unknown as string[]` in tests verifies the `?? []` guard. Production callers never pass a function that returns `null`, but Vitest's `inject` typing is loose, so the guard is load-bearing in real-world consumers.

View File

@@ -0,0 +1,33 @@
/**
* Internal — `skipIfUnavailable` factory with an injectable `injectFn`.
*
* **NOT a public surface.** The public `skipIfUnavailable()` lives in
* `../helpers/skip-if-unavailable.ts` and wraps this factory with Vitest's
* real `inject` function.
*
* @module
*/
/**
* Build a `beforeAll`-compatible callback that skips the current suite when
* `fixtureName` appears in the failed-fixture list returned by `injectFn`.
*
* Generic over the fixture-name keyset so callers paired with a typed
* `createIntegrationSetup` registry get key autocompletion + type-checking.
*
* @typeParam TName - The string-literal type of the fixture name (or plain `string`).
* @param fixtureName - The fixture key to check.
* @param injectFn - Replacement for Vitest's `inject`.
* @returns A `beforeAll`-compatible callback.
*/
export function createSkipIfUnavailableWithInject<TName extends string>(
fixtureName: TName,
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();
}
};
}

View File

@@ -0,0 +1,91 @@
# createIntegrationSetup
## Purpose
`createIntegrationSetup` is the primary entry point for wiring integration test fixtures into the Vitest global-setup lifecycle. It accepts a named map of `ServiceFixture` instances, registers them in a module-level registry that `global-setup.ts` reads at startup, and returns two things: the absolute path of the Vitest global-setup module (ready to drop into `test.globalSetup`) and a typed `inject` helper that test files use to retrieve fixture configs. This one-call configuration model means consumers never need to import from `global-setup.ts` directly.
## Features
- **Typed `inject` helper** — the return type of `inject(key)` is inferred from the fixture's `TConfig` type parameter via `InferConfig`, so no type casts are needed in test files.
- **Auto-resolved `globalSetup` path** — the path is derived from `import.meta.url`, so it works regardless of where the consumer's `vitest.config.ts` lives.
- **Additive registry** — multiple calls to `createIntegrationSetup` accumulate fixtures into a shared registry; this allows split configuration across multiple config files.
- **Injectable test double** — `createIntegrationSetupWithInject` (under the package-private `internal/` directory) exposes the factory with a replaceable `injectFn` for unit testing without real Vitest `provide`/`inject` infrastructure.
- **Zero runtime overhead** — the function does no async work; startup cost is deferred entirely to `global-setup.ts`.
## Usage: Basic Example
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
import { redisFixture } from "./fixtures/redis.js";
const { globalSetup, inject } = createIntegrationSetup({ redis: redisFixture });
export default defineConfig({
test: {
globalSetup: [globalSetup],
},
});
export { inject };
// redis.test.ts
import { inject } from "../vitest.config.js";
import { beforeEach } from "vitest";
import { skipIfUnavailable } from "@suaveplan/testing-integration/helpers/skip-if-unavailable";
beforeEach(skipIfUnavailable("redis"));
it("connects to redis", async () => {
const { url } = inject("redis"); // typed as RedisConfig
// ...
});
```
## Usage: Advanced Example
Accumulating fixtures across multiple config calls:
```ts
// vitest.config.ts
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
// Split fixture definitions across modules
const { globalSetup: gsA } = createIntegrationSetup({ redis: redisFixture });
const { globalSetup: gsB, inject } = createIntegrationSetup({ postgres: postgresFixture });
// Both fixture sets are registered in the shared package-private fixtureRegistry
export default defineConfig({
test: {
globalSetup: [gsA], // only one path needed — registry is shared
},
});
```
## API Reference
### `createIntegrationSetup(fixtures)`
**Parameters:**
- `fixtures: Record<string, ServiceFixture<…>>` — named map of fixtures to register.
**Returns:** `IntegrationSetupResult<TFixtures>`
### `IntegrationSetupResult<TFixtures>`
| Property | Type | Description |
|---|---|---|
| `globalSetup` | `string` | Absolute path to `global-setup.js`. Add to `test.globalSetup`. |
| `inject` | `<K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]>` | Typed accessor for fixture configs populated by global-setup. |
### Package-private internals
`createIntegrationSetupWithInject(fixtures, injectFn)` and the `fixtureRegistry` `Map` live under `src/internal/` and are deliberately NOT exported via `package.json` `exports`. They are reachable only from within this package's own source tree (used by tests and by the public façade above) and consumers MUST NOT rely on them.
## Implementation Notes
The `globalSetup` path is computed via `new URL("./global-setup.js", import.meta.url).pathname`. This resolves relative to the compiled output location so the path is always valid after `vite build`. It does not depend on `__dirname` or any Node.js path resolution, making it compatible with ES module environments.
The `inject` closure captures `injectFn` at creation time and calls it lazily — only when `inject(key)` is invoked inside a test. This lazy evaluation is necessary because Vitest's `inject` only returns values after the global-setup `provide` calls have run.

View File

@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { ServiceFixture } from "../fixture/service-fixture.js";
import { createIntegrationSetupWithInject } from "../internal/create-integration-setup-internal.js";
import { fixtureRegistry } from "../internal/fixture-registry.js";
import { createIntegrationSetup } from "./create-integration-setup.js";
type FakeConfig = { url: string };
function makeFake(
name: string,
config: FakeConfig
): ServiceFixture<FakeConfig> {
return {
name,
start: async () => config,
stop: async () => {},
healthCheck: async () => true,
};
}
describe("createIntegrationSetup", () => {
beforeEach(() => {
fixtureRegistry.clear();
});
it("populates fixtureRegistry with provided fixtures", {
timeout: 5000,
}, () => {
const r = makeFake("redis", { url: "r" });
const p = makeFake("postgres", { url: "p" });
createIntegrationSetup({ redis: r, postgres: p });
expect(fixtureRegistry.get("redis")).toBe(r);
expect(fixtureRegistry.get("postgres")).toBe(p);
});
it("returns globalSetup as absolute path ending with global-setup.js", {
timeout: 5000,
}, () => {
const { globalSetup } = createIntegrationSetup({});
expect(typeof globalSetup).toBe("string");
expect(globalSetup).toMatch(/global-setup\.js$/);
expect(globalSetup.startsWith("/")).toBe(true);
});
it("inject() reads from __integration_configs__ via injectable injectFn", {
timeout: 5000,
}, () => {
const fakeInject = (_key: string) => ({
redis: { url: "redis://injected" },
});
const { inject } = createIntegrationSetupWithInject(
{ redis: makeFake("redis", { url: "" }) },
fakeInject as (
key: string
) => Record<string, Record<string, unknown>>
);
expect(inject("redis")).toEqual({ url: "redis://injected" });
});
it("multiple calls accumulate into registry", { timeout: 5000 }, () => {
createIntegrationSetup({ redis: makeFake("redis", { url: "r" }) });
createIntegrationSetup({
postgres: makeFake("postgres", { url: "p" }),
});
expect(fixtureRegistry.size).toBe(2);
});
});

View File

@@ -0,0 +1,35 @@
/**
* createIntegrationSetup — wires fixtures into the Vitest global-setup bridge.
*
* @module
*/
import { inject as vitestInject } from "@suaveplan/testing/runner";
import type { ServiceFixture } from "../fixture/service-fixture.js";
import { createIntegrationSetupWithInject } from "../internal/create-integration-setup-internal.js";
import type { IntegrationSetupResult } from "./create-integration-setup.types.js";
/**
* Registers fixtures and returns a `globalSetup` path + typed `inject` helper.
*
* Call once (or multiple times to accumulate fixtures) in your `vitest.config.ts`,
* then add the returned `globalSetup` path to `test.globalSetup`.
*
* @example
* ```ts
* const { globalSetup, inject } = createIntegrationSetup({
* redis: redisFixture,
* postgres: postgresFixture,
* });
*
* // in tests:
* const { url } = inject("redis"); // typed as RedisConfig
* ```
*/
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>>
);
}

View File

@@ -0,0 +1,31 @@
/**
* Types for {@link createIntegrationSetup}.
*/
import type {
InferConfig,
ServiceFixture,
} from "../fixture/service-fixture.js";
/**
* Return value of {@link createIntegrationSetup}.
*
* @typeParam TFixtures - The map of fixture instances passed to `createIntegrationSetup`.
*/
export interface IntegrationSetupResult<
TFixtures extends Record<string, ServiceFixture<Record<string, unknown>>>,
> {
/**
* Absolute path to the Vitest global-setup module. Pass this to
* `globalSetup` in your `vitest.config.ts`.
*/
globalSetup: string;
/**
* Retrieve the config object for a fixture by key. The return type is
* inferred from the fixture's `TConfig` type parameter.
*
* @param key - The fixture key.
* @returns The config object produced by `fixture.start()`.
*/
inject: <K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]>;
}

View File

@@ -0,0 +1,73 @@
# global-setup
## Purpose
`global-setup.ts` is the Vitest global-setup module that bridges the fixture registry populated by `createIntegrationSetup` with the test environment. It exports `setup()` and `teardown()` lifecycle hooks that Vitest calls before and after the test suite. During `setup()`, it starts all registered fixtures via `TestEnvironment` and makes their configs and failure list available to every test file through Vitest's `provide`/`inject` mechanism. This design means consumers never need to manage container startup/teardown in individual test files.
## Features
- **Automatic fixture orchestration** — reads from `_fixtureRegistry` (populated by `createIntegrationSetup`) and delegates to `TestEnvironment` for parallel startup and reverse-order teardown.
- **Vitest `provide` bridge** — calls `provide("__integration_configs__", configs)` and `provide("__integration_failed__", failed)` so test files can access this data via `inject()` or the `skipIfUnavailable` helper.
- **Graceful partial failure** — uses `TestEnvironment`'s `Promise.allSettled`-based startup so a single failing fixture does not abort the entire suite.
- **Safe teardown** — wraps `env?.teardown()` so teardown is a no-op if `setup()` never ran (e.g. in degenerate configurations).
- **Excluded from library build** — listed in `tsconfig.lib.json`'s `exclude` array and the Vite `external` list so it does not appear in the distributed package.
## Usage: Basic Example
```ts
// vitest.config.ts
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
import { redisFixture } from "./fixtures/redis.js";
const { globalSetup } = createIntegrationSetup({ redis: redisFixture });
export default {
test: {
globalSetup: [globalSetup], // <-- points to global-setup.js in the dist
},
};
```
## Usage: Advanced Example
The module is used indirectly via `createIntegrationSetup` in almost all cases. However, if you need to import it directly (e.g. for a custom global-setup wrapper), you can do so:
```ts
// custom-global-setup.ts
import { setup as integrationSetup, teardown as integrationTeardown } from "@suaveplan/testing-integration/setup/global-setup";
import { createIntegrationSetup } from "@suaveplan/testing-integration/setup/create-integration-setup";
import { redisFixture } from "./fixtures/redis.js";
createIntegrationSetup({ redis: redisFixture });
export async function setup() {
await integrationSetup();
// additional custom setup...
}
export async function teardown() {
await integrationTeardown();
// additional custom teardown...
}
```
## API Reference
### `setup(): Promise<void>`
Called by Vitest at the start of the test run.
Reads all fixtures from `_fixtureRegistry`, constructs a `TestEnvironment`, calls `setup()`, then:
- Calls `provide("__integration_configs__", configs)` — map of fixture key → config object.
- Calls `provide("__integration_failed__", failed)` — array of fixture names that failed to start.
### `teardown(): Promise<void>`
Called by Vitest at the end of the test run. Delegates to `env.teardown()` if `env` was created. Safe to call when `setup()` was never called.
## Implementation Notes
The `env` variable is module-scoped rather than exported because Vitest loads global-setup modules as ES modules and re-uses the module instance between `setup` and `teardown` calls. This ensures `teardown` always references the same `TestEnvironment` that `setup` created.
This file is intentionally excluded from `tsconfig.lib.json` because it imports `vitest/node` (a server-only Vitest API), which should not appear in a library's type definitions. It is still built by Vite as a separate output module because the Rollup `external` list excludes `vitest/node`, allowing it to be bundled independently and resolved at runtime.

View File

@@ -0,0 +1,47 @@
/**
* Vitest global-setup module — starts all registered fixtures and provides
* their configs and the failed-fixture list to tests via `inject()`.
*
* This file is excluded from the library build and is loaded directly by
* Vitest's global-setup mechanism. It must not be imported by library code.
*
* Installs the Bun-NAPI shim FIRST as a module-level side-effect so any
* subsequent testcontainers / dockerode / ssh2 / cpu-features module load
* does not crash Bun's NAPI loader before its catch-fallback runs. No-op
* under Node. See `../internal/bun-napi-shim.ts` for details.
*/
import { TestEnvironment } from "../environment/test-environment.js";
import { installBunNapiShim } from "../internal/bun-napi-shim.js";
import { fixtureRegistry } from "../internal/fixture-registry.js";
installBunNapiShim();
/** Vitest global-setup context passed to `setup()` by the Vitest runner. */
interface GlobalSetupContext {
provide: (key: string, value: unknown) => void;
}
let env: TestEnvironment | undefined;
/**
* Called by Vitest before any tests run.
* Starts all fixtures registered via `createIntegrationSetup` and exposes
* their configs through `inject("__integration_configs__")` and the list of
* failed fixture names through `inject("__integration_failed__")`.
*/
export async function setup(context: GlobalSetupContext): Promise<void> {
if (env) await env.teardown();
const fixtures = Object.fromEntries(fixtureRegistry);
env = new TestEnvironment(fixtures);
const { configs, failed } = await env.setup();
context.provide("__integration_configs__", configs);
context.provide("__integration_failed__", failed);
}
/**
* Called by Vitest after all tests complete.
* Stops all fixtures in reverse start order.
*/
export async function teardown(): Promise<void> {
await env?.teardown();
}

View File

@@ -0,0 +1,2 @@
export { createIntegrationSetup } from "./create-integration-setup.js";
export type { IntegrationSetupResult } from "./create-integration-setup.types.js";

View File

@@ -0,0 +1,77 @@
/**
* dist-asserts — regression test that exercises the BUILT artifact
* (`dist/index.js`), not the source.
*
* Guards against the data:URL globalSetup bug fixed by
* `testing-integration-build-fix`: Vite library mode previously inlined
* `new URL("../setup/global-setup.js", import.meta.url).pathname` as a
* `data:video/mp2t;base64,…` URL, which Vitest could not load. Any future
* refactor that re-introduces an asset-intercepted URL expression in the
* build output will fail this test.
*
* Runs after `build` because turbo declares `test.dependsOn: ["build"]`.
*
* @module
*/
import { existsSync } from "node:fs";
import { isAbsolute } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ServiceFixture } from "../src/fixture/service-fixture.js";
interface StubConfig extends Record<string, unknown> {
answer: number;
}
const stubFixture: ServiceFixture<StubConfig> = {
name: "stub",
async start(): Promise<StubConfig> {
return { answer: 42 };
},
async stop(): Promise<void> {},
async healthCheck(): Promise<boolean> {
return true;
},
};
interface DistModule {
createIntegrationSetup: (
fixtures: Record<string, ServiceFixture<StubConfig>>
) => { globalSetup: string; inject: unknown };
}
async function loadDist(): Promise<DistModule> {
return (await import("../dist/index.js")) as unknown as DistModule;
}
describe("dist artifact: createIntegrationSetup.globalSetup", () => {
it("returns an absolute filesystem path that exists on disk", async () => {
const { createIntegrationSetup } = await loadDist();
const result = createIntegrationSetup({ stub: stubFixture });
expect(typeof result.globalSetup).toBe("string");
expect(result.globalSetup.length).toBeGreaterThan(0);
expect(isAbsolute(result.globalSetup)).toBe(true);
expect(existsSync(result.globalSetup)).toBe(true);
});
it("MUST NOT return a data: / blob: / non-file URL scheme", async () => {
const { createIntegrationSetup } = await loadDist();
const result = createIntegrationSetup({ stub: stubFixture });
expect(result.globalSetup.startsWith("data:")).toBe(false);
expect(result.globalSetup.startsWith("blob:")).toBe(false);
expect(result.globalSetup.startsWith("http:")).toBe(false);
expect(result.globalSetup.startsWith("https:")).toBe(false);
});
it("globalSetup ends with the expected filename", async () => {
const { createIntegrationSetup } = await loadDist();
const result = createIntegrationSetup({ stub: stubFixture });
// Tolerant of POSIX (/) and Windows (\) path separators.
expect(
result.globalSetup.endsWith("setup/global-setup.js") ||
result.globalSetup.endsWith("setup\\global-setup.js")
).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
{
"$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",
"src/setup/global-setup.ts",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,43 @@
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: {
index: "src/index.ts",
"setup/global-setup": "src/setup/global-setup.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"vitest",
"vitest/node",
"testcontainers",
/^testcontainers\/.*/,
/^node:/,
/^@suaveplan\/.*/,
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.spec-conformance.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/setup/global-setup.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});