Initial Commit
This commit is contained in:
117
packages/testkit/testing/testing-integration/README.md
Normal file
117
packages/testkit/testing/testing-integration/README.md
Normal 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.
|
||||
|
||||
104
packages/testkit/testing/testing-integration/package.json
Normal file
104
packages/testkit/testing/testing-integration/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { TestEnvironment, TestEnvironmentError } from "./test-environment.js";
|
||||
export type {
|
||||
TestEnvironmentOptions,
|
||||
TestEnvironmentSetupResult,
|
||||
} from "./test-environment.types.js";
|
||||
@@ -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.
|
||||
@@ -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>();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { InferConfig, ServiceFixture } from "./service-fixture.js";
|
||||
@@ -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.
|
||||
@@ -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>();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
24
packages/testkit/testing/testing-integration/src/index.ts
Normal file
24
packages/testkit/testing/testing-integration/src/index.ts
Normal 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();
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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>>
|
||||
>();
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>>
|
||||
);
|
||||
}
|
||||
@@ -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]>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createIntegrationSetup } from "./create-integration-setup.js";
|
||||
export type { IntegrationSetupResult } from "./create-integration-setup.types.js";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
43
packages/testkit/testing/testing-integration/vite.config.ts
Normal file
43
packages/testkit/testing/testing-integration/vite.config.ts
Normal 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\/.*/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user