first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
# @suaveplan/testing - Universal test utilities (FakeClock, Factory, FixtureManager, MemoryStore, builders, helpers, observability) — runner-agnostic, no DOM dependencies
## Summary
Shared test infrastructure for Suaveplan. Provides runner primitives (Vitest or bun-test via conditional exports), structured builders, a deterministic FakeClock, Factory and FixtureManager, an in-memory store, async helpers, and a WSL2-aware `createTestConfig()` factory. Every other `@suaveplan/*` package imports from here when writing tests.
This package is **Layer 0** in the dependency graph: it has zero `@suaveplan/*` workspace dependencies. External catalog deps only. Version `0.1.0` .
<!-- begin:auto-docs -->
## Documentation tree
- [request](./src/builders/request/request.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [request](./src/builders/request/request.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [create-test-config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [clock-constants](./src/constants/clock-constants.md) — `clock-constants` centralises the default configuration values and safety limits for `FakeClock`, the controllable time…
- [fixture-constants](./src/constants/fixture-constants.md) — `fixture-constants` defines the safety limits governing `FixtureManager`, the lifecycle-aware test fixture coordinator…
- [helpers-constants](./src/constants/helpers-constants.md) — `helpers-constants` provides the default polling configuration for `waitFor`, the async condition helper in `@suaveplan…
- [http-constants](./src/constants/http-constants.md) — `http-constants` provides the default HTTP configuration values used by `ResponseBuilder`, the fluent HTTP response fix…
- [store-constants](./src/constants/store-constants.md) — `store-constants` defines the default configuration and hard upper bounds for `MemoryStore`, the in-process key-value s…
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [codes](./src/errors/codes.md) — `TESTING_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing` emits. All codes share the `TES…
- [testing-error](./src/errors/testing-error.md) — Every error thrown from `@suaveplan/testing` is a `TestingError` or a domain-specific subclass (`ClockError`, `Fixtures…
- [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [fixture-manager-internals](./src/fixtures/fixture-manager/fixture-manager-internals.md) — Houses the internal algorithmic helpers for `FixtureManager` so that the public-facing class file stays under the 300-l…
- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [test-helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - vitest — The `runner/vitest` module re-exports vite…
- [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - vitest — The `runner/vitest` module re-exports vite…
- [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
- [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [http](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [request](./src/builders/request/request.md) — <!-- begin:auto-docs --> _(no submodules)_- [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_- [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_- [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-test-config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [clock-constants](./src/constants/clock-constants.md) — `clock-constants` centralises the default configuration values and safety limits for `FakeClock`, the controllable time…
- [fixture-constants](./src/constants/fixture-constants.md) — `fixture-constants` defines the safety limits governing `FixtureManager`, the lifecycle-aware test fixture coordinator…
- [helpers-constants](./src/constants/helpers-constants.md) — `helpers-constants` provides the default polling configuration for `waitFor`, the async condition helper in `@suaveplan…
- [http-constants](./src/constants/http-constants.md) — `http-constants` provides the default HTTP configuration values used by `ResponseBuilder`, the fluent HTTP response fix…
- [store-constants](./src/constants/store-constants.md) — `store-constants` defines the default configuration and hard upper bounds for `MemoryStore`, the in-process key-value s…
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing` emits. All codes share the `TES…
- [testing-error](./src/errors/testing-error.md) — Every error thrown from `@suaveplan/testing` is a `TestingError` or a domain-specific subclass (`ClockError`, `Fixtures…
- [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture-manager-internals](./src/fixtures/fixture-manager/fixture-manager-internals.md) — Houses the internal algorithmic helpers for `FixtureManager` so that the public-facing class file stays under the 300-l…
- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [test-helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
- [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
- [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
- [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_- [request](./src/builders/request/request.md) — <!-- begin:auto-docs --> _(no submodules)_- [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_- [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_- [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-test-config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [clock-constants](./src/constants/clock-constants.md) — `clock-constants` centralises the default configuration values and safety limits for `FakeClock`, the controllable time…
- [fixture-constants](./src/constants/fixture-constants.md) — `fixture-constants` defines the safety limits governing `FixtureManager`, the lifecycle-aware test fixture coordinator…
- [helpers-constants](./src/constants/helpers-constants.md) — `helpers-constants` provides the default polling configuration for `waitFor`, the async condition helper in `@suaveplan…
- [http-constants](./src/constants/http-constants.md) — `http-constants` provides the default HTTP configuration values used by `ResponseBuilder`, the fluent HTTP response fix…
- [store-constants](./src/constants/store-constants.md) — `store-constants` defines the default configuration and hard upper bounds for `MemoryStore`, the in-process key-value s…
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing` emits. All codes share the `TES…
- [testing-error](./src/errors/testing-error.md) — Every error thrown from `@suaveplan/testing` is a `TestingError` or a domain-specific subclass (`ClockError`, `Fixtures…
- [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture-manager-internals](./src/fixtures/fixture-manager/fixture-manager-internals.md) — Houses the internal algorithmic helpers for `FixtureManager` so that the public-facing class file stays under the 300-l…
- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [test-helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
- [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
- [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
- [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_- [request](./src/builders/request/request.md) — <!-- begin:auto-docs --> _(no submodules)_- [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_- [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_- [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-test-config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [clock-constants](./src/constants/clock-constants.md) — `clock-constants` centralises the default configuration values and safety limits for `FakeClock`, the controllable time…
- [fixture-constants](./src/constants/fixture-constants.md) — `fixture-constants` defines the safety limits governing `FixtureManager`, the lifecycle-aware test fixture coordinator…
- [helpers-constants](./src/constants/helpers-constants.md) — `helpers-constants` provides the default polling configuration for `waitFor`, the async condition helper in `@suaveplan…
- [http-constants](./src/constants/http-constants.md) — `http-constants` provides the default HTTP configuration values used by `ResponseBuilder`, the fluent HTTP response fix…
- [store-constants](./src/constants/store-constants.md) — `store-constants` defines the default configuration and hard upper bounds for `MemoryStore`, the in-process key-value s…
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing` emits. All codes share the `TES…
- [testing-error](./src/errors/testing-error.md) — Every error thrown from `@suaveplan/testing` is a `TestingError` or a domain-specific subclass (`ClockError`, `Fixtures…
- [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture-manager-internals](./src/fixtures/fixture-manager/fixture-manager-internals.md) — Houses the internal algorithmic helpers for `FixtureManager` so that the public-facing class file stays under the 300-l…
- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [test-helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
- [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
- [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
- [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_- [http](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_- [request](./src/builders/request/request.md) — <!-- begin:auto-docs --> _(no submodules)_- [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_ - [response](./src/builders/response/response.md) — <!-- begin:auto-docs --> _(no submodules)_- [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fake-clock](./src/clock/fake-clock/fake-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_ - [create-test-config](./src/config/create-test-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [clock-constants](./src/constants/clock-constants.md) — `clock-constants` centralises the default configuration values and safety limits for `FakeClock`, the controllable time…
- [fixture-constants](./src/constants/fixture-constants.md) — `fixture-constants` defines the safety limits governing `FixtureManager`, the lifecycle-aware test fixture coordinator…
- [helpers-constants](./src/constants/helpers-constants.md) — `helpers-constants` provides the default polling configuration for `waitFor`, the async condition helper in `@suaveplan…
- [http-constants](./src/constants/http-constants.md) — `http-constants` provides the default HTTP configuration values used by `ResponseBuilder`, the fluent HTTP response fix…
- [store-constants](./src/constants/store-constants.md) — `store-constants` defines the default configuration and hard upper bounds for `MemoryStore`, the in-process key-value s…
- [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_ - [context](./src/context/context.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing` emits. All codes share the `TES…
- [testing-error](./src/errors/testing-error.md) — Every error thrown from `@suaveplan/testing` is a `TestingError` or a domain-specific subclass (`ClockError`, `Fixtures…
- [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factory](./src/factories/factory/factory.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [fixture-manager-internals](./src/fixtures/fixture-manager/fixture-manager-internals.md) — Houses the internal algorithmic helpers for `FixtureManager` so that the public-facing class file stays under the 300-l…
- [fixture-manager](./src/fixtures/fixture-manager/fixture-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_ - [test-helpers](./src/helpers/test-helpers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_ - [with-observability](./src/observability/with-observability.md) — <!-- begin:auto-docs --> _(no submodules)_- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
- [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_ - [memory-store](./src/storage/memory-store/memory-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_ - [http](./src/types/http.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```sh
bun add -D @suaveplan/testing
```
## Quick usage
### Vitest config
```ts
// vitest.config.ts
import { createTestConfig } from "@suaveplan/testing/config";
export default await createTestConfig({
environment: "happy-dom",
});
```
### In a test file
```ts
import {
describe,
it,
expect,
FakeClock,
defineFactory,
waitFor,
} from "@suaveplan/testing";
describe("user flow", () => {
const userFactory = defineFactory({
defaults: {
id: (i) => `u-${i}`,
name: "Anonymous",
},
traits: {
admin: { name: "Admin" },
},
});
it("builds deterministic users", { timeout: 5000 }, () => {
const user = userFactory.build();
expect(user.id).toBe("u-0");
});
});
```
### Deterministic clock
```ts
import { FakeClock } from "@suaveplan/testing";
const clock = new FakeClock({ startTime: 0 });
let fired = false;
clock.setTimeout(() => { fired = true; }, 1000);
clock.advance(1000);
// fired === true
```
## Public surface
Every export is documented in its co-located `.md` file. High-level map:
- **Runner** — `describe`, `it`, `test`, `expect`, `vi`, `fn`, `spy`, `stub`, `mock`, `fakeClock`, `restoreMocks`, `restoreTimers`
- **Builders** — `RequestBuilder`, `ResponseBuilder`, presets (`get`, `post`, `put`, `patch`, `del`, `ok`, `created`, `noContent`, `badRequest`, `unauthorized`, `notFound`, `serverError`)
- **Clock** — `FakeClock` (implements `Clock` interface)
- **Factories** — `defineFactory`, `Factory` (supports traits, per-factory plugins, deterministic output under seeded RNG)
- **Fixtures** — `FixtureManager` (supports lifecycle plugins, AbortSignal)
- **Storage** — `MemoryStore` (TTL expiry via injected clock, capacity-bounded)
- **Helpers** — `assertThrows`, `assertThrowsAsync`, `createCollector`, `deferred`, `measureTime`, `sleep`, `uniqueTestId`, `waitFor`, `resetTestIdCounter`
- **Constants** — `DEFAULT_*`, `MAX_*`
- **Types** — `HttpMethod`, `HttpHeaders`, `QueryParams`, `TestRequest`, `TestResponse`, and the feature-local `*State` / `*Options` / `*Snapshot` interfaces
- **Subpaths** — `./config` (`createTestConfig`, `TestConfigOptions`), `./utils` (barrel re-export for single-import convenience), `./polyfills` (side-effect module for `setupFiles`)
## Design
- Every public function accepts an optional `{ clock?, rng?, signal? }` options parameter. Zero-config callers are unaffected; callers who need determinism pass their own `Clock` / `Rng` / `AbortSignal`.
- Every thrown error is a `TestingError` subclass with a stable `TESTING.<DOMAIN>.<REASON>` code and `{ cause?, context? }` fields.
- Public inputs and outputs have Zod schemas in sibling `*.schemas.ts` files. Schemas are NOT imported by functional entry points — tree-shake test proves this.
- Every public entrypoint is wrapped with OpenTelemetry counters, histograms, and (for iterative ops) spans via `@opentelemetry/api`. No-op when no SDK is registered.
- fast-check property tests and mitata benchmarks in `src/__tests__/` and `src/__benchmarks__/` respectively.
See the design document for the full design and the per-module decisions.
## Error codes
Every error thrown from `@suaveplan/testing` has a stable code. Callers should branch on `err.code` rather than parsing `err.message`.
| Code | Thrown by |
|---|---|
| `TESTING.CLOCK.MAX_TIMERS_EXCEEDED` | `FakeClock.setTimeout` / `setInterval` |
| `TESTING.CLOCK.NEGATIVE_ADVANCE` | `FakeClock.advance` |
| `TESTING.FIXTURES.SETUP_FAILED` | `FixtureManager.setupAll` |
| `TESTING.FIXTURES.TEARDOWN_FAILED` | `FixtureManager.teardownAll` |
| `TESTING.FIXTURES.MAX_FIXTURES_EXCEEDED` | `FixtureManager.register` |
| `TESTING.FACTORY.BUILD_FAILED` | `Factory.build` / `buildBatch` |
| `TESTING.FACTORY.TRAIT_NOT_FOUND` | `Factory.build` with unknown trait |
| `TESTING.STORE.CAPACITY_EXCEEDED` | `MemoryStore.set` |
| `TESTING.STORE.KEY_NOT_FOUND` | `MemoryStore.get` (strict mode) |
| `TESTING.HELPERS.WAIT_TIMEOUT` | `waitFor` |
| `TESTING.HELPERS.ASSERTION_FAILED` | `assertThrows` / `assertThrowsAsync` |
| `TESTING.BUILDER.INVALID_STATE` | `RequestBuilder` / `ResponseBuilder` |
| `TESTING.CONFIG.UNKNOWN_RUNNER` | `createTestConfig` |
| `TESTING.ABORTED` | any iterative helper when its `signal` fires |
## License
MIT

View File

@@ -0,0 +1,144 @@
# RequestBuilder — fluent HTTP request construction for tests
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RequestBuilder` produces a `TestRequest` through a chainable API: method, URL, headers, query params, body, and path params each set via their own method, with `.build()` producing the final immutable request. Quick-helper presets (`get`, `post`, `put`, `patch`, `del`, `head`, `options`) return a pre-filled builder so the common cases are one-liners. Every invalid state transition throws `BuilderError` with a stable `TESTING.BUILDER.INVALID_STATE` code.
`RequestBuilder` is designed for composition: any sequence of chained mutator calls is associative — the order of header / query / body / param calls does not change the final state. This property is covered by a `fast-check` property test in a later wave.
## Features
- Fluent chainable API: `.method()`, `.url()`, `.header()`, `.query()`, `.queryParam()`, `.body()`, `.params()`
- Preset factories: `get(url)`, `post(url, body?)`, `put(url, body?)`, `patch(url, body?)`, `del(url)`, `head(url)`, `options(url)`
- Overloaded `.header(name, value)` / `.header(headers)` accepting either signature
- `.clear()` resets the builder without allocating a new instance
- Immutable `state` getter for assertion-based composition tests
- Throws `BuilderError("TESTING.BUILDER.INVALID_STATE")` on unknown methods, empty URL, missing required argument pairs, or `.build()` without method/url set
- Zod schemas in a peer `request.schemas.ts` for opt-in runtime validation
## Usage
### Basic example — build a GET request
```ts
import { get } from "@suaveplan/testing";
const req = get("/users/42")
.header("Accept", "application/json")
.query({ expand: "addresses" })
.build();
// req.method === "GET"
// req.url === "/users/42"
// req.headers.Accept === "application/json"
// req.query.expand === "addresses"
```
### Advanced example — POST with body, path params, and merged headers
```ts
import { post } from "@suaveplan/testing";
const req = post("/users/:id/posts", { title: "hello" })
.params({ id: "42" })
.header({
Accept: "application/json",
"Content-Type": "application/json",
})
.header("X-Request-Id", crypto.randomUUID())
.query({ draft: "1" })
.build();
// req.method === "POST"
// req.params.id === "42"
// req.headers["Content-Type"] === "application/json"
// req.body === { title: "hello" }
```
### Direct `RequestBuilder` for custom presets
```ts
import { RequestBuilder } from "@suaveplan/testing/builders/request";
function rpc(method: string, body: unknown) {
return new RequestBuilder()
.method("POST")
.url(`/rpc/${method}`)
.body(body)
.header("Content-Type", "application/json");
}
```
## API Reference
### `class RequestBuilder`
#### Getters
- `state: RequestBuilderState` — immutable snapshot of `{ method, url, headers, query, body, params }`
#### Setters (all return `this`)
- `method(method: HttpMethod): this`
- `url(url: string): this`
- `header(name: string, value: string): this`
- `header(headers: HttpHeaders): this`
- `query(params: QueryParams): this`
- `queryParam(name: string, value: string | string[]): this`
- `body(value: TBody | undefined): this`
- `params(values: Record<string, string>): this`
- `params(name: string, value: string): this`
- `clear(): this`
#### Terminator
- `build(): TestRequest<TBody | undefined>` — throws `BuilderError` if method or url is missing
### Preset factories
All presets thread an optional `<TBody>` type parameter; omitting it
defaults to `unknown` to preserve the pre-generic ergonomics.
- `get<TBody = unknown>(url: string): RequestBuilder<TBody>`
- `post<TBody = unknown>(url: string, body?: TBody): RequestBuilder<TBody>`
- `put<TBody = unknown>(url: string, body?: TBody): RequestBuilder<TBody>`
- `patch<TBody = unknown>(url: string, body?: TBody): RequestBuilder<TBody>`
- `del<TBody = unknown>(url: string): RequestBuilder<TBody>` — DELETE (named to avoid the `delete` keyword)
- `head<TBody = unknown>(url: string): RequestBuilder<TBody>`
- `options<TBody = unknown>(url: string): RequestBuilder<TBody>`
#### Generic example
```ts
import { post } from "@suaveplan/testing/builders/request";
interface CreateUser {
readonly name: string;
readonly email: string;
}
const req = post<CreateUser>("/users", {
name: "Ada",
email: "ada@example.com",
}).build();
// req.body is typed as `CreateUser | undefined`, not `unknown`.
```
### Error codes thrown
| Code | Condition |
|---|---|
| `TESTING.BUILDER.INVALID_STATE` | Unknown HTTP method, empty URL, missing second argument to the two-argument `header` / `params` overloads, or `.build()` without method/url |
## Implementation Notes
- **Presets return a fresh builder each call.** No shared state; safe to call preset factories concurrently from multiple tests.
- **Composition is associative.** `get(url).header(a).query(b)` and `get(url).query(b).header(a)` produce the same `state` because each mutator touches a distinct bag. Property-tested in a later wave.
- **Internal bags are `Record<string, ...>` not `Map<string, ...>`.** JSON-serializable output without conversion, and the `state` getter's spread clone is cheap (all values are strings or primitives).
- **`.body(undefined)` clears body.** The builder stores `undefined` as "no body set"; `build()` carries that forward unchanged so consumers distinguish "explicit null body" from "no body". If you need an explicit null body, pass `null`.
- **`.clear()` mutates in place** rather than returning a new builder. Useful in tight loops where allocation matters; the `state` getter still returns an immutable snapshot so callers can't trip themselves up.
- **Header / param merging is shallow.** A subsequent call with the same key overwrites the prior value. Matches HTTP semantics (last-write-wins for single-valued headers).

View File

@@ -0,0 +1,130 @@
# ResponseBuilder — fluent HTTP response construction for tests
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ResponseBuilder` produces a `TestResponse` (status, headers, body, latency) through a chainable API. Quick-helper factories (`ok`, `created`, `noContent`, `badRequest`, `unauthorized`, `notFound`, `serverError`) return a pre-filled builder for the common status codes so most callers skip the `.status()` call entirely. Invalid status codes or negative latency throw `BuilderError` with a stable `TESTING.BUILDER.INVALID_STATE` code.
The builder is the response-side counterpart to `RequestBuilder`: same design principles, same associative-composition guarantee, same error vocabulary. Paired with a fixture or factory that wires request → response, it's the minimal surface needed to model HTTP round-trips in tests without standing up real servers.
## Features
- Fluent chainable API: `.status()`, `.header()`, `.body()`, `.latency()`
- Seven preset factories covering the canonical success, redirection, client-error, and server-error codes
- Overloaded `.header(name, value)` / `.header(headers)` accepting either signature
- `latencyMs` models simulated backend delay without actual sleep — fixture harnesses can use it to order responses deterministically
- Defaults: status 200, zero latency, no body, no headers — every unset field has a sane production-safe default
- `.clear()` resets to defaults in place
- Immutable `state` getter for composition assertions
- Throws `BuilderError("TESTING.BUILDER.INVALID_STATE")` on out-of-range status, non-finite / negative latency, or missing second argument to the two-argument `.header` overload
- Zod schemas in a peer `response.schemas.ts` for opt-in runtime validation
## Usage
### Basic example — 200 OK with a JSON body
```ts
import { ok } from "@suaveplan/testing";
const res = ok({ id: 42, name: "alice" })
.header("Content-Type", "application/json")
.build();
// res.status === 200
// res.body === { id: 42, name: "alice" }
```
### Advanced example — 429 Too Many Requests with retry headers and latency
```ts
import { ResponseBuilder } from "@suaveplan/testing/builders/response";
const res = new ResponseBuilder()
.status(429)
.header({
"Content-Type": "application/json",
"Retry-After": "5",
})
.body({ error: "rate_limited", retryInMs: 5000 })
.latency(120)
.build();
// res.status === 429
// res.headers["Retry-After"] === "5"
// res.latencyMs === 120
```
### Error-path preset — 404 with diagnostic body
```ts
import { notFound } from "@suaveplan/testing";
const res = notFound({ error: "user_not_found", id: "42" })
.header("X-Request-Id", "r-123")
.build();
```
## API Reference
### `class ResponseBuilder`
#### Getters
- `state: ResponseBuilderState` — immutable snapshot of `{ status, headers, body, latencyMs }`
#### Setters (all return `this`)
- `status(code: number): this` — integer in `[100, 600)` or throws
- `header(name: string, value: string): this`
- `header(headers: HttpHeaders): this`
- `body(value: TBody | undefined): this`
- `latency(ms: number): this` — non-negative finite or throws
- `clear(): this`
#### Terminator
- `build(): TestResponse<TBody | undefined>`
### Preset factories
All presets thread an optional `<TBody>` type parameter; omitting it
defaults to `unknown` to preserve the pre-generic ergonomics.
- `ok<TBody = unknown>(body?: TBody): ResponseBuilder<TBody>` — 200
- `created<TBody = unknown>(body?: TBody): ResponseBuilder<TBody>` — 201
- `noContent<TBody = unknown>(): ResponseBuilder<TBody>` — 204, no body
- `badRequest<TBody = unknown>(body?: TBody): ResponseBuilder<TBody>` — 400
- `unauthorized<TBody = unknown>(body?: TBody): ResponseBuilder<TBody>` — 401
- `notFound<TBody = unknown>(body?: TBody): ResponseBuilder<TBody>` — 404
- `serverError<TBody = unknown>(body?: TBody): ResponseBuilder<TBody>` — 500
#### Generic example
```ts
import { ok } from "@suaveplan/testing/builders/response";
interface UserDto {
readonly id: string;
readonly name: string;
}
const res = ok<UserDto>({ id: "1", name: "Ada" }).build();
// res.body is typed as `UserDto | undefined`, not `unknown`.
```
### Error codes thrown
| Code | Condition |
|---|---|
| `TESTING.BUILDER.INVALID_STATE` | Status outside `[100, 600)`, non-integer status, negative or non-finite latency, missing second argument to the two-argument `.header` overload |
## Implementation Notes
- **Default status is 200**, not `null` or `undefined`. A fresh `ResponseBuilder` is already a valid 200 OK — `.build()` never throws on under-specification (unlike `RequestBuilder` which requires method + url).
- **Status range `[100, 600)`** matches the HTTP/1.1 status class vocabulary. Codes outside this window are unrepresentable in real HTTP and would reveal a test-setup bug.
- **`latency(0)` is the default.** Non-zero values don't actually sleep — they're recorded as metadata on the built `TestResponse` for fixture harnesses to use (e.g., ordering responses by expected arrival time in a simulated network).
- **Preset factories** return a fresh builder each call. No shared state.
- **Composition is associative** across `.header`, `.body`, `.latency`, `.status` — each touches an independent field. Property-tested in a later wave.
- **`.body(undefined)` stores `undefined`** (distinct from "explicit null"). If you want an explicit null body pass `null`.

View File

@@ -0,0 +1,123 @@
# FakeClock — deterministic clock with a controllable timer queue
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FakeClock` is an in-memory `Clock` implementation for tests. It lets you freeze, advance, and snapshot simulated time without coupling tests to the real `Date.now()` or `performance.now()`. Every helper in `@suaveplan/testing` that accepts an optional `clock` parameter will use this in place of the default. Because `FakeClock implements Clock`, code written against the context interface doesn't change when you swap in deterministic time.
The clock owns its own timer queue: `setTimeout`, `setInterval`, `clearTimeout`, and `clearInterval` are methods on the instance. Advancing time via `advance(ms)` fires timers in order up to the new target. `runAll()` exhausts the queue; `snapshot()` / `restore()` capture and replay state. All invariant violations surface as `ClockError` with stable codes, never plain `Error`.
## Features
- Implements the shared `Clock` interface (`now()`, `hrtime()`)
- Deterministic `setTimeout` / `setInterval` with integer ids, cleared by id
- `advance(ms)` fires due timers in fireAt-ascending order (ties by insertion order)
- `runAll()` fires every pending timer until the queue is empty
- `snapshot()` / `restore()` capture full state including pending timers
- `pendingCount`, `elapsed` getters for inspection
- Optional `autoAdvance` mode for tight loops that schedule without manually advancing
- Enforces `MAX_TIMERS` (10,000) via `ClockError("TESTING.CLOCK.MAX_TIMERS_EXCEEDED")`
- Rejects negative `advance` / negative delay with `ClockError("TESTING.CLOCK.NEGATIVE_ADVANCE")`
- Zero workspace dependencies; runs identically in Bun, Node, and browsers
## Usage
### Basic example — advance time and fire a one-shot timer
```ts
import { FakeClock } from "@suaveplan/testing/clock/fake-clock";
const clock = new FakeClock({ startTime: 1_700_000_000_000 });
let fired = false;
clock.setTimeout(() => { fired = true; }, 250);
clock.advance(249);
// fired === false — not yet due
clock.advance(1);
// fired === true — clock.now() is now startTime + 250
```
### Advanced example — intervals, snapshots, and injection
```ts
import { FakeClock } from "@suaveplan/testing/clock/fake-clock";
import type { Clock } from "@suaveplan/testing/context";
function poll(clock: Clock, predicate: () => boolean): number {
return clock.now();
}
const clock = new FakeClock();
const ticks: number[] = [];
const id = clock.setInterval(() => ticks.push(clock.now()), 100);
clock.advance(350);
// ticks === [100, 200, 300]
const snap = clock.snapshot();
clock.clearInterval(id);
clock.advance(10_000);
// snap still references the interval timer; restore brings it back
clock.restore(snap);
// clock is back exactly where it was before clear+advance
```
## API Reference
### `class FakeClock implements Clock`
#### Constructor
```ts
new FakeClock(options?: FakeClockOptions)
```
Options (`FakeClockOptions`):
- `startTime?: number` — initial ms-since-epoch; default `0`
- `autoAdvance?: boolean` — when `true`, scheduling implicitly advances; default `false`
- `autoAdvanceMs?: number` — amount to advance per schedule when autoAdvance is on; default `1`
#### Clock interface
- `now(): number` — current simulated ms
- `hrtime(): bigint` — monotonic nanoseconds since the clock's epoch
#### Scheduling
- `setTimeout(callback: () => void, delayMs: number): number` — returns a timer id
- `setInterval(callback: () => void, intervalMs: number): number` — returns a timer id; `intervalMs` must be positive or `ClockError("TESTING.CLOCK.NEGATIVE_ADVANCE")` is thrown
- `clearTimeout(id: number): void` — no-op for unknown ids
- `clearInterval(id: number): void` — no-op for unknown ids
#### Control
- `advance(ms: number): void` — move time forward; throws `ClockError("TESTING.CLOCK.NEGATIVE_ADVANCE")` if `ms < 0`
- `runAll(): void` — fire until the queue is empty
- `clear(): void` — remove all pending timers without firing
#### Inspection
- `pendingCount: number`
- `elapsed: number` — same as `now()`
- `snapshot(): ClockSnapshot` — readonly deep copy of state
- `restore(snapshot: ClockSnapshot): void` — replace state
### Error codes thrown
| Code | Condition |
|---|---|
| `TESTING.CLOCK.MAX_TIMERS_EXCEEDED` | Scheduling when `pendingCount >= MAX_TIMERS` |
| `TESTING.CLOCK.NEGATIVE_ADVANCE` | Calling `advance(n)` with `n < 0`, `setTimeout` with negative delay, or `setInterval` with non-positive interval |
## Implementation Notes
- **Firing order.** `advance(ms)` collects all timers whose `fireAt <= target`, sorts by `(fireAt asc, id asc)`, and fires in that order. A callback that schedules a new timer within the same window gets picked up on the next `collectDue` pass inside the same `advance` call. A callback that clears its own timer (or another pending one) is respected — the cleared timer does not fire.
- **Interval timers** are rescheduled at `fireAt + interval` **before** the callback runs, so a callback that deletes its own id does not see an extra fire.
- **`hrtime` updates** track `currentMs` deltas multiplied by 1,000,000 nanoseconds. This keeps the two clocks consistent without requiring separate sub-millisecond simulation.
- **No Web Timers leak.** `FakeClock` never calls real `setTimeout` / `setInterval`; it owns its queue entirely. Safe to run in parallel alongside real timers without collision.
- **`MAX_TIMERS`** is a scheduling bound, not a historical bound — cleared timers don't count. A legitimate test with many long-lived intervals will need to clear what it no longer needs or raise the bound through a custom subclass; 10,000 is deliberately high enough that runaway-scheduling bugs trip it first.
- **Shape compatibility with future `@suaveplan/datetime`.** The `Clock` interface matches what a datetime-package adapter will expose, so application code that threads `clock` through can swap implementations with no API change.

View File

@@ -0,0 +1,85 @@
# `createTestConfig`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createTestConfig` is the canonical Vitest-config factory for every package in Suaveplan. It collapses the ~30 lines of `defineConfig({ test: { … } })` boilerplate that every package was hand-rolling into a single typed call, while baking in the platform-specific defaults that are non-negotiable on Bun + WSL2: the `forks` pool (since `vmThreads` and `threads` both fail), the `globalThis.crypto` polyfill via `@suaveplan/testing/polyfills` as a setup file, the `istanbul` coverage provider with **100 / 100 / 100 / 100** thresholds, and the canonical `src/**/*.test.ts` + `tests/**/*.test.ts` glob shape.
The factory is conservative: every option has a sensible default, every override is additive (extra setup files layer on top of the polyfills entry, extra includes append to the canonical patterns), and the only field that requires opt-in is `testTimeout` for async-heavy suites.
## Features
- Single-line `vitest.config.ts` for the common case: `export default createTestConfig()`.
- Built-in WSL2 / Bun safety: `forks` pool, polyfills always present, file parallelism on by default but easily disabled for browser tests.
- Istanbul coverage with 100 % thresholds — matches `AGENTS.md` §7 and the `bunx turbo test:coverage` pre-publish gate.
- Additive options for additional setup files, includes, excludes, and coverage excludes.
- `testTimeout` and `hookTimeout` linked by default; override either independently.
## Usage: Basic Example
```ts
// vitest.config.ts
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();
```
That's the entire file for a Node-runtime, sync-heavy package. Polyfills auto-loaded; coverage thresholds enforced; the `forks` pool selected.
## Usage: Advanced Example
```ts
// DOM package with extra setup, async tests, and a relaxed coverage exclusion
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
environment: "happy-dom",
fileParallelism: false,
testTimeout: 30_000,
additionalSetupFiles: ["./tests/setup/jest-dom.ts"],
additionalCoverageExcludes: ["src/legacy/**"],
});
```
```ts
// Integration package with Testcontainers (long async setup)
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 30_000,
hookTimeout: 60_000,
additionalIncludes: ["integration/**/*.test.ts"],
});
```
## API Reference
### `createTestConfig(options?: CreateTestConfigOptions): UserConfig`
Returns a `vitest/config` `UserConfig` ready to default-export. Throws nothing; pure transformation of options into config.
### `CreateTestConfigOptions`
| Field | Type | Default | Notes |
|---|---|---|---|
| `environment` | `"node" \| "happy-dom" \| "jsdom"` | `"node"` | DOM packages should use `@suaveplan/testing-dom`'s helper if it exists. |
| `testTimeout` | `number` | `5000` | Bump to `30000` for async/IO/integration. |
| `hookTimeout` | `number` | `testTimeout` | Per-hook budget. |
| `additionalSetupFiles` | `string[]` | `[]` | Layered after the polyfills entry. |
| `additionalIncludes` | `string[]` | `[]` | Appended to `src/**/*.test.ts` + `tests/**/*.test.ts`. |
| `additionalExcludes` | `string[]` | `[]` | Appended to `dist/**`, `node_modules/**`, `src/__benchmarks__/**`. |
| `coverageInclude` | `string[]` | `["src/**/*.ts"]` | Replaces, not appends. |
| `additionalCoverageExcludes` | `string[]` | `[]` | Appended to defaults (test, bench, schemas, types, barrel index, benchmarks, tests dir). |
| `thresholds` | `{statements,branches,functions,lines}` | `100/100/100/100` | Lowering requires an spec change. |
| `fileParallelism` | `boolean` | `true` | Set `false` for browser tests on WSL2 (20+ concurrent forks time out). |
| `pool` | `"forks" \| "threads" \| "vmThreads"` | `"forks"` | Only `forks` reliably works on Bun/WSL2. |
## Implementation Notes
- The polyfills setup file is referenced by the package specifier `@suaveplan/testing/polyfills`, not a relative path. Vitest resolves it via the consuming package's `node_modules`. This avoids brittle `./tests/setup/polyfills.ts` paths that drifted across 81 copies pre-centralisation.
- `setupFiles` ordering matters: polyfills run first so that `globalThis.crypto` is patched before any user setup that touches Web Crypto.
- Coverage `exclude` patterns intentionally include `src/**/*.types.ts` because feature-co-located `*.types.ts` files are pure type aliases that can't be exercised by runtime tests.
- `coverageInclude` defaults to `["src/**/*.ts"]` — packages with `src/legacy/` carve-outs should use `additionalCoverageExcludes` rather than narrowing `coverageInclude`, so that new code added under `src/` is captured automatically.
- The factory does not set `mergeConfig` or extend any base config — explicitly avoided per the `crypto is not defined` warning in `CLAUDE.md`. Every config is self-contained via the factory call.

View File

@@ -0,0 +1,82 @@
# clock-constants
## Purpose
`clock-constants` centralises the default configuration values and safety limits for `FakeClock`, the controllable time implementation used throughout `@suaveplan/testing`. By grouping these values here, any change to a default (e.g., raising `MAX_TIMERS`) is a single-line edit visible in one file, rather than a search-and-replace across multiple modules.
The constants are consumed both by `FakeClock`'s constructor (to merge against user-supplied options) and by its timer registration path (to enforce the upper bound and throw a descriptive error before the process runs out of memory).
## Features
- `DEFAULT_CLOCK_OPTIONS` — a fully-resolved `Required<FakeClockOptions>` object with concrete values for every field, enabling a zero-argument `new FakeClock()` to function without conditional logic inside the constructor
- `MAX_TIMERS` — the upper bound on concurrent timers per `FakeClock` instance; enforced defensively to catch runaway scheduling loops in tests before they OOM the process
- Typed against `FakeClockOptions` so TypeScript immediately flags any drift between constants and the options interface
- Zero runtime side effects — pure constant exports, safe to tree-shake
## Usage: Basic Example
```ts
import { DEFAULT_CLOCK_OPTIONS, MAX_TIMERS } from "@suaveplan/testing/constants";
// Merge user options over defaults
const opts: Required<FakeClockOptions> = {
...DEFAULT_CLOCK_OPTIONS,
startTime: Date.now(),
};
console.log(MAX_TIMERS); // 10000
```
## Usage: Advanced Example
```ts
import { DEFAULT_CLOCK_OPTIONS, MAX_TIMERS } from "@suaveplan/testing/constants";
import type { FakeClockOptions } from "@suaveplan/testing/clock";
function createClock(options: FakeClockOptions = {}) {
const resolved: Required<FakeClockOptions> = {
...DEFAULT_CLOCK_OPTIONS,
...options,
};
if (resolved.startTime < 0) {
throw new RangeError("startTime must be non-negative");
}
return resolved;
}
// The timer guard is checked inside FakeClock itself, but the constant
// is exported so external tooling can surface the same limit in error messages.
function assertTimerCapacity(count: number): void {
if (count > MAX_TIMERS) {
throw new Error(`Timer count ${count} exceeds MAX_TIMERS (${MAX_TIMERS})`);
}
}
```
## API Reference
### `DEFAULT_CLOCK_OPTIONS`
```ts
const DEFAULT_CLOCK_OPTIONS: Required<FakeClockOptions> = {
startTime: 0, // Epoch zero — deterministic baseline
autoAdvance: false, // Time only advances when explicitly ticked
autoAdvanceMs: 1, // Milliseconds per auto-advance tick (when enabled)
};
```
All fields are present so merging with user options is a single `{ ...DEFAULT_CLOCK_OPTIONS, ...userOptions }` spread without nullish coalescing per field.
### `MAX_TIMERS`
```ts
const MAX_TIMERS = 10_000;
```
The maximum number of live timers (setTimeout / setInterval callbacks) that a single `FakeClock` instance will accept before throwing `ClockError("TESTING.CLOCK.MAX_TIMERS_EXCEEDED")`. Sized to catch runaway recursion or scheduling loops in tests; legitimate test suites rarely need more than a handful of concurrent timers.
## Implementation Notes
`DEFAULT_CLOCK_OPTIONS` uses `Required<FakeClockOptions>` rather than a plain object literal so that TypeScript enforces exhaustiveness — if a new optional field is added to `FakeClockOptions`, the compiler will flag this file as the required update point. This is intentional: the constants file is the canonical source of truth for defaults, not the `FakeClock` constructor.
`MAX_TIMERS` is set to 10,000 rather than `Infinity` or a very large number to fail fast on common mistake patterns (e.g., `setInterval` in a loop without cleanup). If a legitimate workload genuinely needs more timers, the limit should be raised here with a comment explaining the use case.

View File

@@ -0,0 +1,71 @@
# fixture-constants
## Purpose
`fixture-constants` defines the safety limits governing `FixtureManager`, the lifecycle-aware test fixture coordinator in `@suaveplan/testing`. Concentrating these limits in a named constant file rather than inlining magic numbers inside `FixtureManager` makes the bound immediately discoverable, easily adjustable, and self-documenting in error messages.
The single constant exported here acts as a defensive guard against accidental fixture accumulation — a symptom of runaway test setup code or mis-wired fixture registration in large test suites.
## Features
- `MAX_FIXTURES` — upper bound on the number of fixtures a single `FixtureManager` instance will accept before throwing a structured error
- Typed as a plain `number` constant — no runtime computation, safe to import anywhere
- Centralises the limit so it appears in exactly one place in the codebase, making audits and capacity planning straightforward
- Barrel-exported from `@suaveplan/testing/constants` for convenient import
## Usage: Basic Example
```ts
import { MAX_FIXTURES } from "@suaveplan/testing/constants";
console.log(MAX_FIXTURES); // 100
// FixtureManager internally checks:
// if (this.fixtures.length >= MAX_FIXTURES) {
// throw new FixturesError("TESTING.FIXTURES.MAX_FIXTURES_EXCEEDED", ...);
// }
```
## Usage: Advanced Example
```ts
import { MAX_FIXTURES } from "@suaveplan/testing/constants";
import { FixtureManager } from "@suaveplan/testing/fixtures";
// Generate one fixture per API endpoint under test
const manager = new FixtureManager();
const endpoints = getApiEndpoints(); // returns string[]
if (endpoints.length > MAX_FIXTURES) {
throw new Error(
`Cannot register ${endpoints.length} endpoint fixtures — exceeds MAX_FIXTURES (${MAX_FIXTURES}). ` +
"Split the test suite or increase MAX_FIXTURES."
);
}
for (const endpoint of endpoints) {
manager.register({
name: endpoint,
setup: () => startMockServer(endpoint),
teardown: (server) => server.close(),
});
}
```
## API Reference
### `MAX_FIXTURES`
```ts
const MAX_FIXTURES = 100;
```
The maximum number of fixture registrations a single `FixtureManager` will accept. Exceeding this limit causes `FixtureManager.register()` to throw `FixturesError("TESTING.FIXTURES.MAX_FIXTURES_EXCEEDED")`.
This value is intentionally conservative — real test suites rarely need more than a few dozen fixtures per manager. If your suite genuinely requires more, raise this constant and document the rationale so future maintainers understand the intent.
## Implementation Notes
A limit of 100 was chosen to catch the most common mistake pattern — registering a fixture inside a loop over test-data arrays — while leaving ample headroom for legitimately large suites. The exact number is not load-bearing; any value that distinguishes "runaway registration" from "intentional use" serves the purpose.
The constant is defined in `src/constants/` (flat, no sub-directory) following the project convention that constant files are domain-grouped leaves without their own sub-trees.

View File

@@ -0,0 +1,66 @@
# helpers-constants
## Purpose
`helpers-constants` provides the default polling configuration for `waitFor`, the async condition helper in `@suaveplan/testing`. By exporting these values as named constants rather than inlining them inside `waitFor`, they become visible to consumers who want to understand or override the timing behaviour, and they remain in a single authoritative location for any future adjustment.
`waitFor` repeatedly invokes a condition function until it returns truthy or a deadline is reached. The two constants here — timeout and poll interval — define that deadline and cadence under zero-configuration usage.
## Features
- `DEFAULT_WAIT_OPTIONS` — a `Required<Pick<WaitForOptions, "timeoutMs" | "intervalMs">>` object with concrete defaults for both timing fields
- Typed against `WaitForOptions` so any rename or type change in the helpers interface is caught at compile time
- Zero runtime side effects — pure constant export, tree-shaken away if unused
- Barrel-exported from `@suaveplan/testing/constants` for a single import point
## Usage: Basic Example
```ts
import { DEFAULT_WAIT_OPTIONS } from "@suaveplan/testing/constants";
console.log(DEFAULT_WAIT_OPTIONS.timeoutMs); // 5000
console.log(DEFAULT_WAIT_OPTIONS.intervalMs); // 50
```
## Usage: Advanced Example
```ts
import { DEFAULT_WAIT_OPTIONS } from "@suaveplan/testing/constants";
import { waitFor } from "@suaveplan/testing/helpers";
// Use defaults when no timing override is needed
await waitFor(() => element.textContent === "loaded");
// Tighten the timeout for a unit test that should be instant
await waitFor(
() => cache.has("key"),
{ ...DEFAULT_WAIT_OPTIONS, timeoutMs: 500 }
);
// Slow the poll interval for an integration test with I/O latency
await waitFor(
() => db.exists("record-id"),
{ ...DEFAULT_WAIT_OPTIONS, intervalMs: 250, timeoutMs: 10_000 }
);
```
## API Reference
### `DEFAULT_WAIT_OPTIONS`
```ts
const DEFAULT_WAIT_OPTIONS: Required<Pick<WaitForOptions, "timeoutMs" | "intervalMs">> = {
timeoutMs: 5000, // Give up after 5 seconds
intervalMs: 50, // Poll every 50 milliseconds
};
```
`timeoutMs` is the maximum wall-clock time (in milliseconds) `waitFor` will spend polling before throwing a timeout error. `intervalMs` is the sleep duration between consecutive condition evaluations.
## Implementation Notes
A 50 ms poll interval was chosen as a balance between responsiveness (the test detects changes quickly) and CPU load (not a tight loop). For UI tests where state changes are driven by requestAnimationFrame cycles (≈16 ms), 50 ms provides approximately three frame windows per poll — sufficient for most animation-triggered state updates.
A 5 second timeout is conservative for unit tests but appropriate for integration tests that may involve real I/O. Tests that need tighter bounds should pass an explicit `timeoutMs` rather than relying on this default, so the intent is clear in the test source.
Both constants are typed against the `WaitForOptions` interface (`Required<Pick<...>>`) rather than plain numbers so that TypeScript validates the field names at compile time. If `WaitForOptions` is ever renamed or restructured, this file surfaces the breakage immediately.

View File

@@ -0,0 +1,67 @@
# http-constants
## Purpose
`http-constants` provides the default HTTP configuration values used by `ResponseBuilder`, the fluent HTTP response fixture constructor in `@suaveplan/testing`. By naming and exporting these values rather than embedding magic numbers inside the builder, the intent of each default is self-documenting and any change (e.g., raising the default latency for slow-backend simulations) is a single-line edit in one file.
Both constants represent the "fastest happy path" baseline: a 200 OK status with zero artificial latency, suitable for the majority of test scenarios where response timing is irrelevant.
## Features
- `DEFAULT_HTTP_STATUS` — the HTTP status code used by `ResponseBuilder` when no explicit status is set (200 OK)
- `DEFAULT_RESPONSE_LATENCY_MS` — the simulated delay in milliseconds before `ResponseBuilder` resolves (0 = immediate)
- Typed as plain `number` constants — no runtime computation, zero dependency overhead
- Barrel-exported from `@suaveplan/testing/constants` for a single named import
- Used by both `ResponseBuilder` construction and the latency simulation path in the builder's `build()` method
## Usage: Basic Example
```ts
import { DEFAULT_HTTP_STATUS, DEFAULT_RESPONSE_LATENCY_MS } from "@suaveplan/testing/constants";
console.log(DEFAULT_HTTP_STATUS); // 200
console.log(DEFAULT_RESPONSE_LATENCY_MS); // 0
```
## Usage: Advanced Example
```ts
import { DEFAULT_HTTP_STATUS, DEFAULT_RESPONSE_LATENCY_MS } from "@suaveplan/testing/constants";
import { ResponseBuilder } from "@suaveplan/testing/builders";
// Default response — 200 OK, instant
const ok = new ResponseBuilder().build();
// Override status; keep default latency
const created = new ResponseBuilder({ status: 201 }).build();
// Simulate a slow backend by overriding only latency
const slow = new ResponseBuilder({
status: DEFAULT_HTTP_STATUS,
latencyMs: 200, // 200 ms instead of DEFAULT_RESPONSE_LATENCY_MS
}).build();
```
## API Reference
### `DEFAULT_HTTP_STATUS`
```ts
const DEFAULT_HTTP_STATUS = 200;
```
The HTTP status code assigned to `ResponseBuilder` instances when no explicit `status` option is supplied. 200 OK is the canonical happy-path response and the correct default for the majority of unit and integration test scenarios.
### `DEFAULT_RESPONSE_LATENCY_MS`
```ts
const DEFAULT_RESPONSE_LATENCY_MS = 0;
```
The simulated response latency in milliseconds used when no `latencyMs` option is supplied to `ResponseBuilder`. Zero means "respond immediately" — tests run at full speed with no artificial delay. Tests that need to verify timeout handling or loading-state UI should pass an explicit `latencyMs` value to make the intent clear.
## Implementation Notes
Both constants are intentionally set to the fastest-possible baseline (`200 OK`, `0 ms latency`) rather than any "realistic" value. Fixture harnesses should simulate slow or error responses explicitly — leaking timing assumptions into defaults would make test suites non-deterministic across different hardware speeds.
The constants live in `src/constants/http-constants.ts` rather than inside `response-builder.ts` to keep the builder file focused on behaviour and to allow other modules (e.g., request parsing test helpers) to share the same HTTP defaults without importing `ResponseBuilder`.

View File

@@ -0,0 +1,78 @@
# store-constants
## Purpose
`store-constants` defines the default configuration and hard upper bounds for `MemoryStore`, the in-process key-value store used as a fixture backend in `@suaveplan/testing`. Centralising these values in a named constants file makes the capacity and TTL defaults discoverable, prevents magic numbers from proliferating across the store implementation, and gives consumers a single import point when they need to reference these limits in test assertions or error messages.
The two constants cover distinct concerns: `DEFAULT_STORE_OPTIONS` sets the zero-config baseline for a typical fixture store, while `MAX_STORE_ENTRIES` enforces a hard ceiling to catch runaway configurations before they exhaust process memory.
## Features
- `DEFAULT_STORE_OPTIONS` — a `Required<Pick<MemoryStoreOptions, "maxEntries" | "defaultTtlMs">>` object with sensible fixture-scale defaults
- `MAX_STORE_ENTRIES` — the hard upper bound on `maxEntries`; exceeding this at construction time throws a structured `StoreError`
- Typed against `MemoryStoreOptions` so compile-time checking catches field renames or type changes
- Zero runtime side effects — pure constant exports, fully tree-shaken when not imported
- Barrel-exported from `@suaveplan/testing/constants` for a single named import
## Usage: Basic Example
```ts
import { DEFAULT_STORE_OPTIONS, MAX_STORE_ENTRIES } from "@suaveplan/testing/constants";
console.log(DEFAULT_STORE_OPTIONS.maxEntries); // 1000
console.log(DEFAULT_STORE_OPTIONS.defaultTtlMs); // 0 (entries never expire by default)
console.log(MAX_STORE_ENTRIES); // 1_000_000
```
## Usage: Advanced Example
```ts
import { DEFAULT_STORE_OPTIONS, MAX_STORE_ENTRIES } from "@suaveplan/testing/constants";
import { MemoryStore } from "@suaveplan/testing/storage";
// Use defaults for a simple fixture store
const store = new MemoryStore(DEFAULT_STORE_OPTIONS);
// Override capacity for a large integration test dataset
const largeStore = new MemoryStore({
...DEFAULT_STORE_OPTIONS,
maxEntries: 50_000,
});
// Validate user-supplied options before constructing
function safeCreateStore(maxEntries: number) {
if (maxEntries > MAX_STORE_ENTRIES) {
throw new RangeError(
`maxEntries ${maxEntries} exceeds MAX_STORE_ENTRIES (${MAX_STORE_ENTRIES})`
);
}
return new MemoryStore({ maxEntries, defaultTtlMs: 0 });
}
```
## API Reference
### `DEFAULT_STORE_OPTIONS`
```ts
const DEFAULT_STORE_OPTIONS: Required<Pick<MemoryStoreOptions, "maxEntries" | "defaultTtlMs">> = {
maxEntries: 1_000,
defaultTtlMs: 0,
};
```
`maxEntries` caps the number of keys the store will hold before rejecting writes with a capacity error (1,000 is a reasonable ceiling for fixture data). `defaultTtlMs` of `0` means entries live indefinitely unless a per-call TTL is explicitly provided — the right default for deterministic test fixtures where expiry would cause flakiness.
### `MAX_STORE_ENTRIES`
```ts
const MAX_STORE_ENTRIES = 1_000_000;
```
The absolute maximum value allowed for `MemoryStoreOptions.maxEntries`. Attempting to construct a `MemoryStore` with a higher limit throws `StoreError("TESTING.STORE.CAPACITY_EXCEEDED")`. This guard exists to catch copy-paste mistakes (e.g., passing bytes as entry counts) rather than to limit legitimate use — at 1 M entries, the store is already holding more data than any realistic test fixture.
## Implementation Notes
`DEFAULT_STORE_OPTIONS` is typed as `Required<Pick<MemoryStoreOptions, ...>>` rather than the full `Required<MemoryStoreOptions>` because `MemoryStoreOptions` may contain additional optional fields (e.g., a clock injection for TTL expiry) that have no meaningful "global default". Picking only the fields managed by this file prevents unintentional silencing of missing-option TypeScript errors elsewhere.
`MAX_STORE_ENTRIES` is set three orders of magnitude above `DEFAULT_STORE_OPTIONS.maxEntries` to accommodate intentionally large integration-test datasets while still rejecting obviously erroneous configurations. If a legitimate workload exceeds 1 M entries, this constant should be raised with an accompanying comment explaining the verified use case.

View File

@@ -0,0 +1,90 @@
# Context — Clock and RNG
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/testing/context` exposes two runtime-agnostic interfaces — `Clock` and `Rng` — plus a zero-config `defaultClock`, a `cryptoRng` backed by the Web Crypto API, and a deterministic `seededRng(seed)`. Every helper in `@suaveplan/testing` that reads time or randomness goes through one of these, never `Date.now()` or `Math.random()` directly. This is the foundation that makes tests reproducible: pass a `seededRng` and a `FakeClock` into a Factory or Fixture and the output is byte-stable across runs.
The interfaces are intentionally shape-compatible with the future `@suaveplan/datetime` and `@suaveplan/crypto` RNG surfaces. When those packages ship on a later layer, callers can pass a `@suaveplan/datetime` clock to `@suaveplan/testing` helpers without any signature changes — the rule forbidding cross-`@suaveplan/*` testing deps only applies here, not to the packages upstream that consume testing.
## Features
- `Clock` interface: `now()` (ms since epoch) and `hrtime()` (monotonic bigint nanoseconds)
- `Rng` interface: `nextUint32()` (range `[0, 2^32)`) and `nextFloat()` (range `[0, 1)`)
- `defaultClock`: reads `Date.now()` / `performance.now()`
- `cryptoRng`: cryptographic RNG from `globalThis.crypto.getRandomValues`
- `seededRng(seed)`: deterministic Mulberry32 PRNG — same seed, same sequence, across every target runtime
- Zero dependencies on other `@suaveplan/*` packages (Layer 0)
- Runtime-validation schemas exported from the peer `context.schemas.ts` (Zod), not pulled into the functional bundle
## Usage
### Basic example
```ts
import { defaultClock, cryptoRng } from "@suaveplan/testing/context";
console.log(defaultClock.now()); // → ms since epoch
console.log(cryptoRng.nextUint32()); // → random uint32
```
### Advanced example — deterministic test under a fixed seed
```ts
import { seededRng, type Rng, type Clock } from "@suaveplan/testing/context";
import { FakeClock } from "@suaveplan/testing/clock/fake-clock";
function chooseWinner(candidates: readonly string[], rng: Rng): string {
const idx = Math.floor(rng.nextFloat() * candidates.length);
return candidates[idx] ?? candidates[0]!;
}
const rng = seededRng(42);
const clock: Clock = new FakeClock({ startTime: 1_000_000 });
// chooseWinner is byte-stable across runs because rng is seeded
const winner = chooseWinner(["alice", "bob", "carol"], rng);
```
### Runtime-validate an externally-supplied context object
```ts
import { ContextSchemas } from "@suaveplan/testing/context/context.schemas";
function acceptClock(maybeClock: unknown) {
const clock = ContextSchemas.clock.parse(maybeClock); // throws ZodError on mismatch
return clock.now();
}
```
## API Reference
### `interface Clock`
- `now(): number` — milliseconds since the Unix epoch. May jump backward if system time is adjusted.
- `hrtime(): bigint` — monotonic high-resolution time in nanoseconds. Reference point is implementation-defined; only differences are meaningful.
### `interface Rng`
- `nextUint32(): number` — integer in `[0, 2^32)`.
- `nextFloat(): number` — float in `[0, 1)`.
### `defaultClock: Clock`
Shared instance. Safe to pass around; stateless.
### `cryptoRng: Rng`
Shared instance backed by `globalThis.crypto.getRandomValues`. Throws synchronously at module load if the host runtime lacks Web Crypto — import `@suaveplan/testing/polyfills` via vitest `setupFiles` if running in a bare Node context.
### `seededRng(seed: number): Rng`
Returns a new `Rng` producing a deterministic stream. Same seed → byte-identical output across Bun, Node, and browser targets. Uses Mulberry32 — fast, sound for test use, NOT cryptographically secure.
## Implementation Notes
- **Why Mulberry32?** Small (3 lines of state update), fast (Math.imul-based, JIT-friendly), statistically adequate for test scenarios. We want reproducibility, not cryptographic randomness — `cryptoRng` covers the latter.
- **Why `bigint` for `hrtime`?** Nanosecond timestamps overflow Number precision in ~104 days. `bigint` is safe for the lifetime of any realistic test process.
- **Why throw at module load if Web Crypto is missing?** A missing `crypto.getRandomValues` is an environment-setup defect, not a runtime path. Throwing early surfaces the problem clearly; silently degrading to `Math.random()` would leak pseudo-randomness into code the caller thought was cryptographic.
- **No dependency on `@suaveplan/*` packages.** rule #9: testing-infrastructure packages cannot take workspace deps. The `Clock` / `Rng` shape is intentionally compatible with `@suaveplan/datetime` so a later adapter can bridge.

View File

@@ -0,0 +1,52 @@
# codes
## Purpose
`TESTING_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing` emits. All codes share the `TESTING.` namespace so consumers can filter testing-harness failures out of production log aggregation. The registry is hand-built (not via `defineCodes`) because this package has zero `@suaveplan/*` dependencies — adding one would break rule #8 in `CLAUDE.md`.
## Features
- Frozen record keyed by reason (e.g. `"CLOCK.MAX_TIMERS_EXCEEDED"`, `"FIXTURES.SETUP_FAILED"`)
- `TestingCode` type alias for exhaustive-check support in consumer `switch` statements
- Stable — additions append; removals require a deprecation cycle
- Structural twin of `defineCodes` output — drop-in replacement if the dep rule relaxes
## Usage: Basic Example
```ts
import { TESTING_ERROR_CODES } from "@suaveplan/testing";
TESTING_ERROR_CODES.FACTORY_BUILD_FAILED; // "TESTING.FACTORY.BUILD_FAILED"
```
## Usage: Advanced Example
```ts
import {
TESTING_ERROR_CODES,
TestingError,
type TestingCode,
} from "@suaveplan/testing";
function shouldRetry(err: TestingError): boolean {
const retryable: readonly TestingCode[] = [
TESTING_ERROR_CODES.FIXTURES_SETUP_FAILED,
TESTING_ERROR_CODES.WAIT_TIMEOUT,
];
return retryable.includes(err.code);
}
```
## API Reference
### `TESTING_ERROR_CODES`
Frozen object; every value is a dot-separated code string starting with `TESTING.`.
### `type TestingCode`
Union of every `TESTING_ERROR_CODES[K]` value.
## Implementation Notes
Every throw site in the package constructs a `TestingError` subclass (or one of the domain-specific subclasses like `FixturesError`, `FactoryError`, `WaitTimeoutError`) with a code drawn from this registry. Codes are never renumbered — adding a new reason always appends to the record, which keeps downstream consumer `switch` blocks forward-compatible (a newly added code surfaces as a TypeScript exhaustiveness error, which is the intended review prompt). The registry is intentionally flat; subdomains use dotted reasons (`"FACTORY.BUILD_FAILED"`, `"FIXTURES.SETUP_FAILED"`) rather than nested objects so every lookup is a one-property read.

View File

@@ -0,0 +1,128 @@
# Errors — structured error hierarchy
## Purpose
Every error thrown from `@suaveplan/testing` is a `TestingError` or a domain-specific subclass (`ClockError`, `FixturesError`, `FactoryError`, `StoreError`, `HelpersError`, `BuilderError`, `ConfigError`, `AbortedError`). All carry a stable ASCII `code` of the form `TESTING.<DOMAIN>.<REASON>`, a developer-facing English `message`, an optional `cause` preserving the original thrown value, and optional structured `context`. Callers branch on `err.code` — never parse `err.message`, which is not the machine contract.
Shape is intentionally compatible with the future `@suaveplan/error` package. When `@suaveplan/error` lands on a later layer, a thin adapter can bridge the two without requiring any change to `@suaveplan/testing`'s public surface. Until then, this hierarchy is the local truth.
## Features
- `TestingError` base class with `{ code, cause?, context? }` fields
- Per-domain subclasses preserving `instanceof` semantics through the transpile pipeline
- `AbortedError` for any iterative helper whose `AbortSignal` fires
- Stable `TESTING_ERROR_CODES` dictionary enumerating every code this package emits
- `TESTING_ERROR_CODE_PATTERN` regex for validating caller-supplied codes in downstream adapters
- Peer `testing-error.schemas.ts` exports Zod shape validation for runtime error-object checks (opt-in, not pulled into the functional bundle)
## Usage
### Basic example — catch and branch on code
```ts
import {
FakeClock,
TestingError,
TESTING_ERROR_CODES,
} from "@suaveplan/testing";
const clock = new FakeClock();
try {
for (let i = 0; i < 100_000; i++) clock.setTimeout(() => {}, 1);
} catch (err) {
if (
err instanceof TestingError &&
err.code === TESTING_ERROR_CODES.CLOCK_MAX_TIMERS_EXCEEDED
) {
// Handle deterministically.
} else {
throw err;
}
}
```
### Advanced example — narrow by subclass, read context
```ts
import { ClockError, FixturesError, type TestingErrorCode } from "@suaveplan/testing";
function classify(err: unknown): TestingErrorCode | "UNKNOWN" {
if (err instanceof ClockError) return err.code;
if (err instanceof FixturesError) {
const fixtureName = err.context?.fixtureName;
console.warn(`Fixture ${String(fixtureName)} failed:`, err.cause);
return err.code;
}
return "UNKNOWN";
}
```
### Throwing from your own helper
```ts
import { HelpersError } from "@suaveplan/testing";
function requirePositive(n: number): number {
if (n <= 0) {
throw new HelpersError(
"TESTING.HELPERS.ASSERTION_FAILED",
`Expected a positive number, got ${n}`,
{ context: { received: n } }
);
}
return n;
}
```
## API Reference
### `class TestingError extends Error`
Base class.
- `code: TestingErrorCode` — stable ASCII code (see `TESTING_ERROR_CODES`)
- `message: string` — developer-facing text
- `cause?: unknown` — the triggering error, preserved as-is
- `context?: Record<string, unknown>` — structured diagnostics
Constructor signature:
```ts
new TestingError(code: TestingErrorCode, message: string, options?: TestingErrorOptions)
```
### Domain subclasses
Each extends `TestingError` and sets a more specific `name`:
- `ClockError` — `FakeClock` errors
- `FixturesError` — `FixtureManager` errors
- `FactoryError` — `Factory` errors
- `StoreError` — `MemoryStore` errors
- `HelpersError` — `assertThrows`, `assertThrowsAsync`, `waitFor` errors
- `BuilderError` — `RequestBuilder` / `ResponseBuilder` state errors
- `ConfigError` — `createTestConfig` errors
- `AbortedError` — `TESTING.ABORTED` only; thrown by any iterative helper on abort
### `TESTING_ERROR_CODES`
Frozen record. Keys are SCREAMING_SNAKE_CASE; values are the ASCII codes.
### `TESTING_ERROR_CODE_PATTERN`
```ts
/^TESTING\.[A-Z]+\.[A-Z_]+$|^TESTING\.ABORTED$/
```
### `interface TestingErrorOptions`
- `cause?: unknown`
- `context?: Record<string, unknown>`
## Implementation Notes
- **`Object.setPrototypeOf(this, new.target.prototype)`** in `TestingError`'s constructor preserves `instanceof` after transpile to older targets. Required when the error class is subclassed and thrown across bundle boundaries.
- **`cause` is `unknown`, not `Error`.** Callers may throw non-Error values (strings, plain objects). Preserve them as-is rather than coercing.
- **Messages stay English.** No i18n in `@suaveplan/testing` (rule parallels utils D10 on main). Messages are for developers reading CI logs; user-facing translation happens at the consuming application's presentation layer, keyed on `code`.
- **Why per-domain subclasses?** TypeScript narrows on `instanceof ClockError` cleanly, so code branching on subclass + code combo stays ergonomic. All subclasses still satisfy `instanceof TestingError`.
- **Code pattern.** Regex is permissive for forward compatibility — future domains will slot in without changing the pattern.

View File

@@ -0,0 +1,156 @@
# Factory — deterministic object factory for tests
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Factory<T>` produces test entities from a declarative `FactoryDefinition`: static or index-computed defaults, named traits that override defaults, caller overrides that beat traits, and an optional `afterCreate` hook for post-processing. A single call is `build(options?)`; bulk creation is `buildBatch(count, options?)` which captures per-item failures without aborting the whole batch. Both honor an injected `Rng` for reproducibility — pass `seededRng(42)` and get byte-stable output across runs.
Designed for fixture harnesses where you need hundreds of entities with varied attributes. The sequence-index default (`(index, rng) => …`) lets you produce `user-0`, `user-1`, `user-2` without coordinating counters. Traits keep the common variants named and discoverable. The `afterCreate` hook is the escape hatch for cross-field invariants.
## Features
- `defineFactory(def)` / `new Factory(def)` — equivalent entry points
- Defaults can be static values or `(index, rng) => value` functions
- Named traits merge on top of defaults; applied in the order listed
- Caller overrides merge on top of traits; highest precedence
- `afterCreate(entity, index)` for cross-field transforms
- `buildBatch(count, options)` accumulates failures into `BatchBuildResult.failed` without throwing
- `AbortSignal` checked every 256 items during batch builds; throws `AbortedError` on abort
- `FactoryError("TESTING.FACTORY.TRAIT_NOT_FOUND")` when a trait name doesn't resolve
- `FactoryError("TESTING.FACTORY.BUILD_FAILED")` when a default fn, afterCreate, or batch input throws
- Deterministic: same `rng` + same definition + same index → same output
- Zod schemas in peer `factory.schemas.ts` for opt-in runtime validation
## Usage
### Basic example — named traits and index-computed defaults
```ts
import { defineFactory, seededRng } from "@suaveplan/testing";
interface User {
id: string;
name: string;
role: "user" | "admin";
}
const userFactory = defineFactory<User>({
defaults: {
id: (i) => `u-${i}`,
name: "Anonymous",
role: "user",
},
traits: {
admin: { role: "admin", name: "Admin" },
},
});
const alice = userFactory.build({ overrides: { name: "Alice" } });
const bob = userFactory.build({ traits: ["admin"] });
// alice.id === "u-0", alice.name === "Alice"
// bob.id === "u-1", bob.role === "admin", bob.name === "Admin"
```
### Advanced example — batch with abort and deterministic RNG
```ts
import { defineFactory, seededRng } from "@suaveplan/testing";
interface Event {
id: string;
timestamp: number;
payload: Record<string, unknown>;
}
const events = defineFactory<Event>({
defaults: {
id: (i) => `e-${i}`,
timestamp: (i) => i * 1000,
payload: () => ({ source: "test" }),
},
afterCreate: (event, i) => ({
...event,
payload: { ...event.payload, seq: i },
}),
});
const controller = new AbortController();
setTimeout(() => controller.abort(new Error("enough")), 50);
try {
const batch = events.buildBatch(100_000, {
rng: seededRng(42),
signal: controller.signal,
});
console.log(batch.items.length, batch.failed.length);
} catch (err) {
// AbortedError thrown if the 256-item check trips before the batch completes.
}
```
### Trait composition and overrides
```ts
const mixed = userFactory.build({
traits: ["admin"],
overrides: { name: "Override Wins" },
});
// mixed.role === "admin", mixed.name === "Override Wins"
```
## API Reference
### `defineFactory<T>(definition: FactoryDefinition<T>): Factory<T>`
Factory factory. Returns a new `Factory<T>`. Functionally equivalent to `new Factory(definition)`.
### `class Factory<T>`
#### Methods
- `build(options?: FactoryBuildOptions<T>): T`
- `buildBatch(count: number, options?: FactoryBuildOptions<T>): BatchBuildResult<T>`
- `reset(index?: number): void` — reset the internal sequence counter; defaults to `0`
#### Getters
- `sequenceIndex: number` — next index that will be used if `options.index` is omitted
### `interface FactoryDefinition<T>`
- `defaults: FactoryDefaults<T>` — per-key static value or `(index, rng) => value`
- `traits?: Record<string, Partial<T>>` — named overrides
- `afterCreate?: (entity: T, index: number) => T` — post-processing hook
### `interface FactoryBuildOptions<T>`
- `overrides?: Partial<T>`
- `traits?: readonly string[]` — applied in order
- `index?: number` — override the factory's counter
- `rng?: Rng` — defaults to `cryptoRng`
- `signal?: AbortSignal` — checked during `buildBatch`
### `interface BatchBuildResult<T>`
- `items: readonly T[]` — successful builds
- `failed: readonly BatchBuildFailure[]` — `{ index, error }` per failure
### Error codes thrown
| Code | Condition |
|---|---|
| `TESTING.FACTORY.TRAIT_NOT_FOUND` | `options.traits` references an undeclared name |
| `TESTING.FACTORY.BUILD_FAILED` | A default fn or `afterCreate` throws, or `count < 0` / non-integer in `buildBatch` |
| `TESTING.ABORTED` | `buildBatch` sees `signal.aborted === true` |
## Implementation Notes
- **Sequence counter** advances only when `options.index` is omitted. Explicit-index calls don't bump the counter, so interleaving explicit and implicit builds is predictable.
- **Trait application** is left-fold — `traits: ["a", "b"]` applies `a`, then `b` on top. Overrides then beat both. Matches JavaScript spread semantics.
- **`afterCreate` errors** are wrapped in `FactoryError` with `cause` preserved, so debugging doesn't lose the underlying stack.
- **Per-item failure capture** in `buildBatch` — a bad default for item 27 doesn't prevent items 28..N from being built. Non-default errors (abort) still short-circuit. Matches the "capture failures, keep progressing" pattern from the enterprise spec.
- **Abort check every 256 items** is deliberate — fine-grained enough that ~millisecond abort response is plausible, coarse enough that batches of < 256 items have zero abort-check overhead.
- **`cryptoRng` is the default `rng`.** Non-deterministic by design; pass `seededRng` when you need reproducibility. Both satisfy the `Rng` interface.
- **`FactoryDefaults<T>`** is a mapped type allowing each key to be either a static value or a factory fn. TypeScript infers the `T` shape from the defaults automatically.

View File

@@ -0,0 +1,97 @@
# fixture-manager-internals
## Purpose
Houses the internal algorithmic helpers for `FixtureManager` so that the public-facing class file stays under the 300-line hard limit while each concern remains self-contained and readable. All exports are package-internal and not part of the `@suaveplan/testing` public API.
The module groups four distinct concerns: topological sort for dependency resolution, plugin hook dispatch with error isolation, readonly state projection, and error wrapping. Keeping these separate from the public `FixtureManager` class makes them individually inspectable and unit-testable without instantiating the manager.
## Features
- **Kahn-style DFS topological sort** (`resolveOrder`) with cycle detection and missing-dependency reporting, throwing structured `FixturesError` for both failure modes
- **Plugin hook dispatch** (`runPluginHooks`) with isolated per-plugin error handling: exceptions inside hooks are logged to `console.error` and never interrupt the fixture pipeline
- **Readonly state projection** (`readonlyState`) converts the mutable internal state record to the public `FixtureState` snapshot type
- **Error wrapping** (`wrapFailure`) normalises any thrown value — `Error`, string, or other — into a structured `FixturesError` with fixture name and phase context
- **Abort guard** (`assertNotAborted`) checks `AbortSignal.aborted` and throws a structured `AbortedError` so callers see cooperative cancellation errors, not raw `DOMException`
## Usage: Basic Example
These helpers are consumed internally by `FixtureManager`. Direct use:
```ts
import { resolveOrder } from "./fixture-manager-internals.js";
const fixtures = new Map([
["db", { name: "db", dependencies: [], setup: async () => {} }],
["server", { name: "server", dependencies: ["db"], setup: async () => {} }],
]);
const order = resolveOrder(fixtures);
// ["db", "server"] — db before server
```
## Usage: Advanced Example
```ts
import { runPluginHooks, wrapFailure, assertNotAborted } from "./fixture-manager-internals.js";
import { TESTING_ERROR_CODES } from "../../errors/codes.js";
const plugins = [{ name: "logger", beforeEach: async ({ fixtureName }) => {
console.log(`Setting up: ${fixtureName}`);
}}];
// Dispatch a hook
await runPluginHooks(plugins, "beforeEach", {
fixtureName: "db",
state: { name: "db", value: null, isSetup: false, setupCount: 0, teardownCount: 0 },
phase: "setup",
});
// Wrap an error from a failing fixture
try {
throw new Error("connection refused");
} catch (err) {
const wrapped = wrapFailure(err, TESTING_ERROR_CODES.FIXTURES_SETUP_FAILED, "db", "setup");
// wrapped is a FixturesError with code, message, and context
}
// Check for abort before processing next fixture
const controller = new AbortController();
controller.abort();
assertNotAborted(controller.signal); // throws AbortedError
```
## API Reference
### `resolveOrder(fixtures): string[]`
Performs a DFS topological sort of the fixture dependency graph. Returns fixture names in setup order (all dependencies before their dependents).
- **Throws** `FixturesError(FIXTURES_DEPENDENCY_CYCLE)` when a cycle is detected
- **Throws** `FixturesError(FIXTURES_DEPENDENCY_MISSING)` when a dependency is not registered
### `runPluginHooks(plugins, hook, ctx): Promise<void>`
Iterates `plugins` and calls `plugin[hook](ctx)` on each. Errors from individual plugins are caught and logged to `console.error`; they do not propagate. The `hook` parameter is one of `"beforeEach"`, `"afterEach"`, or `"onError"`.
### `readonlyState(state): FixtureState`
Projects the mutable `MutableFixtureState` record to the public `FixtureState` snapshot type. Creates a new object each call — callers cannot mutate the internal state via the returned snapshot.
### `wrapFailure(err, code, name, phase): FixturesError`
Normalises any thrown value into a `FixturesError`. If `err` is already a `FixturesError` it is returned as-is. Otherwise `err.message` (or `String(err)`) is embedded in the error message alongside `name` and `phase`.
### `assertNotAborted(signal): void`
Checks `signal?.aborted`. If true, throws `AbortedError` with `signal.reason` as the cause. Safe to call with `undefined` (no-op).
### `MutableFixtureState` (interface)
Internal state record mutated in-place during setup/teardown. Fields: `name`, `value`, `isSetup`, `setupCount`, `teardownCount`. Exported only for use by `FixtureManager`.
## Implementation Notes
`resolveOrder` uses DFS with a `visiting` set to detect back-edges (cycles). The `visited` set prevents re-processing nodes in multi-dependency diamonds. The path accumulator (spread on each recursive call) provides the full cycle path for error messages without requiring a parent pointer or stack unwinding.
`runPluginHooks` intentionally swallows plugin errors rather than collecting them, because plugin failures are observability concerns (logging, metrics) that should not interfere with the fixture lifecycle the test author controls.

View File

@@ -0,0 +1,146 @@
# FixtureManager — fixture orchestration with dependencies, plugins, and cancellation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FixtureManager` coordinates the setup and teardown of test fixtures. Each fixture declares a name, a setup function, an optional teardown, and optional dependencies on other fixtures. The manager resolves dependencies via topological sort, runs setup in order, teardown in reverse, and captures per-fixture failures without short-circuiting the rest of the run. Lifecycle plugins observe (but don't control) the pipeline via `beforeEach`, `afterEach`, and `onError` hooks.
Cooperative cancellation via `AbortSignal` fires between fixtures — never mid-fixture. A teardown that's already running will complete; the next one won't start. Errors inside fixtures surface as `FixturesError` with `cause` preserved and `context.fixtureName` set, so debugging keeps the causal chain intact.
## Features
- `register(definition)` — add a fixture; duplicate names / overflow throw
- `registerPlugin(plugin)` — add a lifecycle observer
- `setupAll(options?)` / `teardownAll(options?)` — run the full pipeline
- Dependency resolution via Kahn-style topo sort; cycles throw `TESTING.FIXTURES.DEPENDENCY_CYCLE`
- Missing-dep detection at resolution time, not setup time
- Plugin `beforeEach` / `afterEach` fire around each fixture; `onError` fires on fixture failure
- Plugin hooks that throw are logged and swallowed — they never interrupt the fixture pipeline
- `AbortSignal` checked between fixtures
- `MAX_FIXTURES` (100) prevents runaway registration
- Per-fixture `setupCount` / `teardownCount` counters for idempotency assertions
- Failures captured into `SetupAllResult.failed` / `TeardownAllResult.failed`, not thrown — one bad fixture doesn't hide the others
- Zod schemas in peer `fixture-manager.schemas.ts`
## Usage
### Basic example — two fixtures, one depends on the other
```ts
import { FixtureManager } from "@suaveplan/testing";
const mgr = new FixtureManager();
mgr.register({
name: "database",
setup: async () => ({ dsn: "postgres://..." }),
teardown: async () => {},
});
mgr.register({
name: "app",
dependencies: ["database"],
setup: async () => ({ started: true }),
teardown: async () => {},
});
const result = await mgr.setupAll();
// result.successful === ["database", "app"]
// result.failed === []
await mgr.teardownAll();
```
### Advanced example — plugin observes each fixture + abort mid-run
```ts
import {
FixtureManager,
type FixtureManagerPlugin,
} from "@suaveplan/testing";
const plugin: FixtureManagerPlugin = {
name: "timing",
async beforeEach(ctx) {
console.log(`→ ${ctx.phase} ${ctx.fixtureName}`);
},
async afterEach(ctx) {
console.log(`✓ ${ctx.phase} ${ctx.fixtureName}`);
},
async onError(ctx) {
console.error(`✗ ${ctx.phase} ${ctx.fixtureName}:`, ctx.error.code);
},
};
const mgr = new FixtureManager();
mgr.registerPlugin(plugin);
mgr.register({
name: "slow",
setup: async () => new Promise((r) => setTimeout(r, 1_000)),
});
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(new Error("done")), 100);
try {
await mgr.setupAll({ signal: ctrl.signal });
} catch (err) {
// AbortedError if the signal fires between fixtures.
}
```
## API Reference
### `class FixtureManager`
#### Methods
- `register<T>(fixture: FixtureDefinition<T>): void`
- `registerPlugin(plugin: FixtureManagerPlugin): void`
- `get<T = unknown>(name: string): FixtureState<T> | undefined` — pass `<T>` to recover the value type used at registration; defaults to `unknown` for untyped callers
- `setupAll(options?: { signal? }): Promise<SetupAllResult>`
- `teardownAll(options?: { signal? }): Promise<TeardownAllResult>`
#### Getters
- `names: readonly string[]`
### `interface FixtureDefinition<T>`
- `name: string`
- `setup: () => T | Promise<T>`
- `teardown?: (value: T) => void | Promise<void>`
- `dependencies?: readonly string[]`
### `interface FixtureManagerPlugin`
- `name: string`
- `beforeEach?({ fixtureName, state, phase })`
- `afterEach?({ fixtureName, state, phase })`
- `onError?({ fixtureName, phase, error })`
### `interface SetupAllResult` / `TeardownAllResult`
- `successful: readonly string[]`
- `failed: readonly FixtureFailure[]`
### Error codes thrown
| Code | Condition |
|---|---|
| `TESTING.FIXTURES.MAX_FIXTURES_EXCEEDED` | `register()` beyond `MAX_FIXTURES` |
| `TESTING.FIXTURES.SETUP_FAILED` | duplicate registration, or fixture setup threw (wrapped) |
| `TESTING.FIXTURES.TEARDOWN_FAILED` | fixture teardown threw (wrapped) |
| `TESTING.FIXTURES.DEPENDENCY_CYCLE` | graph contains a cycle |
| `TESTING.FIXTURES.DEPENDENCY_MISSING` | a dep name isn't registered |
| `TESTING.ABORTED` | `signal.aborted === true` between fixtures |
## Implementation Notes
- **Dependency resolution is upfront.** `setupAll` calls `resolveOrder()` first, so cycles and missing deps surface before any setup runs. Teardown uses the reversed order from the same resolution.
- **Failure capture over short-circuit.** A broken fixture does not stop the loop — the manager records the failure in `SetupAllResult.failed` and continues with the next fixture. Rationale: a full suite run surfaces every broken fixture at once, not one per iteration.
- **Plugin hook errors are swallowed** (with a `console.error` trace). A broken plugin must not corrupt the fixture pipeline; plugins are observability surfaces, not control-flow participants.
- **Abort boundary is between fixtures.** A running setup/teardown completes; the next fixture doesn't start. This keeps teardown safe — you don't want an abort to leave a half-torn-down resource on the floor.
- **`setupCount` / `teardownCount`** are per-fixture monotonic counters. Useful for tests that want to assert "teardown happened exactly once" after an unusual path.
- **`MAX_FIXTURES = 100`** is a runaway-registration guard, not a hard cap — if a suite legitimately needs more, split into multiple `FixtureManager` instances. Typical fixture counts are < 20.

View File

@@ -0,0 +1,147 @@
# Test helpers — async assertions, waiting, deferring, timing, ids
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/testing/helpers` ships the small pile of cross-cutting async helpers every test eventually wants: `waitFor` for polling, `sleep` for explicit waits, `deferred` for hand-coordinating two pieces of async code, `assertThrows` / `assertThrowsAsync` for narrowing thrown errors, `measureTime` for ad-hoc timing, `createCollector` for accumulating values across callbacks, and `uniqueTestId` / `resetTestIdCounter` for short stable identifiers. Every helper that reads time goes through an injected `Clock` so tests stay deterministic when paired with `FakeClock`. Every iterative helper accepts an `AbortSignal` and surfaces aborts as `AbortedError`.
These are first-class members of the public surface — many test packages will use only the helpers and never touch `FixtureManager` or `Factory`. Keep the API stable.
## Features
- `assertThrows(fn, matcher?)` / `assertThrowsAsync(fn, matcher?)` — `matcher` accepts string, RegExp, ErrorConstructor, or predicate
- `waitFor(predicate, options?)` — polls every `intervalMs`, fails at `timeoutMs`, honors `signal`, uses injected `clock`
- `sleep(ms)` — wrapped real-`setTimeout` Promise
- `deferred<T>()` — `{ promise, resolve, reject }` for hand-coordinating producers and consumers
- `createCollector<T>()` — `{ push, items, clear, size }` for accumulating values from callbacks
- `measureTime(fn)` — `{ result, elapsedMs }` using `performance.now` when available
- `uniqueTestId(prefix?)` / `resetTestIdCounter()` — monotonic ids
- All errors thrown are `HelpersError` (subclass of `TestingError`) with stable codes
- Zod schema for `WaitForOptions` in peer `test-helpers.schemas.ts`
## Usage
### Basic example — wait for an async condition
```ts
import { waitFor } from "@suaveplan/testing";
let ready = false;
setTimeout(() => { ready = true; }, 100);
await waitFor(() => ready, { timeoutMs: 1000, intervalMs: 25 });
```
### Advanced example — deterministic wait with FakeClock and abort
```ts
import { FakeClock, waitFor, deferred } from "@suaveplan/testing";
const clock = new FakeClock();
const ctrl = new AbortController();
let cond = false;
const waiter = waitFor(() => cond, {
clock,
timeoutMs: 5000,
intervalMs: 50,
signal: ctrl.signal,
});
clock.advance(100);
cond = true;
clock.runAll();
await waiter;
```
### Assertions
```ts
import { assertThrows, assertThrowsAsync } from "@suaveplan/testing";
assertThrows(() => JSON.parse("not json"), SyntaxError);
assertThrows(() => JSON.parse("not json"), /Unexpected token/);
await assertThrowsAsync(
async () => fetch("https://invalid"),
(err) => err instanceof TypeError
);
```
### Coordinating async code
```ts
import { deferred, createCollector } from "@suaveplan/testing";
const events = createCollector<string>();
const ready = deferred<void>();
emitter.on("event", (name: string) => {
events.push(name);
if (name === "done") ready.resolve();
});
await ready.promise;
expect(events.items()).toContain("done");
```
## API Reference
### `assertThrows<TError extends Error = Error>(fn: () => unknown, matcher?: ErrorMatcher): TError`
Returns the caught error narrowed to `TError`. Throws `HelpersError("TESTING.HELPERS.ASSERTION_FAILED")` if `fn` doesn't throw, or throws something that doesn't match. Callers who pass an `ErrorConstructor` matcher (e.g. `TestingError`) typically pass the same constructor as `<TError>` so they can read subclass fields without a follow-up `instanceof` narrowing:
```ts
import { TestingError } from "@suaveplan/testing/errors";
import { assertThrows } from "@suaveplan/testing/helpers";
const err = assertThrows<TestingError>(() => doIt(), TestingError);
err.code; // ok — typed as TestingError["code"]
```
### `assertThrowsAsync<TError extends Error = Error>(fn: () => Promise<unknown>, matcher?: ErrorMatcher): Promise<TError>`
Async variant. Same `<TError>` contract.
### `waitFor(predicate, options?): Promise<void>`
Options (`WaitForOptions`):
- `timeoutMs?: number` — default `5000`
- `intervalMs?: number` — default `50`
- `clock?: Clock` — default `defaultClock`
- `signal?: AbortSignal`
### `sleep(ms: number): Promise<void>`
### `deferred<T>(): Deferred<T>`
### `createCollector<T>(): Collector<T>`
- `.push(value)` / `.items()` / `.clear()` / `.size`
### `measureTime<T>(fn): Promise<TimingResult<T>>`
### `uniqueTestId(prefix?: string): string` / `resetTestIdCounter(): void`
### `type ErrorMatcher`
`string | RegExp | ErrorConstructor | ((err: unknown) => boolean)`
### Error codes thrown
| Code | Condition |
|---|---|
| `TESTING.HELPERS.ASSERTION_FAILED` | `assertThrows` / `assertThrowsAsync` failure, or `waitFor` predicate threw, or `sleep` got a bad ms value |
| `TESTING.HELPERS.WAIT_TIMEOUT` | `waitFor` exceeded `timeoutMs` |
| `TESTING.ABORTED` | `waitFor` saw `signal.aborted` |
## Implementation Notes
- **`uniqueTestId` is module-scope state.** This is the one helper with intentionally-shared state — ids are unique within a process. Call `resetTestIdCounter()` between test files if you need stable id assertions.
- **`waitFor` polls via `sleep(intervalMs)`** — real wall-clock sleep, not Clock-driven. The `clock` parameter governs timeout detection only. If you want the polling itself to be FakeClock-driven, schedule the predicate-flip via the FakeClock's own timers and `runAll`.
- **`assertThrows` returns the error** rather than throwing assertions piecemeal — callers can chain `expect(err.code).toBe(...)`.
- **`ErrorConstructor` matching** uses `err instanceof Ctor`, which works through the `Object.setPrototypeOf` we install in `TestingError`.
- **`deferred` is a thin wrapper** — exists because `new Promise(...)` doesn't expose resolve/reject by default. Use it for any hand-coordinated async sequence.
- **`measureTime` is async-aware** — handles both sync and async `fn` returns, awaits the result, then takes the end timestamp.

View File

@@ -0,0 +1,77 @@
# Observability — `withObservability`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`withObservability(options, fn)` wraps any public entrypoint in `@suaveplan/testing` with OpenTelemetry counters, histograms, and (optionally) spans. Every invocation records a counter increment, a duration histogram sample, and — on error — an error-counter increment carrying the `TestingError.code` as an attribute. Spans are created only for iterative or long-running operations (`waitFor`, `FixtureManager.setupAll`, `Factory.buildBatch`).
The wrapper uses `@opentelemetry/api` directly — a small, stable public-spec package with zero transitive Node dependencies. When no OTEL SDK is registered (the default in Vitest/bun-test runs), `@opentelemetry/api` returns no-op implementations and the overhead collapses to a single function call plus an object allocation per invocation. Real observability kicks in only in CI runs that register an exporter; no test-source changes required.
## Features
- Counters: `testing.<name>.count`
- Histograms: `testing.<name>.duration_ms` (unit `ms`, `status` attribute `"ok" | "error"`)
- Error counters: `testing.<name>.errors` (`code` attribute from `TestingError.code`, `"UNKNOWN"` otherwise)
- Spans: optional via `options.iterative`
- Sync and async functions supported (return type preserved via generics; Promise-returning functions are detected at call time)
- Zero workspace dependencies — only `@opentelemetry/api` (external, catalog:observability)
## Usage
### Basic example — wrap a sync helper
```ts
import { withObservability } from "@suaveplan/testing/observability";
export const schedule = withObservability(
{ name: "clock.schedule" },
(callback: () => void, delayMs: number): number => {
// ... real implementation
return 0;
}
);
```
### Advanced example — wrap an async iterative helper with a span
```ts
import { withObservability } from "@suaveplan/testing/observability";
import type { WaitForOptions } from "../types/testing.types.js";
export const waitFor = withObservability(
{ name: "helpers.waitFor", iterative: true },
async (
predicate: () => boolean | Promise<boolean>,
options?: WaitForOptions
): Promise<void> => {
// ... polling loop
}
);
```
When a caller later imports `waitFor` and invokes it, the metric
`testing.helpers.waitFor.count` increments, the histogram
`testing.helpers.waitFor.duration_ms` receives one sample per call, and a
span named `testing.helpers.waitFor` opens for the duration of the loop.
## API Reference
### `interface WithObservabilityOptions`
- `name: string` — metric-name suffix (e.g. `"clock.schedule"`). Full metric keys become `testing.<name>.count`, `.duration_ms`, `.errors`.
- `iterative?: boolean` — when `true`, a span is opened around the call. Default `false`.
### `withObservability<TArgs, TReturn>(options, fn): (...args: TArgs) => TReturn`
Wraps `fn` preserving its argument list and return type. If `fn` returns a promise, the wrapper chains onto it; the returned function's signature is unchanged.
## Implementation Notes
- **`performance.now()` preferred** for duration measurement; falls back to `Date.now()` if unavailable. Precision loss on fallback is tolerable for test-helper timing.
- **No-op tracer overhead.** `trace.getTracer()` returns a `NoopTracer` when no SDK is registered; `startActiveSpan` is a thin wrapper that calls the callback synchronously. Measured overhead per call is ~5 ns on Node 22 in preliminary runs — below the 5% benchmark gate requirement.
- **Counter / histogram / error-counter instances are cached** by metric name in module-scope `Map`s so we don't allocate a fresh instrument per wrapped call.
- **Error attribute `"UNKNOWN"`** when the thrown value is not a `TestingError`. This happens only if an internal invariant fails (e.g. a thrown string from a dependency) — surface it rather than lose the signal.
- **Span `status` defaults to OK** and is explicitly set to ERROR on throw. Callers relying on span-status-based alerts get accurate signals.
- **Promise detection** is duck-typed (`typeof result.then === "function"`) to support PromiseLike return types beyond native `Promise`. Matches the shape `await` accepts.

View File

@@ -0,0 +1,62 @@
# runner/bun-test
## Purpose
The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `mock`) under the same stable alias the `runner/vitest` module uses. Together they make `@suaveplan/testing-dom/runner` a dual-runner entry point — consumer test files that import from here run unchanged under either vitest (via `bunx vitest`) or bun-test (via `bun test`).
## Features
- Re-exports the bun-test assertion + lifecycle API
- Uses `bun:test` specifier — resolved at runtime by Bun, excluded from vitest builds
- Conditional export — package.json's `exports` picks this module under the `bun` condition
## Usage: Basic Example
```ts
import { describe, it, expect } from "@suaveplan/testing-dom/runner";
describe("same test file, either runner", () => {
it("asserts", () => {
expect(1 + 1).toBe(2);
});
});
```
## Usage: Advanced Example
```ts
import {
describe,
it,
expect,
beforeAll,
afterAll,
mock,
} from "@suaveplan/testing-dom/runner";
describe("bun-test specific mocks", () => {
let server: { close: () => void };
beforeAll(() => {
server = startHttpStub(3000);
});
afterAll(() => server.close());
it("mocks a module method", () => {
const fetchStub = mock<typeof fetch>(() => Promise.resolve(new Response("ok")));
// ... use fetchStub
expect(fetchStub).toHaveBeenCalled();
});
});
```
## API Reference
Re-exports: `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `mock`.
## Implementation Notes
The module imports from `"bun:test"`, which resolves only in Bun; under vitest it's excluded from the bundle via vite's external-module configuration AND via the package's coverage-exclude list (since the file is never executed under vitest). Mocking APIs differ between the two runners: bun-test's `mock` returns a different shape than vitest's `vi.fn`. Consumers who need both runners' test files to type-check should stick to the lowest-common-denominator API — `describe`, `it`, `expect`, and lifecycle hooks — and branch on `detectRunner()` for anything beyond that.
### Integration
The Bun-test runner shim exposes `describe` / `it` / `beforeEach` / `afterEach` / `expect` bindings that mirror Vitest's API so test files can be written once and run in either runner. The shim installs the jest-dom matchers via `registerMatchers` on module load and sets up happy-dom as the default DOM host when the test file imports from `@suaveplan/testing-dom/runner/bun-test`.

View File

@@ -0,0 +1,57 @@
# runner/mocks
## Purpose
The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under vitest and bun-test. Because `vi.mock` / `mock.module` semantics differ sharply between the two runners (and often fail unpredictably in Bun's ESM resolver), this package prefers DI-style helpers — `makeFn`, `makeClass`, `makePromise` — that let a test substitute values at call-site rather than patching the module graph.
## Features
- Runner-agnostic helpers — no reliance on `vi.mock` / `mock.module`
- `makeFn` returns a call-tracking function with `.calls`, `.mock`, and `.mockReturnValueOnce`
- `makeClass` constructs a no-op subclass with overridable methods
- `makePromise` returns `{ promise, resolve, reject }` for deferred resolution in tests
- Works inside both runners without configuration
## Usage: Basic Example
```ts
import { makeFn } from "@suaveplan/testing-dom/runner";
const greet = makeFn<(name: string) => string>(() => "hi");
greet("alice");
expect(greet.calls).toEqual([["alice"]]);
```
## Usage: Advanced Example
```ts
import { makeFn, makePromise } from "@suaveplan/testing-dom/runner";
import { describe, it, expect } from "@suaveplan/testing-dom/runner";
describe("flaky dependency via DI", () => {
it("coordinates two async callers via a deferred", async () => {
const deferred = makePromise<{ status: 200 }>();
const fetchStub = makeFn<() => Promise<{ status: 200 }>>(
() => deferred.promise
);
const results = [fetchStub(), fetchStub()];
deferred.resolve({ status: 200 });
expect(fetchStub.calls.length).toBe(2);
expect((await Promise.all(results)).every((r) => r.status === 200)).toBe(true);
});
});
```
## API Reference
### `makeFn<TFn>(impl?): TFn & { calls: Parameters<TFn>[]; mockReturnValueOnce(v: ReturnType<TFn>): void }`
### `makeClass<T>(overrides?): new () => T`
### `makePromise<T>(): { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void }`
## Implementation Notes
These helpers intentionally duplicate some of what `vi.fn` offers because the project standard (captured in the `bun-vitest-module-mocking` feedback memory) is to avoid module-level mocks entirely in favour of DI. Module mocks break under Bun's ESM resolver in unpredictable ways, and even under vitest they create implicit coupling between the test and the subject's internal imports. `makeFn` records call history but does not provide `toHaveBeenCalled` — consumers use `fn.calls.length` directly so the assertion works under any runner.

View File

@@ -0,0 +1,93 @@
# Runner subpath
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - vitest — The `runner/vitest` module re-exports vite…
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
- vitest — The `runner/vitest` module re-exports vite…
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
- vitest — The `runner/vitest` module re-exports vite…
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
- vitest — The `runner/vitest` module re-exports vite…
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
## Purpose
`@suaveplan/testing/runner` is the single point of indirection between every package in the project and the underlying test runner. Without it, ~95 packages would each `import { describe, it, expect } from "vitest"`, and swapping or wrapping the runner would require touching every test file. With it, the swap happens in one place.
The subpath ships two implementations of the same surface — `vitest.ts` (the default) and `bun-test.ts` (selected by the `bun` package.json condition). Consumers always write `from "@suaveplan/testing/runner"`; the active runtime decides which file is loaded.
## Features
- Re-exports `describe`, `it`, `test`, `expect`, `expectTypeOf`, `vi`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach` for both vitest and bun-test
- Generic mock helpers: `fn<TArgs, TReturn>`, `spy<T, K>`, `stub<T, K>`, `mock(modulePath, factory?)`
- Timer helpers: `fakeClock()`, `restoreMocks()`, `restoreTimers()`
- Runner-neutral types: `RunnerMock<TArgs, TReturn>`, `SpyHandle`
- `expectTypeOf` shim on the bun-test path (type-level assertions are erased at compile time, so a runtime no-op preserves dual-runner parity)
## Usage: Basic Example
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("sum", () => {
it("adds two numbers", () => {
expect(1 + 2).toBe(3);
});
});
```
## Usage: Advanced Example
```ts
import {
describe,
expect,
expectTypeOf,
fn,
it,
type RunnerMock,
spy,
} from "@suaveplan/testing/runner";
describe("typed mocks", () => {
it("infers call signature on fn", () => {
const onClick: RunnerMock<[MouseEvent], void> = fn();
onClick(new MouseEvent("click"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("pins return type at compile time", () => {
const target = { now: () => 42 };
const handle = spy(target, "now");
expectTypeOf(target.now()).toBeNumber();
handle.mockRestore();
});
});
```
## API Reference
| Symbol | Source | Notes |
|---|---|---|
| `describe`, `it`, `test`, `expect`, `expectTypeOf` | runner | Direct re-export (vitest); shim on bun |
| `vi` | runner | Vitest's `vi`, or `bun:test`'s `jest` namespace under bun |
| `beforeAll`, `beforeEach`, `afterAll`, `afterEach` | runner | Direct re-export |
| `fn<TArgs, TReturn>(impl?)` | mocks | Returns `RunnerMock<TArgs, TReturn>` |
| `spy(obj, method)` | mocks | Returns `SpyHandle` |
| `stub(obj, method, impl?)` | mocks | Returns `SpyHandle`, replaces implementation |
| `mock(modulePath, factory?)` | mocks | Module-level mock; `restore()` for cleanup |
| `fakeClock()` | mocks | Returns `{ advance(ms), restore() }` |
| `restoreMocks()`, `restoreTimers()` | mocks | Reset helpers |
## Implementation Notes
- The `bun` package.json condition routes to `dist/runner/bun-test.js`; every other condition routes through `dist/runner/index.js` → `dist/runner/vitest.js`.
- `expectTypeOf` on the bun path is a `Proxy`-backed no-op chain. All assertions are type-level, so the runtime has nothing to do — it just needs to accept the calls without throwing.
- This package may not depend on any other `@suaveplan/*` runtime package (rule 8). The runner is duplicated in `@suaveplan/testing-dom/runner` for the same reason.

View File

@@ -0,0 +1,71 @@
# runner/vitest
## Purpose
The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, and `vi` under a stable alias. It's part of the dual-runner support in `@suaveplan/testing-dom` — consumers who want their test files to run under both vitest and bun-test can import from `@suaveplan/testing-dom/runner` instead of picking one directly. At type-check time this module statically resolves to vitest's types.
## Features
- Re-exports the vitest assertion + lifecycle API
- Preserves upstream types — TS jumps to vitest's definitions
- Conditional export — package.json's `exports` picks this module under the `vitest` condition
## Usage: Basic Example
```ts
import { describe, it, expect } from "@suaveplan/testing-dom/runner";
describe("my suite", () => {
it("asserts", { timeout: 5000 }, () => {
expect(1 + 1).toBe(2);
});
});
```
## Usage: Advanced Example
```ts
import { describe, it, expect, beforeEach, vi } from "@suaveplan/testing-dom/runner";
describe("mockable dependency", () => {
let clock = 0;
beforeEach(() => {
clock = 0;
});
it("uses fake timers", { timeout: 5000 }, () => {
vi.useFakeTimers();
try {
const inner = vi.fn((n: number) => ++clock);
inner(1);
inner(1);
expect(inner).toHaveBeenCalledTimes(2);
expect(clock).toBe(2);
} finally {
vi.useRealTimers();
}
});
});
```
## API Reference
Re-exports: `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `vi`.
## Implementation Notes
The re-export is unconditional because the module is only reachable via the `vitest` condition in `package.json#exports`. If the condition machinery is bypassed (e.g. the import is resolved manually), vitest must still be available as a peer of the consumer — this module does not install vitest or provide its own assertion library.
### Integration
The Vitest runner shim re-exports `describe` / `it` / `beforeEach` / `afterEach` / `expect` from `vitest` and installs the jest-dom matcher set via `registerMatchers` on module load. Importing this module is sufficient for a Vitest file to use the full DOM-matcher surface — no manual matcher wiring needed.
## Further Usage: Basic Example
```ts
import { it, expect, render } from "@suaveplan/testing-dom/runner/vitest";
it("renders hello", { timeout: 5000 }, () => {
const { getByText } = render(<h1>Hello</h1>);
expect(getByText("Hello")).toBeInTheDocument();
});
```

View File

@@ -0,0 +1,135 @@
# MemoryStore — in-memory TTL key/value store with eviction and events
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryStore<T>` is the minimal cache-ish key → value API tests need to model session stores, fixture-scoped state, or ad-hoc in-memory caches. TTL expiry is driven by an injected `Clock` (so swapping in a `FakeClock` gives you deterministic expiry). Capacity-bounded: when a `set` would push size past `maxEntries`, the oldest entry is evicted first. Every mutation emits a `MemoryStoreEvent` to registered listeners — useful for driving assertions off observed behavior rather than peeking at internal state.
Designed for tests, not production. No persistence, no cross-process replication, no atomic multi-key ops. The `hits`/`misses`/`evictions`/`expirations` counters on `stats` are cumulative lifetime counters useful for assertions like "evict happened exactly once".
## Features
- TTL expiry with injected `Clock` — deterministic when paired with `FakeClock`
- Capacity-bounded with oldest-first eviction (`Map` insertion-order traversal)
- Touch-on-get: successful `get(key)` moves the entry to the tail for LRU-ish behavior
- `getStrict(key)` variant throws `StoreError("TESTING.STORE.KEY_NOT_FOUND")` for missing/expired keys
- Event emission on every `set` / `get` / `delete` / `evict` / `expire`
- Stats counters: `hits`, `misses`, `evictions`, `expirations`, `size`, `maxEntries`
- Construction-time validation via `StoreError("TESTING.STORE.CAPACITY_EXCEEDED")` for `maxEntries > MAX_STORE_ENTRIES` or `<= 0`, or negative/non-finite `defaultTtlMs`
- Listener errors are logged and swallowed — a broken listener can't corrupt the store
- Zero `@suaveplan/*` workspace dependencies; only external catalog deps
## Usage
### Basic example — set, get, delete
```ts
import { MemoryStore } from "@suaveplan/testing";
const store = new MemoryStore<string>();
store.set("user:42", "alice");
store.get("user:42"); // → "alice"
store.has("user:42"); // → true
store.delete("user:42"); // → true
store.get("user:42"); // → undefined
```
### Advanced example — deterministic TTL expiry with FakeClock
```ts
import { FakeClock, MemoryStore } from "@suaveplan/testing";
const clock = new FakeClock();
const store = new MemoryStore<number>({
clock,
defaultTtlMs: 1000,
maxEntries: 2,
});
store.set("a", 1);
clock.advance(500);
store.set("b", 2);
clock.advance(600);
// "a" has now been live for 1100 ms — expired
store.get("a"); // → undefined, emits delete{reason:"expire"}
store.get("b"); // → 2 (still live)
// Capacity-bounded eviction
store.set("c", 3);
store.set("d", 4); // evicts "b" (oldest still present)
store.keys(); // → ["c", "d"]
```
### Event listener
```ts
const store = new MemoryStore<string>();
const events: string[] = [];
const unsubscribe = store.on((e) => {
events.push(e.type);
});
store.set("k", "v");
store.get("k");
unsubscribe();
// events === ["set", "get"]
```
### Strict get
```ts
try {
store.getStrict("missing");
} catch (err) {
// StoreError with code TESTING.STORE.KEY_NOT_FOUND
}
```
## API Reference
### `class MemoryStore<T>`
#### Constructor
```ts
new MemoryStore<T>(options?: MemoryStoreOptions)
```
- `maxEntries?: number` — default `1_000`; must be in `(0, MAX_STORE_ENTRIES]`
- `defaultTtlMs?: number` — default `0` (never expires); must be `>= 0` and finite
- `clock?: Clock` — default `defaultClock`
#### Methods
- `set(key: string, value: T, options?: { ttlMs? }): void`
- `get(key: string): T | undefined` — expires stale entries in-place
- `getStrict(key: string): T` — throws on miss
- `has(key: string): boolean` — expires stale entries in-place
- `delete(key: string): boolean`
- `clear(): void`
- `entry(key: string): StoreEntry<T> | undefined`
- `keys(): string[]`
- `on(listener: MemoryStoreListener<T>): () => void` — returns unsubscribe
#### Getters
- `size: number` — non-expired count
- `stats: MemoryStoreStats`
### Error codes thrown
| Code | Condition |
|---|---|
| `TESTING.STORE.CAPACITY_EXCEEDED` | `maxEntries > MAX_STORE_ENTRIES`, `maxEntries <= 0`, negative/non-finite `defaultTtlMs` or per-call `ttlMs` |
| `TESTING.STORE.KEY_NOT_FOUND` | `getStrict` on a missing or expired key |
## Implementation Notes
- **Map insertion order** is the LRU approximation — cheaper than maintaining a doubly-linked list and good enough for test fixtures. `get(key)` on a present entry re-inserts to move it to the tail.
- **Eviction on overflow** in `set`: loops while `size >= maxEntries` deleting the first (oldest) entry until there's room. Emits a `delete{reason:"evict"}` event per eviction so listeners can count.
- **Expiry is lazy**, not swept. `get` / `has` / `entry` check the expiry on each access and delete stale entries in-place. The `size` getter walks the map; `stats.size` is the raw entry count (including expired-but-not-yet-deleted) for accurate observability.
- **Listener errors are swallowed** (with `console.error`). A broken observer must never corrupt store state.
- **`getStrict` reuses `get`** internally — the strict variant is a convenience, not a different code path. Its error carries `context.key` so callers can log which key failed without re-deriving it.
- **No cross-key atomicity.** Each method operates on a single key. `MemoryStore` is a simple test utility, not a transactional cache.

View File

@@ -0,0 +1,99 @@
# HTTP types — shared request / response shapes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`http.types.ts` defines the five public HTTP types that cross feature boundaries in `@suaveplan/testing`: `HttpMethod`, `HttpHeaders`, `QueryParams`, `TestRequest`, and `TestResponse`. Builders (`RequestBuilder`, `ResponseBuilder`), fixture harnesses, and helpers all consume these types; pinning them in `src/types/` keeps the shape declarations in one place so a signature change fans out predictably.
These are plain TypeScript interfaces. They don't carry runtime validation — the sibling `http.schemas.ts` (when added in a later wave, alongside builders) owns Zod schemas that validate these shapes at runtime for opt-in callers.
## Features
- `HttpMethod` — literal union of the seven methods the builders accept (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`)
- `HttpHeaders` — record of header name → value, case-preserving as written
- `QueryParams` — record of query-name → single value or repeated array
- `TestRequest` — the full shape `RequestBuilder.build()` returns: method, url, headers, query, body, params
- `TestResponse` — the full shape `ResponseBuilder.build()` returns: status, headers, body, latencyMs
- All fields `readonly` so caught references can't mutate a built payload
## Usage
### Basic example — consume a built request
```ts
import type { TestRequest } from "@suaveplan/testing";
function logRequest(req: TestRequest): void {
console.log(`${req.method} ${req.url}`);
}
```
### Advanced example — build a handler that branches on method
```ts
import type { HttpMethod, TestRequest, TestResponse } from "@suaveplan/testing";
function handle(req: TestRequest): TestResponse {
const method: HttpMethod = req.method;
switch (method) {
case "GET":
case "HEAD":
return { status: 200, headers: {}, body: null, latencyMs: 0 };
case "DELETE":
return { status: 204, headers: {}, body: null, latencyMs: 0 };
default:
return {
status: 200,
headers: { "content-type": "application/json" },
body: req.body,
latencyMs: 0,
};
}
}
```
## API Reference
### `type HttpMethod`
```ts
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
```
### `type HttpHeaders`
```ts
type HttpHeaders = Record<string, string>;
```
### `type QueryParams`
```ts
type QueryParams = Record<string, string | string[]>;
```
### `interface TestRequest`
- `method: HttpMethod`
- `url: string`
- `headers: HttpHeaders`
- `query: QueryParams`
- `body: unknown`
- `params: Record<string, string>`
### `interface TestResponse`
- `status: number` — in `[100, 600)`
- `headers: HttpHeaders`
- `body: unknown`
- `latencyMs: number`
## Implementation Notes
- **Why `QueryParams` supports `string[]`.** URL query syntax allows repeated keys (`?x=a&x=b`). A single-string-only type would force callers to do the ambiguous comma-join themselves; accepting an array surfaces the intent explicitly.
- **`body` is `unknown`.** Tests may carry plain objects, strings, FormData, Blob, streams — anything. Narrowing here would leak implementation bias from the builder.
- **Status is a plain `number`, not a branded literal union.** The validation bound (`[100, 600)`) lives in `ResponseBuilder.status()` and the Zod schema, not in the type. This keeps the public surface readable while still catching out-of-range values at construction time via `BuilderError`.
- **No `Cookie` / `Set-Cookie` type.** Callers that need to model cookies put them in `headers` as comma-joined strings, same as the wire format.
- **`params`** is the route-matched path parameter bag (e.g. `/users/:id` matches `{ id: "42" }`). Distinct from `query` so handlers can branch on the source.

View File

@@ -0,0 +1,73 @@
# http.types
## Purpose
Shared HTTP-shape types used by the request builder, response builder, fixture harness, and helper functions inside `@suaveplan/testing`. They live under the flat `src/types/` tree because they cross feature boundaries: `RequestBuilder` and `ResponseBuilder` both consume them, and consumer tests regularly pass `TestRequest` / `TestResponse` values around directly without constructing a builder.
## Features
- Zero runtime cost — all exports are pure TypeScript types
- `HttpMethod` union restricted to the seven verbs the builders support (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`)
- `HttpHeaders` record with comma-joined string values so callers that expect a single header string get one deterministically
- `QueryParams` record allowing either a single `string` or `string[]`, which serialises as repeated `?key=a&key=b` pairs
- `TestRequest` / `TestResponse` readonly interfaces so built values can travel through shared fixture state without accidental mutation
- `TestResponse.latencyMs` models simulated backend latency for fixtures that want slow-backend behaviour without actually sleeping
## Usage: Basic Example
```ts
import type { HttpMethod, TestRequest } from "@suaveplan/testing/types/http.types";
const method: HttpMethod = "POST";
const request: TestRequest = {
method,
url: "/api/orders",
headers: { "content-type": "application/json" },
query: {},
body: { sku: "ABC", qty: 2 },
params: { tenant: "acme" },
};
```
## Usage: Advanced Example
```ts
import type {
HttpHeaders,
QueryParams,
TestRequest,
TestResponse,
} from "@suaveplan/testing/types/http.types";
function handle(req: TestRequest): TestResponse {
const headers: HttpHeaders = {
"x-request-id": "req_1",
"content-type": "application/json",
};
const query: QueryParams = { tags: ["ci", "staging"], region: "us-east-1" };
const body = { ok: true, echoedQuery: query };
return { status: 200, headers, body, latencyMs: 25 };
}
const response = handle({
method: "GET",
url: "/health",
headers: {},
query: { tags: ["ci"] },
body: null,
params: {},
});
```
## API Reference
- `HttpMethod` — `"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"`.
- `HttpHeaders` — `Record<string, string>` with comma-joined multi-values.
- `QueryParams` — `Record<string, string | string[]>`.
- `TestRequest` — fully-formed readonly request produced by `RequestBuilder.build`.
- `TestResponse` — fully-formed readonly response produced by `ResponseBuilder.build`, including `status`, `headers`, `body`, and `latencyMs`.
## Implementation Notes
Headers are modelled as `Record<string, string>` rather than a multi-map because the testing layer targets assertion ergonomics, not wire-format perfect round-tripping. Callers that need multi-valued header semantics should comma-join at construction time and split at assertion time, matching the behaviour of `Headers.get()` in the Fetch spec. The `readonly` modifier on every field of `TestRequest` and `TestResponse` prevents tests from mutating shared fixture values mid-assertion, which was a source of flake in an earlier iteration of the package.