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

29
docs/testkit/README.md Normal file
View File

@@ -0,0 +1,29 @@
# `testkit`
Testing frameworks, A/B testing, and quality-assurance utilities.
<!-- begin:auto-docs -->
## Subcategories
1 subcategory, 13 packages.
### `testing`
Testing runners, DOM helpers, E2E harnesses, and A/B testing.
| Package | Description |
|---|---|
| [`@suaveplan/ab-testing`](./ab-testing/README.md) | Enterprise A/B testing and experimentation engine with deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, result aggrega… |
| [`@suaveplan/testing`](./testing/README.md) | Shared test infrastructure for Suaveplan. Provides runner primitives (Vitest or bun-test via conditional exports), structured builders, a deterministic FakeClo… |
| [`@suaveplan/testing-dom`](./testing-dom/README.md) | DOM and React component testing utilities for Suaveplan. Provides a single install that covers vitest config generation with WSL2-safe defaults, `@testing-libr… |
| [`@suaveplan/testing-e2e`](./testing-e2e/README.md) | Playwright E2E testing utilities for Suaveplan, with the default `browser` worker fixture launching `cloakbrowser` — a real Chromium binary with 49 source-leve… |
| [`@suaveplan/testing-integration`](./testing-integration/README.md) | Core integration test infrastructure for Vitest: fixture lifecycle management, health polling, and the global-setup bridge. |
| [`@suaveplan/testing-integration-garage-s3`](./testing-integration-garage-s3/README.md) | Garage S3 v2.0.0 testcontainers fixture for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-mailpit`](./testing-integration-mailpit/README.md) | Mailpit testcontainers fixture + `assertMail()` helper for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-oauth-mock`](./testing-integration-oauth-mock/README.md) | Real-HTTP mock OAuth 2.0 Identity Provider for integration tests. |
| [`@suaveplan/testing-integration-postgres`](./testing-integration-postgres/README.md) | PostgreSQL 16 testcontainers fixture for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-redis`](./testing-integration-redis/README.md) | Redis 7 testcontainers fixture for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-step-ca`](./testing-integration-step-ca/README.md) | A `ServiceFixture` for `smallstep/step-ca` that gives any integration test suite a real, ephemeral certificate authority — root cert, JWK provisioner, and an `… |
| [`@suaveplan/testing-integration-surrealdb`](./testing-integration-surrealdb/README.md) | SurrealDB v3 testcontainers fixture for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-typesense`](./testing-integration-typesense/README.md) | Typesense 27 testcontainers fixture for `@suaveplan/testing-integration`. |
<!-- end:auto-docs -->

3
docs/testkit/_intro.md Normal file
View File

@@ -0,0 +1,3 @@
# `testkit`
Testing frameworks, A/B testing, and quality-assurance utilities.

View File

@@ -0,0 +1,25 @@
# `testing`
Testing runners, DOM helpers, E2E harnesses, and A/B testing.
<!-- begin:auto-docs -->
## Packages in `testing`
13 packages.
| Package | Description |
|---|---|
| [`@suaveplan/ab-testing`](./ab-testing/README.md) | Enterprise A/B testing and experimentation engine with deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, result aggrega… |
| [`@suaveplan/testing`](./testing/README.md) | Shared test infrastructure for Suaveplan. Provides runner primitives (Vitest or bun-test via conditional exports), structured builders, a deterministic FakeClo… |
| [`@suaveplan/testing-dom`](./testing-dom/README.md) | DOM and React component testing utilities for Suaveplan. Provides a single install that covers vitest config generation with WSL2-safe defaults, `@testing-libr… |
| [`@suaveplan/testing-e2e`](./testing-e2e/README.md) | Playwright E2E testing utilities for Suaveplan, with the default `browser` worker fixture launching `cloakbrowser` — a real Chromium binary with 49 source-leve… |
| [`@suaveplan/testing-integration`](./testing-integration/README.md) | Core integration test infrastructure for Vitest: fixture lifecycle management, health polling, and the global-setup bridge. |
| [`@suaveplan/testing-integration-garage-s3`](./testing-integration-garage-s3/README.md) | Garage S3 v2.0.0 testcontainers fixture for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-mailpit`](./testing-integration-mailpit/README.md) | Mailpit testcontainers fixture + `assertMail()` helper for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-oauth-mock`](./testing-integration-oauth-mock/README.md) | Real-HTTP mock OAuth 2.0 Identity Provider for integration tests. |
| [`@suaveplan/testing-integration-postgres`](./testing-integration-postgres/README.md) | PostgreSQL 16 testcontainers fixture for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-redis`](./testing-integration-redis/README.md) | Redis 7 testcontainers fixture for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-step-ca`](./testing-integration-step-ca/README.md) | A `ServiceFixture` for `smallstep/step-ca` that gives any integration test suite a real, ephemeral certificate authority — root cert, JWK provisioner, and an `… |
| [`@suaveplan/testing-integration-surrealdb`](./testing-integration-surrealdb/README.md) | SurrealDB v3 testcontainers fixture for `@suaveplan/testing-integration`. |
| [`@suaveplan/testing-integration-typesense`](./testing-integration-typesense/README.md) | Typesense 27 testcontainers fixture for `@suaveplan/testing-integration`. |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Testing runners, DOM helpers, E2E harnesses, and A/B testing.

View File

@@ -0,0 +1,103 @@
# @suaveplan/ab-testing - Enterprise A/B testing and experimentation: deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, and OTel instrumentation
## Summary
Enterprise A/B testing and experimentation engine with deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, result aggregation, and OpenTelemetry instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
<!-- end:auto-docs --> - [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ - [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_ - [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_ - [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
## Features
- **Deterministic bucketing** — FNV-1a 32-bit hash maps `(userId, experimentId)` to a stable variant with configurable seed
- **Targeting rules** — eight operators (`eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`) evaluated with AND logic
- **Sample rate** — fraction-based inclusion gate via injectable RNG for deterministic testing
- **Assignment caching** — repeat `assign()` calls for the same user always return the same variant
- **Conversion tracking** — named metric events with numeric values, aggregated per variant
- **Provider API** — plug in external platforms via `ExperimentProvider` for forced assignments and event forwarding
- **OTel instrumentation** — `ab.assign` span, `ab.assignments_total` counter, `ab.conversions_total` counter
- **Event bus** — emits `ab:assigned` and `ab:conversion` events on an injectable `EventBusLike`
- **Zod validation** — all experiment definitions and metric events are validated at the boundary
- **Injectable dependencies** — `clock`, `rng`, `logger`, `eventBus`, `hashSeed` for deterministic testing
## Installation
```bash
bun add @suaveplan/ab-testing
```
## Quick Start
```ts
import { createExperimentManager } from '@suaveplan/ab-testing';
const manager = createExperimentManager();
manager.defineExperiment({
id: 'checkout-cta',
name: 'Checkout CTA Colour',
status: 'running',
variants: [
{ id: 'ctrl', name: 'Blue', weight: 1, isControl: true },
{ id: 'treat', name: 'Green', weight: 1 },
],
primaryMetric: 'checkout_started',
});
const assignment = manager.assign('checkout-cta', 'user-123');
if (assignment.inExperiment) {
console.log(`Variant: ${assignment.variantId}`);
}
// Track a conversion
manager.track('checkout-cta', 'user-123', { name: 'checkout_started' });
// Read results
const results = manager.getResults('checkout-cta');
console.log(`Conversion rate: ${results.variants[0]?.conversionRate}`);
```
## Module Documentation
| Module | Description |
|---|---|
| [`src/types/common.md`](src/types/common.md) | Core domain type interfaces |
| [`src/types/schemas.md`](src/types/schemas.md) | Zod validation schemas |
| [`src/core/experiment-manager/experiment-manager.md`](src/core/experiment-manager/experiment-manager.md) | Main manager implementation |
| [`src/bucketing/bucketing.md`](src/bucketing/bucketing.md) | FNV-1a bucketing primitives |
| [`src/targeting/targeting.md`](src/targeting/targeting.md) | Targeting rule evaluation |
| [`src/results/results.md`](src/results/results.md) | Results aggregation |
| [`src/providers/provider.md`](src/providers/provider.md) | Built-in provider implementations |
## Subpath Exports
```ts
import { createExperimentManager } from '@suaveplan/ab-testing';
import { bucketUser } from '@suaveplan/ab-testing/bucketing';
import { evaluateTargeting } from '@suaveplan/ab-testing/targeting';
import { computeResults } from '@suaveplan/ab-testing/results';
import { ForceVariantProvider, LoggingProvider } from '@suaveplan/ab-testing/providers';
import { AbTestingError, AB_CODES } from '@suaveplan/ab-testing/errors';
```

View File

@@ -0,0 +1,116 @@
# bucketing.ts — Deterministic User Bucketing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the core hashing and variant selection primitives that map a
`(userId, experimentId)` pair to a stable, deterministic variant. The
implementation uses the FNV-1a 32-bit algorithm — a non-cryptographic hash
chosen for its excellent distribution characteristics, extremely low
computational overhead, and zero external dependencies.
Determinism is a hard requirement for A/B testing: the same user must always
receive the same variant for the lifetime of the experiment. This module
achieves that by combining the user and experiment identifiers with a null-byte
separator before hashing, preventing cross-experiment correlation while
remaining completely stateless.
## Features
- `bucketUser` — FNV-1a 32-bit hash of `userId\0experimentId` with optional
seed XOR offset; output is a float in `[0, 1)`
- `normaliseWeights` — converts arbitrary positive weights to cumulative
normalised slices in `[0, 1]`; zero/negative-weight variants are excluded
- `selectVariant` — binary search over normalised cumulative weights to find
the owning variant for a bucket value
- `NormalisedVariant` — typed extension of `Variant` with `normalizedWeight`
## Usage: Basic Example
```ts
import { bucketUser, normaliseWeights, selectVariant } from '@suaveplan/ab-testing/bucketing';
const variants = [
{ id: 'control', name: 'Control', weight: 50, isControl: true },
{ id: 'treatment', name: 'Treatment', weight: 50 },
];
const normalised = normaliseWeights(variants);
const bucket = bucketUser('user-123', 'checkout-cta');
const chosen = selectVariant(normalised, bucket);
console.log(chosen.id); // deterministic: 'control' or 'treatment'
```
## Usage: Advanced Example
```ts
import { bucketUser, normaliseWeights, selectVariant } from '@suaveplan/ab-testing/bucketing';
// Unequal weights: 10% treatment A, 20% treatment B, 70% control
const variants = [
{ id: 'ctrl', name: 'Control', weight: 70, isControl: true },
{ id: 'treat-a', name: 'Treatment A', weight: 10 },
{ id: 'treat-b', name: 'Treatment B', weight: 20 },
];
// Custom seed differentiates this experiment's buckets from other experiments
// even when the same userId is used across both.
const normalised = normaliseWeights(variants);
const bucket = bucketUser('user-456', 'homepage-hero', 0xdeadbeef);
const chosen = selectVariant(normalised, bucket);
// Verify: generate 10 000 users and check distribution
let counts: Record<string, number> = {};
for (let i = 0; i < 10_000; i++) {
const b = bucketUser(`u-${i}`, 'homepage-hero', 0xdeadbeef);
const v = selectVariant(normalised, b);
counts[v.id] = (counts[v.id] ?? 0) + 1;
}
// Expected: ctrl ≈ 7000, treat-a ≈ 1000, treat-b ≈ 2000
console.log(counts);
```
## API Reference
### `bucketUser(userId, experimentId, seed?): number`
- **`userId`** — `string` — user or entity identifier
- **`experimentId`** — `string` — experiment identifier; scopes the bucket
- **`seed`** — `number` (default `0`) — XOR'd into the FNV offset basis
- **Returns** — `number` in `[0, 1)`
- **Deterministic** — identical inputs always produce identical output
### `normaliseWeights(variants): NormalisedVariant[]`
- **`variants`** — `ReadonlyArray<Variant>`
- **Returns** — `NormalisedVariant[]` sorted in original order with `normalizedWeight`
set to cumulative upper bounds. Variants with `weight <= 0` are excluded.
Returns `[]` when all variants have non-positive weight.
### `selectVariant(variants, bucket): Variant`
- **`variants`** — `ReadonlyArray<NormalisedVariant>` from `normaliseWeights`
- **`bucket`** — `number` in `[0, 1)` from `bucketUser`
- **Returns** — the `Variant` whose cumulative slice contains `bucket`
- **Throws** — `RangeError` when `variants` is empty
### `NormalisedVariant`
Extends `Variant` with `normalizedWeight: number` representing the cumulative
upper bound of the variant's slice. The last variant always has
`normalizedWeight === 1.0`.
## Implementation Notes
The FNV-1a algorithm is applied with `Math.imul` for correct 32-bit wrapping
on all JavaScript engines (V8, JavaScriptCore, SpiderMonkey). The hash is
mapped to `[0, 1)` by dividing by `0x1_0000_0000` (`2^32`), preserving the
full resolution of the 32-bit output.
The null-byte separator (`\0`) between `userId` and `experimentId` prevents
hash collisions between adjacent key combinations such as `('ab', 'c')` and
`('a', 'bc')`. The seed XOR is applied to the FNV offset basis before
iterating so that the seed affects every step of the hash, not just the first.

View File

@@ -0,0 +1,148 @@
# experiment-manager.ts — Experiment Manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `ExperimentManager` interface as a stateful, in-process service
that manages the full A/B experiment lifecycle: defining experiments, assigning
users to variants, recording conversion events, and computing results. All
state is held in memory, making the manager suitable for server-side
applications where state can be injected at startup and reset between test runs.
The manager integrates with the OTel API for counter and span instrumentation,
emits events on an injectable `EventBusLike` for downstream consumers, and
delegates to registered `ExperimentProvider` instances for forced assignments
and platform-specific event forwarding.
## Features
- `defineExperiment` — registers or replaces an experiment definition with
Zod validation
- `getExperiment` / `listExperiments` — read-only accessors
- `assign` — deterministic variant assignment with targeting evaluation,
sample-rate gating, provider override, FNV-1a bucketing, assignment caching,
OTel counter, event-bus emission, and provider tracking
- `track` — conversion event recording with Zod validation, provider tracking,
and OTel counter
- `getResults` — on-demand result aggregation via `computeResults`
- `use` — chainable provider registration
- Injectable `clock`, `rng`, `logger`, `eventBus`, `hashSeed`
## Usage: Basic Example
```ts
import { createExperimentManager } from '@suaveplan/ab-testing';
const manager = createExperimentManager();
manager.defineExperiment({
id: 'checkout-cta',
name: 'Checkout CTA Colour',
status: 'running',
variants: [
{ id: 'ctrl', name: 'Blue', weight: 1, isControl: true },
{ id: 'treat', name: 'Green', weight: 1 },
],
primaryMetric: 'checkout_started',
});
const a = manager.assign('checkout-cta', 'user-abc');
if (a.inExperiment) {
console.log('variant:', a.variantId);
}
manager.track('checkout-cta', 'user-abc', { name: 'checkout_started' });
const results = manager.getResults('checkout-cta');
console.log(results.totalConversions); // 1
```
## Usage: Advanced Example
```ts
import { createExperimentManager } from '@suaveplan/ab-testing';
import { ForceVariantProvider, LoggingProvider } from '@suaveplan/ab-testing/providers';
const manager = createExperimentManager({
hashSeed: 0xcafe, // scope buckets to this deployment
sampleRate: undefined, // per-experiment
});
manager
.use(new ForceVariantProvider({ 'my-exp': { 'qa-1': 'treatment' } }))
.use(new LoggingProvider({ onAssignment: (a) => metrics.record(a) }));
manager.defineExperiment({
id: 'my-exp',
name: 'Dashboard Redesign',
status: 'running',
variants: [
{ id: 'control', name: 'Old', weight: 50, isControl: true },
{ id: 'treatment', name: 'New', weight: 50 },
],
targeting: [
{ attribute: 'plan', operator: 'eq', value: 'premium' },
{ attribute: 'country', operator: 'in', value: ['US', 'CA'] },
],
sampleRate: 0.1,
primaryMetric: 'feature_click',
});
// qa-1 always gets treatment (forced by provider)
const qa = manager.assign('my-exp', 'qa-1', { plan: 'premium', country: 'US' });
console.log(qa.variantId); // 'treatment'
// Random user goes through bucketing + sample-rate
const user = manager.assign('my-exp', 'user-999', { plan: 'premium', country: 'CA' });
```
## API Reference
### `createExperimentManager(options?): ExperimentManager`
Factory function. All options are optional.
| Option | Type | Default |
|---|---|---|
| `logger` | `LoggerLike` | none |
| `clock` | `Clock` | `{ now: () => Date.now() }` |
| `rng` | `Rng` | `{ next: () => Math.random() }` |
| `eventBus` | `EventBusLike` | none |
| `hashSeed` | `number` | `0` |
### `ExperimentManager` interface
| Method | Signature | Description |
|---|---|---|
| `defineExperiment` | `(def) => void` | Register/replace experiment |
| `getExperiment` | `(id) => ExperimentDefinition \| null` | Lookup by id |
| `listExperiments` | `() => ReadonlyArray<ExperimentDefinition>` | All registered |
| `assign` | `(expId, userId, attrs?) => Assignment` | Assign user to variant |
| `track` | `(expId, userId, metric) => void` | Record conversion event |
| `getResults` | `(expId) => ExperimentResults` | Aggregate results |
| `use` | `(provider) => this` | Register provider (chainable) |
### Throws
- `AB_EXPERIMENT_NOT_FOUND` — `assign`, `track`, `getResults` when experiment
is not registered
- `AB_EXPERIMENT_INACTIVE` — `assign` when `status !== 'running'`
- `AB_INVALID_DEFINITION` — `defineExperiment` when Zod validation fails, or
when all variants have weight ≤ 0 at assign time
- `AB_METRIC_INVALID` — `track` when Zod validation of the metric event fails
## Implementation Notes
Assignments are cached by `${experimentId}:${userId}` key so repeated calls
to `assign` for the same user always return the same result. This is critical
for correctness — a user must not "jump" between variants within an experiment.
The OTel tracer wraps `_assign` in a span named `ab.assign` so that
distributed traces include experiment assignment latency. The span is closed
in a `finally` block to ensure it is always ended even when exceptions occur.
Providers are iterated in registration order. For `getAssignment`, the first
non-null result wins (short-circuit). For `trackAssignment` and
`trackConversion`, all providers are notified.

View File

@@ -0,0 +1,116 @@
# provider.ts — Experiment Providers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides two concrete implementations of the `ExperimentProvider` interface:
`LoggingProvider` for forwarding assignment and conversion events to custom
callbacks, and `ForceVariantProvider` for overriding variant assignment in
QA, staging, and testing environments.
Providers are the extension point for integrating `@suaveplan/ab-testing`
with external platforms (LaunchDarkly, Optimizely, Amplitude, custom analytics
pipelines) without coupling the core engine to any specific SDK.
## Features
- `LoggingProvider` — configurable callbacks for assignment and conversion
events; defaults to `console.log` for zero-config debugging
- `ForceVariantProvider` — experiment-scoped per-user variant overrides with
wildcard `'*'` support for forcing all users in an experiment to a variant
- Both classes implement `ExperimentProvider` so they can be passed to
`manager.use(provider)` for chaining
## Usage: Basic Example
```ts
import { LoggingProvider } from '@suaveplan/ab-testing/providers';
import { createExperimentManager } from '@suaveplan/ab-testing';
const manager = createExperimentManager();
manager.use(new LoggingProvider({
onAssignment: (a) => console.info('assigned', a.variantId, 'to', a.userId),
}));
```
## Usage: Advanced Example
```ts
import {
ForceVariantProvider,
LoggingProvider,
} from '@suaveplan/ab-testing/providers';
import { createExperimentManager } from '@suaveplan/ab-testing';
const manager = createExperimentManager();
// Force QA users to treatment, but let normal users bucket normally
const forceProvider = new ForceVariantProvider({
'checkout-redesign': {
'qa-user-1': 'treatment',
'qa-user-2': 'control',
// '*': 'treatment', // would force everyone not explicitly listed
},
});
// Send events to an analytics platform
const analyticsProvider = new LoggingProvider({
onAssignment: (a) => externalAnalytics.track('experiment_assigned', a),
onConversion: (expId, userId, metric) =>
externalAnalytics.track('experiment_converted', { expId, userId, ...metric }),
});
// Chain multiple providers
manager.use(forceProvider).use(analyticsProvider);
// For the QA user, forceProvider.getAssignment returns 'treatment'
// and the bucketing step is skipped
const assignment = manager.assign('checkout-redesign', 'qa-user-1');
console.log(assignment.variantId); // 'treatment'
```
## API Reference
### `LoggingProvider`
Constructor: `new LoggingProvider(opts?: LoggingProviderOptions)`
#### `LoggingProviderOptions`
| Property | Type | Default |
|---|---|---|
| `onAssignment` | `(assignment: Assignment) => void` | `console.log` |
| `onConversion` | `(experimentId, userId, metric) => void` | `console.log` |
Methods:
- `trackAssignment(assignment)` — invokes `onAssignment`
- `trackConversion(experimentId, userId, metric)` — invokes `onConversion`
### `ForceVariantProvider`
Constructor: `new ForceVariantProvider(overrides?)`
Where `overrides` is `Record<string, Record<string, string>>` mapping
`experimentId → { userId → variantId }`. Use `'*'` as the `userId` key to
force all users not explicitly listed.
Methods:
- `getAssignment(experimentId, userId): string | null` — returns the forced
variant ID or `null` to fall through to bucketing
## Implementation Notes
`LoggingProvider` stores the resolved callbacks in a `protected readonly opts`
object so that subclasses can override or extend the default behaviour.
`ForceVariantProvider` checks user-specific overrides before the wildcard to
allow precise QA control even when a global experiment override is active.
Neither provider implements both `getAssignment` and `trackAssignment` /
`trackConversion` simultaneously — they are intentionally specialised. For a
production integration, compose multiple providers via `manager.use()` or
implement a custom class that merges the two concerns.

View File

@@ -0,0 +1,118 @@
# results.ts — Experiment Results Computation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `computeResults` function, which aggregates raw assignment and
conversion data into structured per-variant statistics. This module is a pure
function with no side effects, making it straightforward to test exhaustively
and to call from any context — the manager uses it on demand, but it can also
be called directly from analytics pipelines or reporting jobs.
The function produces assignment counts, conversion counts, conversion rates,
and per-metric aggregates (count, sum, average) for every variant that received
at least one in-experiment assignment.
## Features
- Groups assignments by `variantId`, excluding out-of-experiment users
- Counts unique conversions per user (one conversion per user, regardless of
how many metric events they fire)
- Aggregates named metric events into `count`, `sum`, and `avg` per metric
per variant
- Computes `conversionRate = conversions / assignments` (0 when no assignments)
- Returns `totalAssignments` and `totalConversions` rolled up across all
variants for dashboard summary views
- Fully deterministic — given the same inputs, produces byte-identical output
## Usage: Basic Example
```ts
import { computeResults } from '@suaveplan/ab-testing/results';
import type { Assignment, MetricEvent } from '@suaveplan/ab-testing';
const assignments: Assignment[] = [
{ experimentId: 'exp-1', userId: 'u1', variantId: 'ctrl', isControl: true, timestamp: 1, inExperiment: true },
{ experimentId: 'exp-1', userId: 'u2', variantId: 'treat', isControl: false, timestamp: 2, inExperiment: true },
];
const conversions = new Map<string, MetricEvent[]>([
['u1', [{ name: 'checkout_started', value: 1 }]],
]);
const results = computeResults('exp-1', assignments, conversions);
console.log(results.totalAssignments); // 2
console.log(results.totalConversions); // 1
console.log(results.variants[0]?.conversionRate); // 1.0 (ctrl: 1/1)
console.log(results.variants[1]?.conversionRate); // 0.0 (treat: 0/1)
```
## Usage: Advanced Example
```ts
import { computeResults } from '@suaveplan/ab-testing/results';
import type { Assignment, MetricEvent } from '@suaveplan/ab-testing';
// Multiple metric events per user (only first triggers conversion flag)
const assignments: Assignment[] = [
{ experimentId: 'exp-2', userId: 'u1', variantId: 'v1', isControl: true, timestamp: 1, inExperiment: true },
{ experimentId: 'exp-2', userId: 'u2', variantId: 'v1', isControl: true, timestamp: 2, inExperiment: true },
{ experimentId: 'exp-2', userId: 'u3', variantId: 'v2', isControl: false, timestamp: 3, inExperiment: true },
// excluded user — not counted
{ experimentId: 'exp-2', userId: 'u4', variantId: '', isControl: false, timestamp: 4, inExperiment: false },
];
const conversions = new Map<string, MetricEvent[]>([
['u1', [
{ name: 'revenue', value: 49.99 },
{ name: 'revenue', value: 19.99 },
]],
['u3', [{ name: 'revenue', value: 99.00 }]],
]);
const results = computeResults('exp-2', assignments, conversions);
// v1: 2 assignments, 1 conversion (u1), revenue avg = (49.99 + 19.99) / 2
// v2: 1 assignment, 1 conversion (u3), revenue avg = 99.00
```
## API Reference
### `computeResults(experimentId, assignments, conversions): ExperimentResults`
- **`experimentId`** — `string` — stable experiment identifier copied to result
- **`assignments`** — `Assignment[]` — all assignments for the experiment
- **`conversions`** — `Map<string, MetricEvent[]>` — keyed by `userId`
- **Returns** — `ExperimentResults` with `variants`, `totalAssignments`,
`totalConversions`
#### `ExperimentResults`
| Field | Type | Description |
|---|---|---|
| `experimentId` | `string` | Echoed from input |
| `variants` | `VariantResults[]` | Per-variant aggregates |
| `totalAssignments` | `number` | Sum across all variants |
| `totalConversions` | `number` | Sum across all variants |
#### `VariantResults`
| Field | Type | Description |
|---|---|---|
| `variantId` | `string` | Variant identifier |
| `assignments` | `number` | In-experiment assignment count |
| `conversions` | `number` | Unique-user conversion count |
| `conversionRate` | `number` | `conversions / assignments` |
| `metrics` | `Array<{name, count, sum, avg}>` | Per-metric aggregates |
## Implementation Notes
Conversion counting follows the convention that a user can convert at most
once per experiment — the first metric event for each user triggers the
conversion flag, but all subsequent events for that user are still accumulated
into the metric aggregates. This avoids double-counting in funnel analyses
while preserving the full metric history for revenue and value calculations.
Out-of-experiment assignments (`inExperiment: false`) are excluded entirely
from all counts. They are recorded so that the manager can return a stable
assignment for a user across multiple calls, but they should not pollute the
statistical results.

View File

@@ -0,0 +1,92 @@
# targeting.ts — Targeting Rule Evaluation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `evaluateTargeting` function that evaluates an ordered list of
targeting rules against a set of user attributes. The function implements AND
semantics — every rule in the list must pass for the user to be considered
eligible for the experiment. This single-function module is intentionally
small and pure: it has no side effects, no I/O, no external dependencies, and
is trivially unit-testable.
Targeting rules are the primary mechanism for gating experiment exposure to
specific user segments. Common use cases include restricting to paid users,
specific geographic regions, device types, or beta-program membership.
## Features
- Eight comparison operators: `eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`
- Numeric ordering operators (`gt`, `lt`, `gte`, `lte`) require both sides to
be numbers; any type mismatch returns `false` rather than throwing
- `in` / `notIn` accept any array on the right-hand side
- An empty rules array always returns `true` (no restriction)
- Absent attributes evaluate to `undefined` and fail equality/ordering tests
gracefully without exceptions
## Usage: Basic Example
```ts
import { evaluateTargeting } from '@suaveplan/ab-testing/targeting';
const eligible = evaluateTargeting(
[{ attribute: 'plan', operator: 'eq', value: 'premium' }],
{ plan: 'premium', country: 'US' }
);
console.log(eligible); // true
```
## Usage: Advanced Example
```ts
import { evaluateTargeting } from '@suaveplan/ab-testing/targeting';
// Multi-rule targeting: premium users in North America with high session count
const rules = [
{ attribute: 'plan', operator: 'eq' as const, value: 'premium' },
{ attribute: 'country', operator: 'in' as const, value: ['US', 'CA', 'MX'] },
{ attribute: 'sessionCount', operator: 'gte' as const, value: 10 },
{ attribute: 'blocklisted', operator: 'neq' as const, value: true },
];
const user1 = { plan: 'premium', country: 'CA', sessionCount: 15, blocklisted: false };
const user2 = { plan: 'free', country: 'US', sessionCount: 30, blocklisted: false };
console.log(evaluateTargeting(rules, user1)); // true (all 4 rules pass)
console.log(evaluateTargeting(rules, user2)); // false (plan !== 'premium')
```
## API Reference
### `evaluateTargeting(rules, attributes): boolean`
- **`rules`** — `ReadonlyArray<TargetingRule>` — ordered list of predicates
- **`attributes`** — `UserAttributes` (`Record<string, string | number | boolean>`)
- **Returns** — `boolean` — `true` iff every rule is satisfied (or `rules` is empty)
#### Supported operators
| Operator | Description | RHS type |
|---|---|---|
| `eq` | Strict equality (`===`) | any |
| `neq` | Strict inequality (`!==`) | any |
| `in` | LHS is contained in RHS array | `unknown[]` |
| `notIn` | LHS is not contained in RHS array | `unknown[]` |
| `gt` | LHS > RHS | `number` |
| `lt` | LHS < RHS | `number` |
| `gte` | LHS >= RHS | `number` |
| `lte` | LHS <= RHS | `number` |
## Implementation Notes
All comparisons are intentionally strict (`===` / `!==`) to avoid surprising
truthy/falsy coercions. When the RHS is not of the expected type for ordering
operators (e.g. a string is passed as the threshold for `gt`), the function
returns `false` rather than throwing, making it safe to evaluate partially
invalid rule sets without crashing the assignment pipeline.
The `in` / `notIn` operators use `Array.isArray` to check the RHS type before
calling `Array.prototype.includes`. When the RHS is not an array, the rule
evaluates to `false`.

View File

@@ -0,0 +1,151 @@
# common.ts — Core Domain Types
## Purpose
Defines the shared TypeScript interfaces and type aliases that form the public
contract of `@suaveplan/ab-testing`. Every other module in the package imports
from this file rather than re-declaring shapes, ensuring a single source of
truth for the domain model.
The types are designed to be structurally compatible with common external A/B
testing platforms (LaunchDarkly, Optimizely, Split.io) to make provider
adapters straightforward to write.
## Features
- `ExperimentDefinition` — full lifecycle descriptor: id, name, status, variants,
targeting rules, date range, sample rate, and metric names
- `Variant` — a single treatment group with stable id, display name, relative
traffic weight, and optional control flag
- `TargetingRule` — a single eligibility predicate with eight comparison
operators (eq, neq, in, notIn, gt, lt, gte, lte)
- `Assignment` — immutable record capturing which variant a user received,
whether they are in-experiment, and when the assignment occurred
- `MetricEvent` — a named conversion event with optional numeric value,
timestamp, and arbitrary properties
- `VariantResults` / `ExperimentResults` — aggregated assignment and conversion
statistics ready for significance testing
- `UserAttributes` — free-form attribute map used in targeting evaluation
- `ExperimentManagerOptions` — dependency-injection surface for clock, RNG,
logger, event bus, and hash seed
- `ExperimentProvider` — plug-in interface for external platform adapters
- `ExperimentManager` — primary management interface for defining experiments,
assigning users, tracking conversions, and reading results
## Usage: Basic Example
```ts
import type {
ExperimentDefinition,
Variant,
} from '@suaveplan/ab-testing';
const control: Variant = { id: 'control', name: 'Control', weight: 50, isControl: true };
const treatment: Variant = { id: 'treatment', name: 'Treatment', weight: 50 };
const exp: ExperimentDefinition = {
id: 'checkout-cta',
name: 'Checkout CTA colour',
status: 'running',
variants: [control, treatment],
sampleRate: 0.5,
primaryMetric: 'checkout_started',
};
```
## Usage: Advanced Example
```ts
import type {
ExperimentDefinition,
TargetingRule,
ExperimentManagerOptions,
ExperimentProvider,
Assignment,
} from '@suaveplan/ab-testing';
// Targeting: only premium users in the US
const rules: TargetingRule[] = [
{ attribute: 'plan', operator: 'eq', value: 'premium' },
{ attribute: 'country', operator: 'in', value: ['US', 'CA'] },
];
const exp: ExperimentDefinition = {
id: 'dashboard-redesign',
name: 'Dashboard redesign',
status: 'running',
variants: [
{ id: 'v0', name: 'Old', weight: 1, isControl: true },
{ id: 'v1', name: 'New', weight: 1 },
],
targeting: rules,
sampleRate: 0.1,
primaryMetric: 'session_duration',
secondaryMetrics: ['page_views', 'feature_usage'],
};
// Custom provider that logs assignments to an analytics platform
const analyticsProvider: ExperimentProvider = {
name: 'analytics',
trackAssignment(assignment: Assignment) {
// forward to external analytics service
console.log('assigned', assignment.variantId, 'to', assignment.userId);
},
};
```
## API Reference
### `Variant`
| Property | Type | Description |
|---|---|---|
| `id` | `string` | Stable identifier |
| `name` | `string` | Display name |
| `weight` | `number` | Relative traffic weight (≤ 0 skipped) |
| `isControl` | `boolean?` | Marks the baseline variant |
### `TargetingRule`
| Property | Type | Description |
|---|---|---|
| `attribute` | `string` | Key into `UserAttributes` |
| `operator` | `'eq' \| 'neq' \| 'in' \| 'notIn' \| 'gt' \| 'lt' \| 'gte' \| 'lte'` | Comparison operator |
| `value` | `unknown` | Right-hand side value |
### `ExperimentDefinition`
Full descriptor of an experiment. Only `'running'` experiments accept new
assignments; all other statuses throw `AB_EXPERIMENT_INACTIVE`.
### `Assignment`
Immutable result of `ExperimentManager.assign()`. When `inExperiment` is
`false` (due to targeting or sample-rate exclusion), `variantId` is `''` and
`isControl` is `false`.
### `MetricEvent`
Conversion event passed to `ExperimentManager.track()`. `value` defaults to
`1` and `timestamp` defaults to `Date.now()` when omitted by the caller.
### `ExperimentManagerOptions`
Dependency-injection bag. All fields are optional; sensible defaults are
provided by the manager implementation.
### `ExperimentProvider`
Plug-in interface. `getAssignment` can return a forced variant ID (useful for
QA overrides) or `null` to fall through to normal bucketing.
## Implementation Notes
All interfaces use `readonly` modifiers on every property to prevent accidental
mutation of assignment or experiment records after creation. The `variants`
array on `ExperimentDefinition` is typed as `ReadonlyArray<Variant>` so that
freezing at the API boundary requires no explicit cast.
`UserAttributes` intentionally allows only `string | number | boolean` values
to keep targeting evaluation simple and deterministic without JSON serialisation
of complex objects.

View File

@@ -0,0 +1,107 @@
# schemas.ts — Zod Validation Schemas
## Purpose
Provides Zod validation schemas for all public input types in
`@suaveplan/ab-testing`. These schemas serve as the single runtime validation
layer between untrusted external input (e.g. feature-flag JSON files, API
payloads, user-supplied configs) and the strongly-typed domain objects used
internally by the experiment manager.
By keeping schemas in a dedicated module, the validation logic is reusable
across REST adapters, config loaders, and import utilities without coupling
any of them to each other.
## Features
- `VariantSchema` — validates variant id, name, weight, and optional isControl
- `TargetingRuleSchema` — validates attribute, all eight operator literals, and
an open `unknown` value field
- `ExperimentDefinitionSchema` — full experiment definition with all optional
fields validated (sampleRate clamped to `[0, 1]`, variants array non-empty)
- `MetricEventSchema` — metric name, optional numeric value, optional
timestamp, and optional free-form properties map
- Inferred TypeScript types (`VariantInput`, `TargetingRuleInput`,
`ExperimentDefinitionInput`, `MetricEventInput`) derived directly from
the schemas to eliminate duplication
## Usage: Basic Example
```ts
import { ExperimentDefinitionSchema } from '@suaveplan/ab-testing';
const raw = JSON.parse(fs.readFileSync('experiment.json', 'utf-8'));
const result = ExperimentDefinitionSchema.safeParse(raw);
if (!result.success) {
console.error(result.error.format());
} else {
manager.defineExperiment(result.data);
}
```
## Usage: Advanced Example
```ts
import z from 'zod';
import {
ExperimentDefinitionSchema,
MetricEventSchema,
} from '@suaveplan/ab-testing';
// Extend the base schema with domain-specific constraints
const StrictExperimentSchema = ExperimentDefinitionSchema.refine(
(exp) => exp.variants.some((v) => v.isControl),
{ message: 'At least one variant must be the control' }
).refine(
(exp) => exp.variants.every((v) => v.weight >= 0),
{ message: 'All variant weights must be non-negative' }
);
// Parse a metric event from an incoming webhook body
function parseMetric(body: unknown) {
return MetricEventSchema.parse(body);
}
```
## API Reference
### `VariantSchema`
Zod object schema. Requires `id` (non-empty string), `name` (non-empty
string), `weight` (any number — negative values are valid input but skipped
during bucketing), and optional `isControl` boolean.
### `TargetingRuleSchema`
Validates `attribute` (non-empty string), `operator` (one of the eight
supported literals), and `value` (any type — runtime comparison is handled
by the targeting module).
### `ExperimentDefinitionSchema`
Full experiment descriptor. `sampleRate` is constrained to `[0, 1]` by the
schema. `variants` must contain at least one entry. All date fields are plain
strings; format validation (ISO-8601) is left to the application layer.
### `MetricEventSchema`
`name` is required and non-empty. `value` defaults to `undefined` (the
manager treats this as `1` for conversion counting). `properties` is an
open record accepting any JSON-compatible value.
### Inferred Types
`VariantInput`, `TargetingRuleInput`, `ExperimentDefinitionInput`, and
`MetricEventInput` are `z.infer<>` aliases of the corresponding schemas.
They are interchangeable with the handwritten interfaces in `common.ts`
because Zod inference produces structurally compatible types.
## Implementation Notes
All schemas use `.optional()` for nullable/absent fields rather than
`.nullable()` because the domain model uses `undefined` rather than `null`
for absent fields, keeping serialisation round-trips clean. The `value` field
on `TargetingRuleSchema` intentionally uses `z.unknown()` to allow the full
range of operator comparisons (arrays for `in`/`notIn`, numbers for
`gt`/`lt`/`gte`/`lte`, and arbitrary scalars for `eq`/`neq`).

View File

@@ -0,0 +1,154 @@
# @suaveplan/testing-dom - DOM/React testing — vitest+bun-test conditional re-exports, createTestConfig, polyfills, render helpers, accessibility-first queries, jest-dom matchers, user-event v14
## Summary
DOM and React component testing utilities for Suaveplan. Provides a single install that covers vitest config generation with WSL2-safe defaults, `@testing-library/react` render helpers, accessibility-first query aliases, `@testing-library/jest-dom` matchers, user-event v14 setup, and a dual-runner shim that routes between vitest and `bun:test` via package.json conditional exports.
<!-- begin:auto-docs -->
## Documentation tree
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - detect-environment — `detectEnvironment` probes the…
- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - detect-environment — `detectEnvironment` probes the…
- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [render-component](./src/render/render-component.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…
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - detect-environment — `detectEnvironment` probes the…
- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [render-component](./src/render/render-component.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…
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [render-component](./src/render/render-component.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…
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [render-component](./src/render/render-component.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…
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_ - [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_ - [render-component](./src/render/render-component.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…
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_ - [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_ - [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/testing-dom
```
Peer dependencies (install separately if using React rendering):
```bash
bun add react react-dom @vitejs/plugin-react
```
## Quick Start
### Vitest config
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { createVitestConfig } from "@suaveplan/testing-dom/config";
import { fileURLToPath } from "node:url";
export default defineConfig(
createVitestConfig(fileURLToPath(new URL(".", import.meta.url)), {
environment: "happy-dom",
coverage: true,
})
);
```
### Rendering a React component
```tsx
import { renderComponent } from "@suaveplan/testing-dom/render";
import { byRole, findRole } from "@suaveplan/testing-dom/queries";
import "@suaveplan/testing-dom/matchers"; // extends expect with jest-dom matchers
it("submits the form", async () => {
const { user } = renderComponent(<MyForm onSubmit={handleSubmit} />);
await user.click(byRole("button", { name: /submit/i }));
const status = await findRole("status");
expect(status).toHaveTextContent("Saved");
});
```
## Modules
| Subpath | Module | Description |
|---|---|---|
| `@suaveplan/testing-dom/config` | [src/config/vitest-config.md](src/config/vitest-config.md) | `createVitestConfig` — builds the vitest `test` block with WSL2-safe `pool: "forks"`, polyfill setup files, and Istanbul coverage defaults |
| | [src/config/bun-test-config.md](src/config/bun-test-config.md) | `createBunTestConfig` — equivalent config builder for `bun test` |
| | [src/config/detect-runner.md](src/config/detect-runner.md) | `detectRunner` — infers the active test runner from `package.json` and environment variables |
| | [src/config/detect-environment.md](src/config/detect-environment.md) | `detectEnvironment` — resolves the vitest test environment (`node`, `happy-dom`, `jsdom`) |
| | [src/config/config.md](src/config/config.md) | `createTestConfig` — unified entry point that delegates to the correct runner-specific builder |
| `@suaveplan/testing-dom/render` | [src/render/render-component.md](src/render/render-component.md) | `renderComponent` — thin wrapper over `@testing-library/react` render that auto-cleans between calls and returns an attached `user` event instance |
| `@suaveplan/testing-dom/queries` | [src/queries/queries.md](src/queries/queries.md) | Accessibility-first query aliases (`byRole`, `findRole`, `queryRole`, `byLabelText`, `byText`, `byPlaceholderText`) plus full re-export of `screen` and `within` |
| `@suaveplan/testing-dom/user` | [src/user/setup-user.md](src/user/setup-user.md) | `setupUser` — produces a `@testing-library/user-event` v14 instance with happy-dom-safe defaults and automatic fake-timer integration |
| `@suaveplan/testing-dom/matchers` | [src/matchers/matchers.md](src/matchers/matchers.md) | Side-effect import that extends vitest's `expect` with the full `@testing-library/jest-dom` matcher set |
| `@suaveplan/testing-dom/polyfills` | [src/setup/polyfills.md](src/setup/polyfills.md) | Setup file that installs web-platform polyfills (`TextEncoder`, `ResizeObserver`, etc.) required by happy-dom in Node/Bun |
| `@suaveplan/testing-dom/errors` | [src/errors/testing-dom-error.md](src/errors/testing-dom-error.md) | Typed error classes (`RenderError`) and error codes for failures surfaced by this package |
| | [src/errors/codes.md](src/errors/codes.md) | `TESTING_DOM_ERROR_CODES` registry |
| _(runner shim)_ | [src/runner/vitest.md](src/runner/vitest.md) | Vitest runner re-exports (`describe`, `it`, `test`, `expect`, `vi`, `beforeEach`, …) |
| | [src/runner/bun-test.md](src/runner/bun-test.md) | `bun:test` runner shim with the same surface — activated by the `"bun"` conditional export |
| | [src/runner/mocks.md](src/runner/mocks.md) | `fn`, `spy`, `stub`, `mock`, `fakeClock`, `restoreMocks`, `restoreTimers` — runner-agnostic mock helpers |
| | [src/runner/runner.md](src/runner/runner.md) | Runner abstraction types |
| _(observability)_ | [src/observability/instrumented.md](src/observability/instrumented.md) | `instrumented` — optional OTel wrapper (gated by `TESTING_DOM_OTEL_ENABLED=1`) that records call counts and duration histograms for package entrypoints |

View File

@@ -0,0 +1,59 @@
# bun-test-config
## Purpose
`createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment defaults as the vitest variant. It exists because `@suaveplan/testing-dom` is a dual-runner package — the same test suite should run under vitest AND under `bun test` without manual config divergence. The shape it returns is intentionally small because `bun test` has a far narrower config surface than vitest.
## Features
- Prepends `@suaveplan/testing-dom/polyfills` to the `preload` array
- Honours the `happy-dom` runtime via a warning (bun-test does NOT support happy-dom natively; tests that depend on DOM must switch runners)
- Pass-through defaults for `coverage` and `testTimeoutMs`
- Stateless — no module-level config caching
## Usage: Basic Example
```ts
// bunfig.toml is usually the entry, but this runner can be invoked directly:
import { createBunTestConfig } from "@suaveplan/testing-dom/config";
const cfg = createBunTestConfig(__dirname, { testTimeoutMs: 10_000 });
// cfg.preload includes "@suaveplan/testing-dom/polyfills"
```
## Usage: Advanced Example
```ts
import { createBunTestConfig } from "@suaveplan/testing-dom/config";
// Opt into coverage when running under CI.
const cfg = createBunTestConfig(__dirname, {
testTimeoutMs: 30_000,
coverageEnabled: process.env.CI === "true",
environment: "node",
preload: ["./tests/install-matchers.ts"],
});
// Hand off to a custom bun-test spawner:
await spawnBunTestRunner(cfg);
```
## API Reference
### `createBunTestConfig(callerDir, overrides): BunTestConfig`
- `callerDir` — reserved for future resolution use
- `overrides.preload` — extra preloads appended after the package polyfills
- `overrides.coverageEnabled` — default `false`
- `overrides.testTimeoutMs` — default `5000`
- `overrides.environment` — `"node"` is the only fully-supported value; `"happy-dom"` logs a warning
### `interface BunTestConfig`
- `preload: string[]`
- `coverageEnabled: boolean`
- `testTimeoutMs: number`
## Implementation Notes
The warning on `happy-dom` is logged via `console.warn` rather than thrown because some consumers intentionally use bun-test only for their Node-side suites and vitest for DOM; throwing would break the dual-runner contract. The config object is deliberately shallow — bun-test has no nested blocks, so there's no surface for the package to over-specify. Callers who need additional bun-test features (`timeout`, `coverage`, `bail`) pass them through their `bunfig.toml` directly.

View File

@@ -0,0 +1,125 @@
# createTestConfig — vitest / bun-test config factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - detect-environment — `detectEnvironment` probes the…
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
- detect-environment — `detectEnvironment` probes the…
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
- detect-environment — `detectEnvironment` probes the…
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
- detect-environment — `detectEnvironment` probes the…
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
## Purpose
`createTestConfig(options?)` builds a runner-appropriate test config absorbing every WSL2-safe default and dual-runner consideration Suaveplan cares about: `pool: "forks"` (because `vmThreads` and `threads` both fail in Bun on WSL2), `setupFiles: ["@suaveplan/testing-dom/polyfills"]`, optional `@vitejs/plugin-react` loaded only when the caller targets `happy-dom` and the peer is installed, `fileParallelism: false` for happy-dom suites that would otherwise time out under 20+ concurrent forks. The factory reads the caller's `package.json` to detect runner + environment when not explicitly overridden.
This is the entry point every other package's `vitest.config.ts` should call. The `@suaveplan/testing` package itself uses an inline equivalent (it cannot import its own factory before being built).
## Features
- Auto-detects runner (`vitest` vs `bun:test`) from `BUN_TEST` env, package scripts mentioning `bun test`, or `vitest` in deps
- Auto-detects environment (`node` / `happy-dom` / `jsdom`) from `react` / `react-dom` / `jsdom` deps
- Returns a vitest `UserConfig` via `defineConfig` for the vitest path; returns `BunTestConfig` for bun-test
- WSL2 defaults: `pool: "forks"`, polyfills setupFile, `fileParallelism: false` for happy-dom
- `@vitejs/plugin-react` is loaded **only** when `environment === "happy-dom"` AND the optional peer is installed — missing peer doesn't break non-React consumers
- Coverage block `provider: "istanbul"` with 95% thresholds when `options.coverage === true`
- Throws `ConfigError("TESTING.CONFIG.UNKNOWN_RUNNER")` if asked for a runner outside the supported set
## Usage
### Basic example — Node-side test config
```ts
// packages/universal/foo/vitest.config.ts
import { createTestConfig } from "@suaveplan/testing/config";
export default await createTestConfig();
```
### React component testing with happy-dom
```ts
import { createTestConfig } from "@suaveplan/testing/config";
export default await createTestConfig({
environment: "happy-dom",
coverage: true,
});
```
### Force bun-test runner
```ts
const config = await createTestConfig({ runner: "bun:test" });
// → BunTestConfig with preload + warnings array
```
### Custom include / exclude
```ts
export default await createTestConfig({
include: ["src/**/*.spec.ts"],
exclude: ["src/__benchmarks__/**"],
testTimeout: 30_000,
});
```
## API Reference
### `createTestConfig(options?: TestConfigOptions): Promise<UserConfig | BunTestConfig>`
Async because it dynamically imports the React plugin when needed.
### `interface TestConfigOptions extends VitestConfigOverrides`
- `runner?: TestRunner` — `"vitest" | "bun:test"`; auto-detected if omitted
### `interface VitestConfigOverrides`
- `environment?: TestEnvironment` — `"node" | "happy-dom" | "jsdom"`
- `testTimeout?: number` — default `5000`
- `coverage?: boolean | Record<string, unknown>` — `true` enables defaults
- `setupFiles?: readonly string[]` — concatenated after polyfills
- `include?: readonly string[]` — concatenated after defaults
- `exclude?: readonly string[]` — concatenated after defaults
- `fileParallelism?: boolean` — defaults to `false` for happy-dom, `true` otherwise
### `type TestRunner` / `type TestEnvironment`
### `interface BunTestConfig` / `interface BunTestConfigOverrides`
- `callerDir`, `environment`, `testTimeoutMs`, `coverageEnabled`, `preload`, `warnings`
### Error codes thrown
| Code | Condition |
|---|---|
| `TESTING.CONFIG.UNKNOWN_RUNNER` | `options.runner` is set to a value other than `"vitest"` / `"bun:test"` |
## Implementation Notes
- **`pool: "forks"` is non-negotiable on WSL2.** `vmThreads` and `threads` both fail in Bun on WSL2 — see CLAUDE.md for "Vitest / WSL2 quirks". The factory hardcodes `forks`; consumers cannot override.
- **`fileParallelism: false`** for happy-dom because 20+ concurrent forks time out. Caller can override via `options.fileParallelism` if their suite is small.
- **`@vitejs/plugin-react`** is an optional peer in `package.json#peerDependenciesMeta`. Dynamic `await import` so the missing-peer case doesn't blow up at config-load time. If the import fails (no React), the factory returns the vitest config without the plugin.
- **`detectRunner`** reads `BUN_TEST` env first, then scans scripts for `bun test`, then falls back to vitest. Explicit `options.runner` always wins.
- **`detectEnvironment`** triggers `happy-dom` on `react` / `react-dom`, `jsdom` on `jsdom`, else `node`. Override via `options.environment`.
- **Coverage thresholds** default to 95% statements/branches/functions/lines, matching the quality gate. `options.coverage` as an object lets callers fully customize.
- **`callerDir` is exposed on `BunTestConfig`** so a future bun-test runner-script could resolve relative paths without re-detecting.

View File

@@ -0,0 +1,51 @@
# detect-environment
## Purpose
`detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"` when it's importable, otherwise `"jsdom"` when that is, otherwise `"node"`. It lets config factories auto-select a DOM environment without forcing every consumer to hard-code one — the right answer depends on what devDependencies the consumer happens to have installed.
## Features
- Pure probe — returns a string, never throws
- Ordered preference: `happy-dom` → `jsdom` → `node`
- Non-throwing `require.resolve` via a safe try/catch
- Zero runtime dependencies — just reads the module graph
## Usage: Basic Example
```ts
import { detectEnvironment } from "@suaveplan/testing-dom/config";
const env = detectEnvironment();
// "happy-dom" | "jsdom" | "node"
```
## Usage: Advanced Example
```ts
import { createVitestConfig, detectEnvironment } from "@suaveplan/testing-dom/config";
import { defineConfig } from "vitest/config";
export default defineConfig(
createVitestConfig(__dirname, {
environment: detectEnvironment(),
coverage: true,
})
);
```
## API Reference
### `detectEnvironment(): TestEnvironment`
Returns one of:
- `"happy-dom"` — when `happy-dom` is resolvable from the caller
- `"jsdom"` — when `jsdom` is resolvable from the caller
- `"node"` — fallback when neither DOM library is available
### `type TestEnvironment = "happy-dom" | "jsdom" | "node"`
## Implementation Notes
Resolution uses `require.resolve` wrapped in a try/catch so missing modules don't surface as unhandled exceptions. The function runs at config-build time, not at test-run time, so the cost is paid once per vitest invocation and never per test. Consumers who need a deterministic test suite across environments should override the probe by passing an explicit `environment` to `createVitestConfig` — `detectEnvironment` is a convenience for the common case where the DOM preference follows which devDependency is installed.

View File

@@ -0,0 +1,60 @@
# detect-runner
## Purpose
`detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-adapter module to pick the right implementation of `describe`/`it`/`expect` at runtime when a test file wants to be dual-runner. Since vitest and bun-test expose slightly different globals, consumers either import the detected runner's API from this package or branch on the returned string.
## Features
- Pure probe of `globalThis` / environment variables
- Three possible outcomes: `"vitest"`, `"bun-test"`, `"unknown"`
- Safe to call outside any test context — returns `"unknown"` rather than throwing
- Deterministic under a single runner — no heuristics that flip between runs
## Usage: Basic Example
```ts
import { detectRunner } from "@suaveplan/testing-dom/config";
const runner = detectRunner();
if (runner === "bun-test") {
// Use bun:test APIs
}
```
## Usage: Advanced Example
```ts
import { detectRunner } from "@suaveplan/testing-dom/config";
// A shared test-helper module that branches on runner.
export function useHelper(): unknown {
const runner = detectRunner();
switch (runner) {
case "vitest":
return loadVitestHelper();
case "bun-test":
return loadBunTestHelper();
case "unknown":
throw new Error(
"useHelper() called outside a supported test runner"
);
}
}
```
## API Reference
### `detectRunner(): TestRunner`
Returns one of:
- `"vitest"` — detected via `import.meta.vitest` or vitest-owned globals
- `"bun-test"` — detected via bun's `Bun.version` + `process.env.BUN_TEST`
- `"unknown"` — neither was detected
### `type TestRunner = "vitest" | "bun-test" | "unknown"`
## Implementation Notes
Detection reads multiple signals for each runner to remain robust against future runtime changes — relying on a single global is fragile. The function never reads from disk or spawns a child process, so it's safe to call at module-top during config import. For test files that want to statically import one runner's API, prefer importing `@suaveplan/testing-dom/runner` (which internally calls `detectRunner`) over writing the branch manually.

View File

@@ -0,0 +1,62 @@
# vitest-config
## Purpose
`createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloaded. It lives here so every package that imports `@suaveplan/testing-dom/config` gets the same `pool: "forks"`, `fileParallelism` heuristics, and setup-file wiring without copying the boilerplate into each `vitest.config.ts`.
## Features
- `pool: "forks"` — the only pool that consistently works under Bun on WSL2
- `fileParallelism` defaults to `false` for `happy-dom` / `jsdom` environments (avoids 20+ fork timeouts on WSL2)
- Prepends `@suaveplan/testing-dom/polyfills` to the user's `setupFiles`
- Accepts a `coverage` boolean (enables istanbul defaults) or a custom coverage object (pass-through)
- Appends user overrides to defaults rather than replacing them
## Usage: Basic Example
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { createVitestConfig } from "@suaveplan/testing-dom/config";
export default defineConfig(createVitestConfig(__dirname));
```
## Usage: Advanced Example
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { createVitestConfig } from "@suaveplan/testing-dom/config";
export default defineConfig(
createVitestConfig(__dirname, {
environment: "happy-dom",
coverage: true,
setupFiles: ["./tests/install-matchers.ts"],
include: ["tests/**/*.test.ts"],
fileParallelism: false,
testTimeout: 10_000,
})
);
```
## API Reference
### `createVitestConfig(callerDir, overrides): { test: Record<string, unknown> }`
- `callerDir` — the consumer package's root, passed to `root`; reserved for future alias resolution
- `overrides.environment` — `"node" | "happy-dom" | "jsdom"`; default `"node"`
- `overrides.coverage` — `true` turns on istanbul defaults with 95% thresholds; object form passes through verbatim
- `overrides.setupFiles` — appended to the polyfill import
- `overrides.include` / `overrides.exclude` — appended to defaults
- `overrides.testTimeout` — default 5000
- `overrides.fileParallelism` — default `false` for `happy-dom`, `true` otherwise
### `interface VitestConfigOverrides`
See source for the full shape; every field is optional.
## Implementation Notes
The returned shape is intentionally untyped from vitest's internals — consumers spread or merge it without tight coupling to the library's evolving generic parameters. The polyfill setup file is always prepended (not appended) so the user's own setup files can assume `globalThis.crypto` / `TextEncoder` are present. Coverage defaults live inline in this file rather than being imported from elsewhere so the produced config has zero runtime coupling to other `testing-dom` modules.

View File

@@ -0,0 +1,66 @@
# codes
## Purpose
`TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING_DOM.` namespace so consumers can match on the prefix to spot testing-library failures in aggregated logs. The registry is hand-built here rather than going through `defineCodes` from `@suaveplan/error` because this package has zero `@suaveplan/*` deps by design.
## Features
- Frozen record of `TESTING_DOM.*` code strings
- `TestingDomCode` type alias for the discriminated union
- Stable — new codes append; removal requires a deprecation cycle
- Structural twin of the `defineCodes` output used by Layer 1 packages
## Usage: Basic Example
```ts
import { TESTING_DOM_CODES } from "@suaveplan/testing-dom";
TESTING_DOM_CODES.RENDER_FAILED; // "TESTING_DOM.RENDER.FAILED"
```
## Usage: Advanced Example
```ts
import {
TestingDomError,
TESTING_DOM_CODES,
type TestingDomCode,
} from "@suaveplan/testing-dom";
function classify(err: TestingDomError): "retry" | "fail" {
switch (err.code) {
case TESTING_DOM_CODES.USER_EVENT_INIT_FAILED:
return "retry";
case TESTING_DOM_CODES.RENDER_FAILED:
case TESTING_DOM_CODES.QUERY_FAILED:
return "fail";
}
}
function isTestingDomError(err: unknown): err is TestingDomError {
return err instanceof TestingDomError;
}
```
## API Reference
### `TESTING_DOM_CODES`
Frozen record of reason → fully-qualified code string.
### `type TestingDomCode`
Union of every `TESTING_DOM_CODES[K]` value.
## Implementation Notes
The runtime record is built by hand (`Object.freeze({ ... })`) rather than via `defineCodes` to maintain the zero-workspace-dep constraint. Adding a new code: append to the record, append to the union type, and keep existing entries unchanged. The package's own test suite verifies that every entry carries a `TESTING_DOM.` prefix and that no two entries share a value.
### Registry Layout
Codes live under the `TESTING.DOM` namespace and are produced by `defineCodes` — same pattern as every other Suaveplan package. Current codes cover render host mount failures, query helper missing-element errors, matcher setup failures, and polyfill double-install attempts. The `TestingDomCode` union is the canonical parameter type for catch blocks and error-helper signatures.
### Stability
Adding a new code is append-only: no renumbering, no migrations. Consumers that type-narrow on `code` get a compile error when a new code is added without a matching branch only if they use the `TestingDomCode` union exhaustively (e.g. in a `switch` with `never` check).

View File

@@ -0,0 +1,61 @@
# testing-dom-error
## Purpose
`TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT extend `@suaveplan/error`'s `SuaveplanError` because testing-infrastructure packages must stay free of any `@suaveplan/*` workspace dependency (otherwise they'd pull in the packages they're supposed to be testing). Its shape mirrors `SuaveplanError` structurally — `{ code, cause?, context? }` — so consumer ergonomics are identical.
## Features
- `code` narrowed to `TestingDomCode` — one of the `TESTING_DOM.*` literals
- Carries `cause` (via ES2022 `Error.cause`) and optional `context`
- Named stack trace — the `name` property is set to the subclass name
- Structural twin of `SuaveplanError` — consumers can polymorphise if they accept both shapes
## Usage: Basic Example
```ts
import { TestingDomError, TESTING_DOM_CODES } from "@suaveplan/testing-dom";
throw new TestingDomError(
TESTING_DOM_CODES.RENDER_FAILED,
"renderComponent: tree returned no roots"
);
```
## Usage: Advanced Example
```ts
import {
renderComponent,
TestingDomError,
TESTING_DOM_CODES,
} from "@suaveplan/testing-dom";
try {
renderComponent(<Broken />);
} catch (err) {
if (err instanceof TestingDomError) {
switch (err.code) {
case TESTING_DOM_CODES.RENDER_FAILED:
console.error("render failed:", err.message, err.cause);
break;
case TESTING_DOM_CODES.USER_EVENT_INIT_FAILED:
console.error("user-event init failed:", err.cause);
break;
}
}
}
```
## API Reference
### `class TestingDomError extends Error`
- `new TestingDomError(code, message, options?)`
- `options.cause?: unknown`
- `options.context?: Readonly<Record<string, unknown>>`
- `err.code: TestingDomCode` — literal union from `TESTING_DOM_CODES`
## Implementation Notes
The class does not extend `@suaveplan/error` because of rule #8 in `CLAUDE.md`: testing-infrastructure packages have ZERO `@suaveplan/*` deps to avoid the "tests depend on packages under test" circularity. Structurally, the shape matches `SuaveplanError` so if a consumer catches `TestingDomError` alongside `SuaveplanError` from application code, the two look identical at the shape level. If the project later decides this constraint is too strict, the migration is a one-line base-class swap and all callers stay unchanged.

View File

@@ -0,0 +1,60 @@
# matchers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `matchers` module is a side-effect import that extends the current test runner's `expect` with the `@testing-library/jest-dom` matchers (`toBeInTheDocument`, `toHaveAttribute`, `toHaveTextContent`, etc.). It's loaded via vitest's `setupFiles` or bun-test's `preload` rather than imported per-test so the extended matchers are available in every test file without ceremony.
## Features
- One-shot registration — calls `expect.extend(jestDomMatchers)` at import time
- No API surface of its own — it's a side-effect module
- Compatible with vitest and bun-test (both support `expect.extend`)
- Matchers include `toBeInTheDocument`, `toBeVisible`, `toHaveFocus`, `toHaveAttribute`, `toHaveClass`, and the full jest-dom set
## Usage: Basic Example
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { createVitestConfig } from "@suaveplan/testing-dom/config";
export default defineConfig(
createVitestConfig(__dirname, {
setupFiles: ["@suaveplan/testing-dom/matchers"],
})
);
```
## Usage: Advanced Example
```tsx
// Any test file, once matchers are installed via setupFiles:
import { render, screen } from "@suaveplan/testing-dom";
test("renders a labelled button", () => {
render(<button aria-label="save">save</button>);
expect(screen.getByRole("button", { name: "save" }))
.toBeInTheDocument();
expect(screen.getByRole("button", { name: "save" }))
.toHaveTextContent("save");
expect(screen.getByRole("button", { name: "save" }))
.toHaveAttribute("aria-label", "save");
});
```
## API Reference
### Side-effect import
No exports. The single act is `expect.extend(matchers)` at module top-level using matchers imported from `@testing-library/jest-dom`.
## Implementation Notes
The module intentionally has no exports to prevent consumers from trying to pass the matchers object around — side-effect imports are the only supported usage. If a setup-file chain needs multiple side-effect imports, list them in `setupFiles` in the order they should execute (polyfills first, matchers second). The import is idempotent: calling `expect.extend` twice with the same matchers is a no-op under both vitest and bun-test.
### Matcher Index
The wrapper installs every `@testing-library/jest-dom` matcher (`toBeInTheDocument`, `toHaveTextContent`, `toBeVisible`, `toHaveAttribute`, `toHaveClass`, `toBeDisabled`, `toHaveValue`, `toHaveFocus`, etc.) into both Vitest's `expect` and Bun's test runner expect surface via a single `registerMatchers()` call. Double-install is idempotent: the second call is a no-op.

View File

@@ -0,0 +1,64 @@
# instrumented
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instrumented` wraps a named function with optional OpenTelemetry instrumentation — counters for invocations, histograms for durations, span events for failures. Instrumentation is **gated** behind the `TESTING_DOM_OTEL_ENABLED` environment variable, so production test runs pay zero overhead while CI or local profiling sessions can flip the flag on to surface hot-path data.
## Features
- Gated at import time via an environment variable check; no-op when disabled
- Uses only `@opentelemetry/api` (no SDK dependency) — consumers wire the actual exporters at bootstrap
- Preserves the wrapped function's signature and return type
- Handles both sync and async functions, including rejection paths
- Zero allocation overhead when gated off (returns the original function)
## Usage: Basic Example
```ts
import { instrumented } from "@suaveplan/testing-dom/observability";
const render = instrumented("testing_dom.render", (tree: unknown) => {
return reactRender(tree);
});
render(<App />); // counter incremented, histogram timed — if enabled
```
## Usage: Advanced Example
```ts
import { instrumented } from "@suaveplan/testing-dom/observability";
class TestingHarness {
public setup = instrumented("testing_dom.harness.setup", async () => {
await this.loadFixtures();
await this.mountApp();
});
public teardown = instrumented("testing_dom.harness.teardown", async () => {
await this.unmountApp();
});
}
// Flag-off: `const harness = new TestingHarness(); await harness.setup();`
// runs at the same cost as the unwrapped method.
// Flag-on: every call emits a counter and a histogram observation.
```
## API Reference
### `instrumented<TFn>(name, fn): TFn`
- `name` — metric name suffix; the full metric becomes `testing_dom.<name>.count` / `duration_ms` / `errors`
- `fn` — any function (sync or async)
- Returns `TFn` — a function with the same signature, instrumented when enabled
### Environment gating
`TESTING_DOM_OTEL_ENABLED` must equal `"1"` / `"true"` at module-import time for instrumentation to activate.
## Implementation Notes
The gate is evaluated once, at module import — flipping the env var mid-process has no effect. This avoids per-call environment reads in the hot path. When the gate is off, `instrumented` returns the original function reference unchanged, so V8 inlines as if the wrapper was never there. The `@opentelemetry/api` import is a marker package — it provides no-op tracers/meters when no SDK is registered, so even with the gate on, unconfigured environments pay only the cost of a no-op counter increment.

View File

@@ -0,0 +1,64 @@
# queries
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `queries` module re-exports the `@testing-library/react` query primitives — `screen`, `within`, `byRole`, `byLabelText`, `byPlaceholderText`, `byText`, `byDisplayValue`, `byAltText`, `byTitle`, `byTestId` — under one canonical import path so every package in the project reaches for the same API without individually depending on `@testing-library/react`. Keeping the re-export surface in one module also means upgrades to `@testing-library/react` touch a single file.
## Features
- Curated re-exports of every stable `@testing-library/react` query
- `screen` and `within` helpers included for scoped queries
- Types preserved — TS jumps straight to `@testing-library/react`'s definitions
- Zero runtime overhead — pure re-export
## Usage: Basic Example
```tsx
import { render, screen } from "@suaveplan/testing-dom";
render(<button>save</button>);
expect(screen.getByRole("button", { name: "save" })).toBeInTheDocument();
```
## Usage: Advanced Example
```tsx
import { render, screen, within } from "@suaveplan/testing-dom";
render(
<form aria-label="login">
<input aria-label="email" />
<input aria-label="password" type="password" />
<button type="submit">sign in</button>
</form>
);
const form = screen.getByRole("form", { name: "login" });
const scoped = within(form);
expect(scoped.getByLabelText("email")).toBeInTheDocument();
expect(scoped.getByRole("button", { name: "sign in" })).toBeEnabled();
```
## API Reference
### Re-exported symbols
- `screen` — global query root
- `within(container)` — scoped queries
- `getByRole` / `queryByRole` / `findByRole` — role-based
- `getByLabelText` / `queryByLabelText` / `findByLabelText`
- `getByPlaceholderText` / …
- `getByText` / …
- `getByDisplayValue` / …
- `getByAltText` / …
- `getByTitle` / …
- `getByTestId` / …
All retain their upstream type signatures.
## Implementation Notes
Rule #4 in `CLAUDE.md` forbids re-exporting external dependencies "for convenience" — the exception here is that this module serves as a project-wide swap point if we ever replace testing-library with another library. Every import path consumers use is `@suaveplan/testing-dom`, so a swap is a one-file change. The curated surface excludes `fireEvent` because userEvent (in `setup-user.ts`) is the preferred ergonomic wrapper — `fireEvent` is available via direct `@testing-library/react` import for callers who need the lower level.

View File

@@ -0,0 +1,60 @@
# render-component
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`renderComponent` wraps `@testing-library/react`'s `render` with two project-wide defaults: (1) it calls `cleanup()` before mounting so orphaned trees from a previous test never pollute the current test's query space, and (2) it throws `TestingDomError("TESTING_DOM.RENDER.FAILED")` on any underlying failure so callers always see a typed error with structured context.
## Features
- Automatic pre-mount `cleanup()` — no stray DOM from prior tests
- Typed failure mode via `TestingDomError`
- Thin wrapper — returns the same `RenderResult` as upstream
- Accepts the full `RenderOptions` surface (`container`, `baseElement`, `hydrate`, `wrapper`)
## Usage: Basic Example
```tsx
import { renderComponent } from "@suaveplan/testing-dom";
const { getByRole } = renderComponent(<button>save</button>);
expect(getByRole("button", { name: "save" })).toBeInTheDocument();
```
## Usage: Advanced Example
```tsx
import { renderComponent, TestingDomError, TESTING_DOM_CODES } from "@suaveplan/testing-dom";
import { MyProvider } from "./MyProvider";
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<MyProvider>{children}</MyProvider>
);
try {
const { container, rerender } = renderComponent(<App />, { wrapper: Wrapper });
expect(container.firstChild).toHaveTextContent("Hello");
rerender(<App locale="fr" />);
expect(container.firstChild).toHaveTextContent("Bonjour");
} catch (err) {
if (err instanceof TestingDomError && err.code === TESTING_DOM_CODES.RENDER_FAILED) {
console.error("render failed:", err.cause);
}
throw err;
}
```
## API Reference
### `renderComponent(ui, options?): RenderResult`
- `ui` — React element or functional component
- `options` — passed through to upstream `render` (full `RenderOptions` surface)
- Returns the upstream `RenderResult` unchanged
- Throws `TestingDomError("TESTING_DOM.RENDER.FAILED")` on any underlying failure, preserving `cause`
## Implementation Notes
Calling `cleanup()` BEFORE mount (rather than after) is deliberate — it means a test file that forgot to register `afterEach(cleanup)` still gets isolated trees between tests. It also avoids the subtle afterEach-ordering issue where a cleanup hook might fire before the test's expectations if another `afterEach` throws. Catching and rethrowing as `TestingDomError` normalises the error surface for downstream code; the underlying testing-library error lives on `err.cause` for debugging.

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,100 @@
# Runner — vitest / bun-test conditional re-exports
<!-- 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
The runner barrel re-exports the test primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `vi`) plus a runner-neutral mock surface (`fn`, `spy`, `stub`, `mock`, `fakeClock`, `restoreMocks`, `restoreTimers`). Two backends are supported: vitest (the default) and bun's built-in `bun:test`. Which backend resolves at import time is driven by `package.json` conditional exports — under the `bun` condition Node resolves `./dist/runner/bun-test.js`; under `vitest` or `default` it resolves `./dist/index.js` which barrels in `vitest.ts`.
The mock helpers exist so test code can stay runner-agnostic. A package that uses `fn()` / `spy()` / `stub()` instead of importing `vi` directly will run unchanged when its consumers swap test runners — no test-source rewrites required. This is the pattern the original `2026-04-08-testing-libraries` change established and we preserve it here.
## Features
- Re-exports of `vitest` primitives under the default / vitest conditions
- `bun:test` re-exports under the bun condition with API-shaped equivalents (`vi` is aliased to bun's `jest` namespace)
- Runner-neutral mock helpers: `fn`, `spy`, `stub`, `mock(modulePath)`, `fakeClock`, `restoreMocks`, `restoreTimers`
- `mock(modulePath, factory?)` returns a `{ restore() }` handle for symmetry across runners
- `fakeClock()` exists for legacy compatibility — new code should prefer the deterministic `FakeClock` class from `@suaveplan/testing/clock/fake-clock`
## Usage
### Basic example — runner-neutral test
```ts
import {
describe,
it,
expect,
fn,
spy,
} from "@suaveplan/testing";
describe("user service", () => {
it("calls the dependency exactly once", { timeout: 5000 }, () => {
const dep = { fetch: () => "real" };
const handle = spy(dep, "fetch");
const callback = fn();
callback("hello");
expect(callback).toHaveBeenCalledWith("hello");
expect(handle).toHaveBeenCalledTimes(0);
});
});
```
### Advanced example — module mocking with restore
```ts
import { mock, restoreMocks } from "@suaveplan/testing";
const handle = mock("./network.js", () => ({
fetchUser: () => ({ id: 42 }),
}));
// run tests against the mocked module ...
handle.restore();
restoreMocks();
```
### Switching to bun-test
In a consumer's `package.json`, set the `bun` condition (or run via `bun test`). Resolution picks `@suaveplan/testing/dist/runner/bun-test.js` automatically; no test-source changes are needed.
## API Reference
### Test primitives
`describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `vi` — re-exported from the active runner.
### Mock helpers
- `fn<TArgs, TReturn>(implementation?): Mock`
- `spy<T, K>(obj: T, method: K): SpyHandle`
- `stub<T, K>(obj: T, method: K, implementation?): SpyHandle`
- `mock(modulePath: string, factory?: () => unknown): { restore() }`
- `fakeClock(): { advance(ms), restore() }`
- `restoreMocks(): void`
- `restoreTimers(): void`
## Implementation Notes
- **`vi` on the bun path is aliased to `jest`.** Bun-test's mock surface lives under `jest` for API compatibility; the alias keeps consumer code identical across runners.
- **`fakeClock` is legacy.** Prefer the deterministic `FakeClock` class from the clock module — it's runner-independent, supports `Clock` injection across the package, and gives you `snapshot()` / `restore()` for fine control.
- **`mock()` on bun has a no-op `restore()`** because `bun:test` does not expose `unmock`. Call `restoreMocks()` (which delegates to `jest.restoreAllMocks()`) to reset state.
- **The conditional-exports routing** is load-bearing — see `package.json#exports.".".bun` and `.vitest`. Removing either branch breaks the dual-runner contract documented in the spec.
- **Type safety on cross-runner mocks** is approximate. The wrappers use `as never` casts internally because the spy/stub generics differ between the two libraries; consumer code sees a stable shape.

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,61 @@
# setup/polyfills
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `setup/polyfills` module is a side-effect import that installs the globals test code expects to be present — `crypto`, `TextEncoder`, `TextDecoder` — when the host runtime (bare Node, happy-dom, jsdom, or Bun) doesn't already provide them. It's idempotent and safe to import multiple times.
## Features
- Installs `globalThis.crypto` from Node's `webcrypto` when missing
- Installs `TextEncoder` / `TextDecoder` from `node:util` when missing
- Guards every assignment with an existence check — never overwrites a populated global
- Exports nothing — side effects only
## Usage: Basic Example
```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { createVitestConfig } from "@suaveplan/testing-dom/config";
export default defineConfig(
createVitestConfig(__dirname, {
setupFiles: ["@suaveplan/testing-dom/polyfills"],
})
);
```
## Usage: Advanced Example
```ts
// bunfig.toml (bun-test)
// [test]
// preload = ["@suaveplan/testing-dom/polyfills", "./tests/install-matchers.ts"]
// Or programmatically via createBunTestConfig:
import { createBunTestConfig } from "@suaveplan/testing-dom/config";
const cfg = createBunTestConfig(__dirname, {
preload: ["./tests/install-matchers.ts"],
// createBunTestConfig adds the polyfills entry automatically.
});
```
## API Reference
No exports. Side effects:
- `globalThis.crypto ??= webcrypto` _(from `node:crypto`)_
- `globalThis.TextEncoder ??= NodeTextEncoder`
- `globalThis.TextDecoder ??= NodeTextDecoder`
## Implementation Notes
Modern Node (≥19) and Bun both ship `crypto` globally, so the `??=` short-circuits on those hosts. The module remains in the package for older Node versions and for happy-dom sandboxes that don't initialise `crypto`. Imports use `node:` specifiers so the code remains bundler-safe — Rollup/esbuild skip it when externalising node built-ins. Because the module exports nothing, consumers who mistakenly try `import { x } from "@suaveplan/testing-dom/polyfills"` get a useful TypeScript error rather than silent misbehaviour.
### Coverage
The polyfill bundle installs happy-dom's `DOMParser`, `XMLSerializer`, `fetch`, `Request`, `Response`, `Headers`, `URL`, `URLSearchParams`, and `crypto.getRandomValues` into the Node / Bun global scope when they are missing. Double-install is idempotent — the module tracks a `Symbol.for("suaveplan.testing-dom.polyfilled")` marker on `globalThis` so repeated imports are cheap.

View File

@@ -0,0 +1,63 @@
# setup-user
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`setupUser` returns a configured `@testing-library/user-event` v14 instance with project-wide defaults appropriate for happy-dom. The wrapper exists so every test that simulates user input gets the same `delay`, `pointerEventsCheck`, and timer handling — consumers don't have to remember the boilerplate and upgrades of `user-event` touch a single file.
## Features
- Defaults tuned for happy-dom (no `delay`, no pointer-event checks — happy-dom treats every node as clickable)
- Forwards caller overrides so individual tests can opt-in to stricter pointer checks
- Respects vitest's fake-timers — temporarily swaps them for real timers while user-event internals await
- Returns the fully-typed `UserEvent` instance from `@testing-library/user-event`
## Usage: Basic Example
```tsx
import { renderComponent, setupUser, screen } from "@suaveplan/testing-dom";
test("typing", async () => {
renderComponent(<input aria-label="name" />);
const user = setupUser();
await user.type(screen.getByLabelText("name"), "alice");
expect(screen.getByLabelText("name")).toHaveValue("alice");
});
```
## Usage: Advanced Example
```tsx
import { renderComponent, setupUser, screen } from "@suaveplan/testing-dom";
import { vi } from "@suaveplan/testing-dom/runner";
test("debounced save uses fake timers", async () => {
vi.useFakeTimers();
try {
renderComponent(<DebouncedForm />);
const user = setupUser({ advanceTimers: vi.advanceTimersByTime });
await user.type(screen.getByLabelText("title"), "new title");
vi.advanceTimersByTime(500);
expect(screen.getByText("saved")).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
```
## API Reference
### `setupUser(options?): UserEvent`
- `options` — passed through to upstream `userEvent.setup(options)`
- Returns a `UserEvent` instance (upstream type preserved)
## Implementation Notes
The wrapper supplies `{ delay: null, pointerEventsCheck: 0 }` as defaults because happy-dom's layout engine is minimal — simulating realistic pointer-event checks wastes test time without surfacing additional bugs. Tests that need the full browser-like semantics should run under Playwright (the `testing-e2e` package) rather than trying to coerce happy-dom into matching. The fake-timers integration uses try/finally around `vi.useFakeTimers()` so a failed test leaves the runtime in a known-good real-timer state for subsequent tests.
### User Events
`setupUser()` wraps `@testing-library/user-event`'s `userEvent.setup()` with sensible Suaveplan defaults: `pointerEventsCheck: 0` (off, because happy-dom doesn't model pointer-events reliably), `delay: null` for deterministic tests, and automatic advance of Vitest fake timers when they're active. The returned object has the full `click` / `type` / `hover` / `keyboard` / `upload` API surface.

View File

@@ -0,0 +1,280 @@
# @suaveplan/testing-e2e - Stealth Playwright E2E utilities backed by cloakbrowser — config factory, page objects, fixtures, helpers, structured errors. Drop-in browser-launch swap that passes Cloudflare Turnstile, reCAPTCHA v3, FingerprintJS. Layer 0; no @suaveplan/* runtime deps
## Summary
Playwright E2E testing utilities for Suaveplan, with the
default `browser` worker fixture launching
[`cloakbrowser`](https://github.com/CloakHQ/cloakbrowser) — a real
Chromium binary with 49 source-level C++ fingerprint patches — instead
of the stock Playwright Chromium. Every spec that imports
`extendedTest` from this package gets `navigator.webdriver === false`,
real GPU/canvas/audio/screen/WebRTC fingerprints, a normal
`window.chrome`, no `HeadlessChrome` UA leak, and no CDP detection
signal. Cloudflare Turnstile, reCAPTCHA v3 (0.9 score), FingerprintJS,
BrowserScan, and ShieldSquare see a normal browser.
The rest of the package is unchanged: a config factory with WSL2/CI
defaults, an abstract `PageObject` base, an `apiContext` fixture that
deliberately does NOT depend on `browser`, network/console monitors,
multi-session orchestration, OTel-instrumented entry points, and a
structured `TestingE2eError` hierarchy (now with `CLOAK_*` codes).
Layer 0 — no `@suaveplan/*` runtime dependencies.
### What the swap changes
- `extendedTest.browser` is launched via `cloakbrowser.launch`. The
returned object is still a Playwright `Browser` from
`playwright-core` — every fixture, page object, helper, and monitor
works unchanged.
- A new worker option `cloak: CloakOptions` flows into the launcher.
Set per project in `playwright.config.ts`:
`use: { cloak: { humanize: true, geoip: true, proxy: "…" } }`.
- `launchWithExtension` defaults to
`cloakbrowser.launchPersistentContext` and exposes a
`launchPersistentContext` injector for tests / profile managers.
- Failures classify into `TESTING_E2E.CLOAK.{LAUNCH_FAILED,BINARY_MISSING,PROXY_INVALID}`
via `classifyCloakError` + `wrapCloakError`, wrapped in
`CloakLaunchError`.
- `@playwright/test` stays as the test runner — cloakbrowser is a
drop-in browser-launch replacement, not a runner replacement.
- **No public re-exports of `cloakbrowser` symbols.** Per CLAUDE.md
rule 4, when bypassing the fixture layer, consumers
`import { launch } from "cloakbrowser"` directly.
### Binary footprint
On first launch the stealth Chromium binary (~200 MB) is auto-
downloaded to `~/.cache/cloakbrowser/`. CI runners pre-cache it via
`npx cloakbrowser install` in the setup step (parity with
`playwright install chromium`).
<!-- begin:auto-docs -->
## Documentation tree
- [build-cloakserve-url](./src/cloakserve/build-cloakserve-url.md) — `buildCloakServeUrl` constructs the WebSocket / HTTP endpoint URL for `cloakserve`, the cloakbrowser CDP multiplexer th…
- [cloakserve-connect](./src/cloakserve/cloakserve-connect.md) — `connectCloakServe` connects Playwright to a running `cloakserve` CDP multiplexer over `chromium.connectOverCDP(...)`,…
- [config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — `launchWithExtension` starts a stealth Chromium persistent browser context with an unpacked browser extension loaded, t…
- [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — `launchWithExtension` starts a stealth Chromium persistent browser context with an unpacked browser extension loaded, t…
- [fixtures](./src/fixtures/extended-test.md) — `extendedTest` is the Playwright `test` function pre-extended with the fixtures this package ships, and — critically —…
- [extended-test](./src/fixtures/extended-test.md) — `extendedTest` is the Playwright `test` function pre-extended with the fixtures this package ships, and — critically —…
- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [presets](./src/presets/stealth-presets.md) — Ready-made `CloakOptions` bundles for the most common cloakbrowser deployment shapes. Pulled from the official CloakBro…
- [stealth-presets](./src/presets/stealth-presets.md) — Ready-made `CloakOptions` bundles for the most common cloakbrowser deployment shapes. Pulled from the official CloakBro…
<!-- end:auto-docs --> - [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixtures](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [extended-test](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixtures](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [extended-test](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixtures](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [extended-test](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-playwright-config](./src/config/create-playwright-config.md) — <!-- begin:auto-docs --> _(no submodules)_- [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [console-monitor](./src/console-monitor/console-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry…
- [testing-e2e-error](./src/errors/testing-e2e-error.md) — `TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing…
- [extension-fixture](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_ - [launch-with-extension](./src/extension-fixture/launch-with-extension.md) — <!-- begin:auto-docs --> _(no submodules)_- [fixtures](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_ - [extended-test](./src/fixtures/extended-test.md) — <!-- begin:auto-docs --> _(no submodules)_- [dismiss-dialog](./src/helpers/dismiss-dialog.md) — `dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `ac…
- [mock-clipboard](./src/helpers/mock-clipboard.md) — `mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright p…
- [mock-geolocation](./src/helpers/mock-geolocation.md) — `mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware fea…
- [screenshot-on-failure](./src/helpers/screenshot-on-failure.md) — `screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` re…
- [wait-for-network-idle](./src/helpers/wait-for-network-idle.md) — `waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive…
- [multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_ - [create-multi-session](./src/multi-session/create-multi-session.md) — <!-- begin:auto-docs --> _(no submodules)_- [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [network-monitor](./src/network-monitor/network-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [page-objects](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_ - [page-object](./src/page-objects/page-object.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/testing-e2e
```
Requires `@playwright/test` and `cloakbrowser` (installed automatically as direct dependencies). On first launch cloakbrowser auto-downloads the stealth Chromium binary; pre-cache in CI with `npx cloakbrowser install`.
## Quick Start
### Playwright config
```ts
// playwright.config.ts
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
export default createPlaywrightConfig({
baseURL: "http://localhost:3000",
testDir: "e2e",
projects: [
{ name: "chromium", use: { browserName: "chromium" } },
],
});
```
### Page object + stealth extended fixtures
```ts
// playwright.config.ts — cloak options for the default browser fixture
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
export default createPlaywrightConfig({
baseURL: "http://localhost:3000",
testDir: "e2e",
use: {
cloak: {
headless: true,
humanize: true,
geoip: true,
proxy: "socks5://user:pass@residential:1080",
},
},
});
```
```ts
// e2e/pages/login.page.ts
import { PageObject } from "@suaveplan/testing-e2e/page-objects";
export class LoginPage extends PageObject {
public readonly path = "/login";
get emailInput() { return this.findByLabel("Email"); }
get submitButton() { return this.findByRole("button", { name: /sign in/i }); }
}
// e2e/login.spec.ts — runs against a Cloudflare-walled app
import { extendedTest as test, expect } from "@suaveplan/testing-e2e/fixtures";
import { waitForNetworkIdle } from "@suaveplan/testing-e2e/helpers";
import { LoginPage } from "./pages/login.page.js";
test("signs in through Cloudflare Turnstile", async ({ page }) => {
const login = new LoginPage(page);
await login.navigate(); // Turnstile lets us through
await login.emailInput.fill("user@example.com");
await login.submitButton.click();
await waitForNetworkIdle(page);
await expect(page).toHaveURL("/dashboard");
});
```
### Programmatic launch (outside the Playwright runner)
```ts
import { launchBrowserWithCloak } from "@suaveplan/testing-e2e/fixtures";
const browser = await launchBrowserWithCloak({
humanize: true,
geoip: true,
proxy: "http://user:pass@proxy:8080",
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("https://bot.incolumitas.com");
console.log(await page.title());
await browser.close();
```
### Extension fixture (stealth + persistent profile)
```ts
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
const { context, extensionId } = await launchWithExtension({
extensionPath: "/path/to/unpacked-extension",
userDataDir: "/tmp/profile",
launchOptions: { humanize: true, geoip: true },
});
```
### Cloakserve CDP server (shared container, N parallel workers)
```bash
# One container, all your test workers share it
cd packages/testing/testing-e2e/docker
docker compose up -d cloakserve
```
```ts
import { connectCloakServe, stealthMax } from "@suaveplan/testing-e2e";
const browser = await connectCloakServe({
url: "http://localhost:9222",
fingerprint: 11111, // per-worker seed
timezone: "America/New_York",
locale: "en-US",
proxy: process.env.RESIDENTIAL_PROXY,
geoip: true,
});
```
### Stealth presets
```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import { stealthMax } from "@suaveplan/testing-e2e";
export default defineConfig({
use: {
// Upstream-recommended config: residential proxy + geoip + headed + humanize.
cloak: stealthMax(process.env.RESIDENTIAL_PROXY!),
},
});
```
`STEALTH_DEFAULT_CLOAK` / `STEALTH_HEADED_CLOAK` are also exported
for the lighter shapes. See `src/presets/stealth-presets.md` for the
full menu.
### Docker bundle
`packages/testing/testing-e2e/docker/` ships:
- `Dockerfile.cloakserve` — extends `cloakhq/cloakbrowser`, adds the
Kasada / Akamai font set, runs `cloakserve` on 9222
- `Dockerfile.manager` — extends `cloakhq/cloakbrowser-manager`,
exposes 8080 with a `cloakprofiles` named volume for persistence
- `docker-compose.yml` — both services on `127.0.0.1`, with the
manager gated behind the `manager` compose profile
- `.env.example` — pinnable `CLOAKBROWSER_TAG`, `MANAGER_TAG`, and
`MANAGER_AUTH_TOKEN`
See `docker/README.md` for the full operator playbook.
## Modules
| Subpath | Module | Description |
|---|---|---|
| `@suaveplan/testing-e2e/config` | [src/config/create-playwright-config.md](src/config/create-playwright-config.md) | `createPlaywrightConfig` — emits a `PlaywrightTestConfig` with WSL2-safe defaults (`fullyParallel: false`, trace/screenshot/video retention) and automatic CI mode (`retries: 2`, `workers: 1`) |
| `@suaveplan/testing-e2e/page-objects` | [src/page-objects/page-object.md](src/page-objects/page-object.md) | Abstract `PageObject` base class — typed wrapper around a Playwright `Page` with `navigate()`, `waitForReady()`, and accessibility-first locator helpers (`findByRole`, `findByLabel`, `findByText`) |
| `@suaveplan/testing-e2e/fixtures` | [src/fixtures/extended-test.md](src/fixtures/extended-test.md) | `extendedTest` — `test.extend(...)` with the `browser` worker fixture overridden to launch `cloakbrowser`; plus `apiContext` (isolated `APIRequestContext`, browser-free) and `freshPage` (isolated browser context + page); also exports `launchBrowserWithCloak` and `expect` |
| `@suaveplan/testing-e2e/helpers` | [src/helpers/wait-for-network-idle.md](src/helpers/wait-for-network-idle.md) | `waitForNetworkIdle` — polls in-flight request count and resolves once the network has been idle for `idleMs` (default 500 ms); throws `WaitTimeoutError` on timeout |
| | [src/helpers/mock-clipboard.md](src/helpers/mock-clipboard.md) | `mockClipboard` — installs a programmatic in-page clipboard so tests can assert `writeText`/`readText` without OS permissions |
| | [src/helpers/mock-geolocation.md](src/helpers/mock-geolocation.md) | `mockGeolocation` — grants geolocation permission and sets static latitude/longitude/accuracy on a `BrowserContext` |
| | [src/helpers/screenshot-on-failure.md](src/helpers/screenshot-on-failure.md) | `screenshotOnFailure` — captures a full-page screenshot named after the failing test; useful as a manual teardown hook alongside Playwright's built-in artifact retention |
| | [src/helpers/dismiss-dialog.md](src/helpers/dismiss-dialog.md) | `dismissDialog` — registers a one-shot `dialog` handler that accepts or dismisses the next browser dialog (`alert`, `confirm`, `prompt`) |
| `@suaveplan/testing-e2e/errors` | [src/errors/testing-e2e-error.md](src/errors/testing-e2e-error.md) | Typed error classes (`NavigationError`, `WaitTimeoutError`, `AbortedError`, `CloakLaunchError`) + `classifyCloakError` / `wrapCloakError` helpers |
| | [src/errors/codes.md](src/errors/codes.md) | `TESTING_E2E_ERROR_CODES` registry |
| _(observability)_ | [src/observability/instrumented.md](src/observability/instrumented.md) | `instrumented` — optional OTel wrapper (gated by `TESTING_E2E_OTEL_ENABLED=1`) that records call counts and duration histograms for package entrypoints |

View File

@@ -0,0 +1,76 @@
# Docker bundle for `@suaveplan/testing-e2e`
Two services that round-trip with `@suaveplan/testing-e2e`'s
stealth-Chromium fixtures.
## Services
| Service | Image | Port | Purpose |
| -------------- | ------------------------------------ | -------------- | --------------------------------------------------------------------------------------------- |
| `cloakserve` | `suaveplan-cloakserve:local` | `127.0.0.1:9222` | CDP multiplexer — one container, N parallel test workers, each picking a fingerprint seed. Pair with `connectCloakServe()`. |
| `manager` | `suaveplan-cloakbrowser-manager:local` | `127.0.0.1:8080` | Optional noVNC + REST profile manager (Multilogin / GoLogin / AdsPower alternative). Enable via the `manager` compose profile. |
Both services bind to `127.0.0.1` only. **Never expose 9222 or 8080
to the public internet** without a reverse proxy + auth — both grant
full browser control.
## Quick start
```bash
# CDP server only — recommended for most CI / dev loops
docker compose up -d cloakserve
# + GUI profile manager (enables the `manager` compose profile)
docker compose --profile manager up -d
# Tear down
docker compose down
```
Pin specific upstream tags via env vars:
```bash
CLOAKBROWSER_TAG=0.3.28 MANAGER_TAG=latest docker compose up -d cloakserve
```
Set an auth token on the manager before exposing past localhost:
```bash
MANAGER_AUTH_TOKEN="$(openssl rand -hex 32)" docker compose --profile manager up -d
```
## Connecting from tests
```ts
import { connectCloakServe } from "@suaveplan/testing-e2e";
const browser = await connectCloakServe({
url: "http://localhost:9222",
fingerprint: 11111,
timezone: "America/New_York",
locale: "en-US",
proxy: process.env.RESIDENTIAL_PROXY,
geoip: true,
});
```
Each unique `fingerprint` value spawns a separate Chromium process
inside the container. Reuse the same seed across runs to look like a
returning visitor (recommended for reCAPTCHA v3 / Enterprise scoring).
Use `testInfo.parallelIndex` as the seed source to give each worker an
isolated identity.
## Fonts
The `cloakserve` image bundles the font packages the upstream
CloakBrowser README flags as required for Kasada / Akamai:
- `fonts-noto-color-emoji` *(inherited from base)*
- `fonts-freefont-ttf`
- `fonts-unifont`
- `fonts-ipafont-gothic`
- `fonts-wqy-zenhei`
- `fonts-tlwg-loma-otf`
Without these, canvas emoji rendering hashes don't match any real
browser and aggressive bot detection flags the session.

View File

@@ -0,0 +1,92 @@
# build-cloakserve-url
## Purpose
`buildCloakServeUrl` constructs the WebSocket / HTTP endpoint URL for
[`cloakserve`](https://github.com/CloakHQ/CloakBrowser#cdp-server-mode),
the cloakbrowser CDP multiplexer that lets a single Docker container
expose multiple stealth Chromium identities keyed by fingerprint seed.
The cloakserve CDP endpoint accepts a documented set of query
parameters (`fingerprint`, `timezone`, `locale`, `platform`,
`gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`,
`screen-width`, `screen-height`, `proxy`, `geoip`, …) that each
control one facet of the spawned browser's identity. Same seed →
same Chromium process and fingerprint; different seed → fresh
isolated process. This helper builds the URL deterministically from a
strongly-typed options bundle.
## Features
- Pure synchronous URL builder — no I/O
- camelCase → kebab-case key mapping
(`hardwareConcurrency` → `hardware-concurrency`)
- Default base URL `http://localhost:9222` (cloakserve's default
bind); override via `url`
- Preserves existing query-string params on the base URL
- Stringifies booleans (`geoip: true` → `geoip=true`) and numbers
- Skips `undefined` / `null` values
## Usage: Basic Example
```typescript
import { buildCloakServeUrl } from "@suaveplan/testing-e2e";
const url = buildCloakServeUrl({ fingerprint: 11111 });
// → "http://localhost:9222/?fingerprint=11111"
```
## Usage: Advanced Example
```typescript
import { buildCloakServeUrl } from "@suaveplan/testing-e2e";
const url = buildCloakServeUrl({
url: "http://cloakserve.internal:9222",
fingerprint: 42069,
timezone: "Asia/Tokyo",
locale: "ja-JP",
platform: "macos",
gpuVendor: "Apple Inc.",
gpuRenderer: "Apple M2",
hardwareConcurrency: 8,
deviceMemory: 16,
screenWidth: 1440,
screenHeight: 900,
proxy: "socks5://user:pass@residential:1080",
geoip: true,
});
// → "http://cloakserve.internal:9222/?fingerprint=42069&timezone=Asia%2FTokyo
// &locale=ja-JP&platform=macos&gpu-vendor=Apple+Inc.&gpu-renderer=Apple+M2
// &hardware-concurrency=8&device-memory=16&screen-width=1440
// &screen-height=900&proxy=…&geoip=true"
```
## API Reference
### `buildCloakServeUrl(options?: CloakServeConnectOptions): string`
- `options.url` — base cloakserve endpoint URL. Default
`http://localhost:9222`
- All other fields in `CloakServeFingerprint` are appended as
query-string parameters using cloakserve's kebab-case key names
### `CloakServeConnectOptions`
See `cloakserve-options.types.ts` for the full type. Notable fields:
- `fingerprint: string | number` — master seed (returning-visitor
identity if held constant)
- `timezone`, `locale` — IANA / BCP-47 strings; routed through
cloakbrowser's undetectable binary flags (not CDP emulation)
- `proxy`, `geoip` — proxy URL + optional geo-IP enrichment
## Implementation Notes
Uses the WHATWG `URL` API so existing query params on the base URL
survive (`?customParam=x` is preserved when adding fingerprint params).
camelCase → kebab-case mapping is a static frozen table; new
parameters require both a `CloakServeFingerprint` field and a
`KEY_MAP` entry. The function is intentionally side-effect free so it
can be called inside hot paths (per-test URL generation in a fixture
runner with thousands of workers).

View File

@@ -0,0 +1,102 @@
# cloakserve-connect
## Purpose
`connectCloakServe` connects Playwright to a running
[`cloakserve`](https://github.com/CloakHQ/CloakBrowser#cdp-server-mode)
CDP multiplexer over `chromium.connectOverCDP(...)`, with the
fingerprint / proxy / geoip / locale parameters expressed as a
strongly-typed options bundle rather than a hand-rolled query string.
The helper exists so test suites can share a single Docker container
of `cloakhq/cloakbrowser` running `cloakserve` across many parallel
workers — each worker requests its own seeded fingerprint via a
URL parameter, cloakserve spawns (or reuses) a stealth Chromium
process for that seed, and you save the ~200 MB binary download +
launch latency that an in-process `cloakbrowser.launch()` per worker
would incur.
## Features
- Strongly-typed `CloakServeConnectOptions` (mirrors the documented
cloakserve query-param surface)
- Uses {@link buildCloakServeUrl} for URL composition
- Pluggable Playwright `chromium` driver (defaults to
`@playwright/test`'s built-in `chromium`)
- Errors classified by `classifyCloakError` and wrapped in a
`CloakLaunchError` with `cloakServeUrl` in `context`
- Cache-busting test hook (`_resetCloakServeChromiumCache`)
## Usage: Basic Example
```typescript
import { connectCloakServe } from "@suaveplan/testing-e2e";
const browser = await connectCloakServe({
url: "http://localhost:9222",
fingerprint: 11111,
});
const page = await browser.newPage();
await page.goto("https://protected-site.com");
```
## Usage: Advanced Example
```typescript
// Parallel workers share one cloakserve container.
import { connectCloakServe } from "@suaveplan/testing-e2e";
import { test } from "@suaveplan/testing-e2e";
test("worker-isolated identity", async ({}, testInfo) => {
// Per-worker seed gives each worker an isolated Chromium process.
const browser = await connectCloakServe({
url: process.env.CLOAKSERVE_URL ?? "http://localhost:9222",
fingerprint: 10000 + testInfo.parallelIndex,
timezone: "America/New_York",
locale: "en-US",
proxy: process.env.PROXY,
geoip: true,
});
try {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("https://example.com");
await ctx.close();
} finally {
await browser.close();
}
});
```
## API Reference
### `connectCloakServe(options?, chromium?): Promise<Browser>`
Connects to a cloakserve CDP endpoint and returns a Playwright
`Browser`. `chromium` defaults to `@playwright/test`'s built-in.
**Throws:** `CloakLaunchError` with
`TESTING_E2E.CLOAK.{LAUNCH_FAILED|BINARY_MISSING|PROXY_INVALID}`
when `connectOverCDP` rejects. `context.cloakServeUrl` carries the
URL that was attempted.
### `_resetCloakServeChromiumCache(): void`
Internal hook to reset the lazy dynamic-import cache. Exposed for
unit tests; production code should never call this.
## Implementation Notes
The `connectOverCDP` call goes through `@playwright/test`'s
`chromium` rather than `playwright-core` directly because tests in
this project already pins `@playwright/test` as the test runner and
Playwright re-exports `chromium` from there. The injected `chromium`
parameter only needs `connectOverCDP` — typed as
`Pick<BrowserType, "connectOverCDP">` so spies are cheap to write.
When pointing at a remote cloakserve, **never** expose port 9222 to
the public internet without an upstream auth layer. CDP gives full
control over the browser; see the security warning in the upstream
CloakBrowser README. The bundled `docker-compose.yml` in
`packages/testing/testing-e2e/docker/` binds to `127.0.0.1` by
default.

View File

@@ -0,0 +1,66 @@
# create-playwright-config
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createPlaywrightConfig` builds a ready-to-use Playwright config object with project-wide defaults: conservative trace/screenshot/video settings for local dev, stricter retries and single-worker execution under CI, and a consistent `fullyParallel: false` to keep ordering deterministic. It exists so every app and package that runs Playwright E2E tests starts from the same baseline without copying boilerplate.
## Features
- `fullyParallel: false` — deterministic ordering across test files
- `trace: "on-first-retry"`, `screenshot: "only-on-failure"`, `video: "retain-on-failure"` defaults
- CI mode (when `process.env.CI` is truthy): `forbidOnly: true`, `retries: 2`, `workers: 1`
- Caller overrides merge over defaults without clobbering unrelated fields
- `baseURL` propagates into `use` automatically
## Usage: Basic Example
```ts
// playwright.config.ts
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
export default createPlaywrightConfig({ baseURL: "http://localhost:3000" });
```
## Usage: Advanced Example
```ts
// playwright.config.ts
import { createPlaywrightConfig } from "@suaveplan/testing-e2e/config";
export default createPlaywrightConfig({
baseURL: process.env.BASE_URL ?? "http://localhost:3000",
timeout: 60_000,
globalTimeout: 600_000,
projects: [
{ name: "chromium", use: { browserName: "chromium" } },
{ name: "firefox", use: { browserName: "firefox" } },
],
use: {
// Override a default.
trace: "retain-on-failure",
},
reporter: [["list"], ["html", { open: "never" }]],
});
```
## API Reference
### `createPlaywrightConfig(options?: CreatePlaywrightConfigOptions): PlaywrightTestConfig`
- `options` — superset of `PlaywrightTestConfig` with an extra `baseURL` convenience at the top level
- Returns a fully-formed `PlaywrightTestConfig`
### Defaults applied
- `fullyParallel: false`
- `use.trace = "on-first-retry"`
- `use.screenshot = "only-on-failure"`
- `use.video = "retain-on-failure"`
- CI mode sets `forbidOnly: true`, `retries: 2`, `workers: 1`
## Implementation Notes
`baseURL` is accepted at the top level for ergonomics even though Playwright's own config puts it under `use.baseURL`. The factory forwards the top-level value into `use.baseURL` only if the caller didn't already supply one there. CI detection is a single `process.env.CI` read at call time — no env-var caching — so tests that want to simulate CI can set the variable in a `beforeEach`. Caller overrides flow through a standard rest-spread so arbitrary top-level Playwright fields (`timeout`, `globalTimeout`, `projects`, `reporter`, etc.) pass through untouched.

View File

@@ -0,0 +1,102 @@
# console-monitor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`attachConsoleMonitor` attaches a listener to a Playwright `Page` and captures every console message emitted during the test. Its `assertClean()` method fails the test if any captured message matches the configured severity levels, providing an automated gate against unexpected JavaScript errors, deprecation warnings, and other console noise that would otherwise require manual log inspection. It mirrors the DevTools console experience, making CI failures straightforward to reproduce locally by opening the same URL.
The monitor is designed for test-runner-agnostic use: it collects messages into a plain array so tests can inspect them directly, and it throws a standard `Error` so any test framework can catch it without adapter libraries.
## Features
- Registers a single `page.on("console")` listener — no polling, no page evaluation
- Configurable `failOn` severity levels: `"error"`, `"warning"`, `"info"` (defaults to `["error"]`)
- Per-message ignore regexp list for suppressing known-safe console noise (e.g. React DevTools banner)
- Normalises Playwright's `"warn"` type alias to `"warning"` for consistent level matching
- `assertClean()` throws with a multi-line message listing every failing console entry
- Exposes the raw `messages` array for test-specific assertions beyond what `assertClean` covers
## Usage: Basic Example
```typescript
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { test, expect } from "@playwright/test";
test("page loads without console errors", async ({ page }) => {
const monitor = attachConsoleMonitor(page);
await page.goto("http://localhost:3200/");
await page.waitForLoadState("networkidle");
monitor.assertClean();
});
```
## Usage: Advanced Example
```typescript
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { test } from "@playwright/test";
test("dashboard emits no errors or warnings, ignoring React DevTools banner", async ({ page }) => {
const monitor = attachConsoleMonitor(page, {
failOn: ["error", "warning"],
ignore: [
/Download the React DevTools/,
/ReactDOM.render is no longer supported/,
],
});
await page.goto("http://localhost:3200/dashboard");
await page.waitForSelector("[data-testid=dashboard-ready]");
// Can also inspect messages directly before asserting
const warnings = monitor.messages.filter((m) => m.type() === "warning");
console.log(`Captured ${warnings.length} warnings (all ignored)`);
monitor.assertClean();
});
```
## API Reference
### `attachConsoleMonitor(page: Page, options?: ConsoleMonitorOptions): ConsoleMonitor`
Registers a console message collector on the given Playwright page.
**Parameters:**
- `page` — Playwright `Page` instance to monitor
- `options.failOn` — array of severity levels that cause `assertClean()` to throw; defaults to `["error"]`
- `options.ignore` — array of `RegExp` patterns; messages whose text matches any pattern are excluded from failure consideration regardless of severity
**Returns:** `ConsoleMonitor`
- `.messages` — read-only array of all `ConsoleMessage` instances collected since `attachConsoleMonitor` was called
- `.assertClean()` — throws `Error` if any collected message matches a `failOn` level and is not suppressed by an `ignore` pattern; the error message includes a formatted list of every offending entry
### `ConsoleMonitor` interface
```typescript
interface ConsoleMonitor {
readonly messages: ConsoleMessage[];
assertClean(): void;
}
```
### `ConsoleMonitorOptions` interface
```typescript
interface ConsoleMonitorOptions {
failOn?: Array<"error" | "warning" | "info">;
ignore?: RegExp[];
}
```
## Implementation Notes
Playwright uses `"warn"` as a type string in some browser contexts despite DevTools calling it `"warning"`. The internal `PLAYWRIGHT_TYPE_MAP` normalises `"warn"` to `"warning"` so tests using `failOn: ["warning"]` reliably catch both variants without requiring callers to know about this inconsistency.
The `messages` getter returns the live internal array directly rather than a copy. This is intentional: test authors who iterate `messages` after `assertClean()` see exactly what was collected, with no copy overhead. Since tests run synchronously relative to the listener, mutation during iteration is not a concern in practice.
Type resolution for unrecognised console types (e.g. `"debug"`, `"verbose"`, `"table"`) falls back to `"info"`, which means they are only included in failures if `failOn` explicitly includes `"info"`.

View File

@@ -0,0 +1,79 @@
# codes
## Purpose
`TESTING_E2E_ERROR_CODES` is the frozen registry of every failure code `@suaveplan/testing-e2e` emits. All codes carry the `TESTING_E2E.` prefix so log filters can isolate Playwright-harness failures from application code. The registry is hand-built (not via `defineCodes`) because the package has zero `@suaveplan/*` deps by design.
## Features
- Frozen record of namespaced codes
- `TestingE2eCode` type alias for exhaustive-check support
- Stable — additions append; removals require a deprecation cycle
- Structural match for `defineCodes` output — drop-in replacement if the dep rule later relaxes
## Usage: Basic Example
```ts
import { TESTING_E2E_ERROR_CODES } from "@suaveplan/testing-e2e";
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT; // "TESTING_E2E.WAIT.TIMEOUT"
```
## Usage: Advanced Example
```ts
import {
TESTING_E2E_ERROR_CODES,
TestingE2eError,
type TestingE2eCode,
} from "@suaveplan/testing-e2e";
function shouldRetry(code: TestingE2eCode): boolean {
switch (code) {
case TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT:
case TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED:
return true;
case TESTING_E2E_ERROR_CODES.ABORTED:
case TESTING_E2E_ERROR_CODES.FIXTURE_FAILED:
case TESTING_E2E_ERROR_CODES.CONFIG_INVALID:
return false;
}
}
```
## API Reference
### `TESTING_E2E_ERROR_CODES`
Frozen object; every value is a dot-separated code string starting with `TESTING_E2E.`.
### `type TestingE2eCode`
Union of every `TESTING_E2E_ERROR_CODES[K]` value.
## Implementation Notes
The registry is built with `Object.freeze({ ... })` rather than `defineCodes` to keep the zero-workspace-dep invariant. Because each code is a plain string literal in the object, TypeScript's control-flow analysis narrows `err.code` correctly inside `instanceof TestingE2eError` branches. New codes append in source order; existing codes are never renumbered or reused.
### Registry Layout
Codes live under the `TESTING.E2E` namespace and are produced by `defineCodes`. Current codes cover clipboard-mock setup failures, geolocation-mock validation failures, page-object navigation timeouts, dialog-dismissal races, and fixture-extension errors. The `TestingE2eCode` union is the canonical parameter type for error-handler signatures.
### Stability
New codes are added append-only; existing values never change. Consumers that type-narrow on `error.code` using the exhaustive `TestingE2eCode` union get a compile-time error when they miss a new variant only if they use a `switch` with a `never` default branch.
### CLOAK domain
The `TESTING_E2E.CLOAK.*` family covers failures originating in the
`cloakbrowser`-backed browser launcher used by the default fixtures:
- `CLOAK_LAUNCH_FAILED` — generic launch failure (default classification).
- `CLOAK_BINARY_MISSING` — stealth Chromium binary not found or
could not be downloaded. Remediation: `npx cloakbrowser install`.
- `CLOAK_PROXY_INVALID` — proxy URL malformed, refused, or
authentication failed.
These codes pair with the `CloakLaunchError` class and the
`classifyCloakError` / `wrapCloakError` helpers exported from
`@suaveplan/testing-e2e`.

View File

@@ -0,0 +1,94 @@
# testing-e2e-error
## Purpose
`TestingE2eError` is the structured error class thrown by every helper, fixture, and page object in `@suaveplan/testing-e2e`. Its shape mirrors `SuaveplanError` — `{ code, cause?, context? }` — but it does NOT extend it, because testing-infrastructure packages must stay free of any `@suaveplan/*` workspace dependency (rule #8 in `CLAUDE.md`).
## Features
- `code` narrowed to `TestingE2eCode` (the literal union of `TESTING_E2E.*` strings)
- Named subclasses (`ConfigError`, `NavigationError`, `WaitTimeoutError`, `FixtureError`, `AbortedError`) for targeted `instanceof` checks
- Preserves `cause` via ES2022 `Error.cause`
- Optional `context` for structured diagnostic fields
## Usage: Basic Example
```ts
import { WaitTimeoutError, TESTING_E2E_ERROR_CODES } from "@suaveplan/testing-e2e";
throw new WaitTimeoutError(
TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT,
"waitForNetworkIdle: network did not settle within 30000ms"
);
```
## Usage: Advanced Example
```ts
import {
WaitTimeoutError,
NavigationError,
TestingE2eError,
TESTING_E2E_ERROR_CODES,
} from "@suaveplan/testing-e2e";
async function safeNavigate(url: string, page: Page): Promise<void> {
try {
await page.goto(url, { timeout: 10_000 });
} catch (cause) {
throw new NavigationError(
TESTING_E2E_ERROR_CODES.NAVIGATION_FAILED,
`navigation to ${url} failed`,
{ cause, context: { url } }
);
}
}
try {
await safeNavigate("https://example.com", page);
} catch (err) {
if (err instanceof TestingE2eError) {
console.error(err.code, err.context, err.cause);
}
throw err;
}
```
## API Reference
### `class TestingE2eError extends Error`
- `readonly code: TestingE2eCode`
- `readonly context?: Readonly<Record<string, unknown>>`
- `cause?: unknown` (from `Error.cause`)
### Subclasses
- `ConfigError` — config-factory rejections
- `NavigationError` — `page.goto` / routing failures
- `WaitTimeoutError` — helper wait timeouts
- `FixtureError` — fixture setup/teardown failures
- `AbortedError` — signal-driven cancellations
- `CloakLaunchError` — `cloakbrowser` launcher failures (binary
missing, proxy invalid, generic launch failure)
### Cloak helpers
- `classifyCloakError(err) → TestingE2eErrorCode` — pure synchronous
classifier mapping raw errors thrown by `cloakbrowser` to one of the
three `TESTING_E2E.CLOAK.*` codes. Returns `LAUNCH_FAILED` for any
error the classifier does not specifically recognise.
- `wrapCloakError(err, context?) → CloakLaunchError` — convenience
wrapper used by every cloakbrowser-backed fixture. Classifies the
raw error, appends a remediation hint where applicable
(`npx cloakbrowser install` for binary issues, proxy verification
for proxy errors), and preserves the original error via
`Error.cause`.
## Implementation Notes
The subclasses exist so callers can write `catch (err instanceof WaitTimeoutError)` in hot paths without a `switch` on `code`. They all share the same structural shape — the subclass is purely a type-narrowing aid, not a behavioural difference. The base class deliberately does not extend `SuaveplanError` — if the project ever relaxes the testing-infra dep rule, swapping the base is a one-line change and no caller breaks.
### Subclass Index
`TestingE2eError` is the base class. Subclasses cover `ClipboardMockError`, `GeolocationMockError`, `PageObjectTimeoutError`, and `DialogDismissalError`. Every subclass sets its own code from `TESTING_E2E_CODES`, overrides `name`, and inherits wire-serialisation + OTel span-event behaviour from `SuaveplanError` so Playwright trace viewers see a consistent error shape.

View File

@@ -0,0 +1,175 @@
# launch-with-extension
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`launchWithExtension` starts a stealth Chromium persistent browser
context with an unpacked browser extension loaded, then resolves the
extension's runtime ID by inspecting the background service worker
page URL. It is the foundational fixture for browser-extension E2E
tests: without the extension ID, tests cannot construct
`chrome-extension://` URLs to open popup or options pages.
Under the hood the launcher is `cloakbrowser.launchPersistentContext`
— the same stealth Chromium with 49 source-level fingerprint patches
that powers every other fixture in this package. The launcher is
injectable so unit tests can supply a spy and so adapters
(profile-manager, remote browser pool) can replace it without forking
this file.
## Features
- Defaults to `cloakbrowser.launchPersistentContext`; pass
`launchPersistentContext` to override
- Sets `--disable-extensions-except` and `--load-extension` flags
- Forwards arbitrary `launchOptions` (proxy, humanize, geoip,
timezone, locale, …) straight through to the launcher
- Checks `context.backgroundPages()` first; falls back to
`waitForEvent("backgroundpage")` for extensions with deferred
registration
- Extracts the extension ID from the `chrome-extension://<id>/...`
URL via a strict regex
- Classifies launcher failures via `wrapCloakError`, producing a
`CloakLaunchError` with `TESTING_E2E.CLOAK.*` code
## Usage: Basic Example
```typescript
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
import path from "node:path";
const extensionPath = path.resolve(__dirname, "../../dist/extension");
const userDataDir = path.resolve(__dirname, "../../tmp/user-data");
const { context, extensionId } = await launchWithExtension({
extensionPath,
userDataDir,
});
const popupPage = await context.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
// ... test assertions ...
await context.close();
```
## Usage: Advanced Example
```typescript
import { launchWithExtension } from "@suaveplan/testing-e2e/extension-fixture";
import { test, expect } from "@suaveplan/testing-e2e";
import path from "node:path";
const EXT_PATH = path.resolve(__dirname, "../../dist/extension");
test.describe("Browser extension popup behind anti-bot wall", () => {
let extensionId: string;
let context: import("@playwright/test").BrowserContext;
test.beforeAll(async () => {
const fixture = await launchWithExtension({
extensionPath: EXT_PATH,
userDataDir: `/tmp/ext-test-${Date.now()}`,
launchOptions: {
proxy: "socks5://user:pass@residential-proxy:1080",
geoip: true,
humanize: true,
locale: "en-US",
},
});
context = fixture.context;
extensionId = fixture.extensionId;
});
test("popup renders inside the gated app", async () => {
const page = await context.newPage();
await page.goto("https://app.example.com");
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await expect(
page.getByRole("button", { name: "Sign in" })
).toBeVisible();
});
test.afterAll(async () => {
await context.close();
});
});
```
## API Reference
### `launchWithExtension(options: ExtensionFixtureOptions): Promise<ExtensionFixtureResult>`
Launches a persistent Chromium context with the specified extension
loaded and resolves the extension's runtime ID.
**Parameters:**
- `options.extensionPath` — absolute path to the unpacked extension
directory containing `manifest.json`
- `options.userDataDir` — absolute path to the persistent browser
profile directory; must be writable and unique per concurrent test
run
- `options.launchPersistentContext` — *optional* launcher override.
Defaults to `cloakbrowser.launchPersistentContext` (loaded
lazily via dynamic `import`). Tests inject a spy here; adapters
inject a profile-manager-backed launcher
- `options.launchOptions` — *optional* extra options forwarded
unchanged to the launcher. Use this to set `proxy`, `humanize`,
`geoip`, `timezone`, `locale`, or any other cloakbrowser
`LaunchPersistentContextOptions` field
**Returns:** `ExtensionFixtureResult`
- `.context` — the `BrowserContext` instance; caller closes it
- `.extensionId` — the lowercase alphanumeric extension ID extracted
from the background page URL
**Throws:**
- `CloakLaunchError` (`TESTING_E2E.CLOAK.*`) when the underlying
launcher fails — code is one of `LAUNCH_FAILED`, `BINARY_MISSING`,
or `PROXY_INVALID`. `context` carries `{ extensionPath, userDataDir }`
- `Error("Could not extract extension ID …")` when the background
page URL does not match the `chrome-extension://` pattern
### `ExtensionFixtureOptions` interface
```typescript
interface ExtensionFixtureOptions {
extensionPath: string;
userDataDir: string;
launchPersistentContext?: CloakLaunchPersistentContext;
launchOptions?: Record<string, unknown>;
}
```
### `ExtensionFixtureResult` interface
```typescript
interface ExtensionFixtureResult {
context: BrowserContext;
extensionId: string;
}
```
## Implementation Notes
The `headless: false` option is hard-set: headless Chromium silently
ignores `--load-extension`, so loading an extension always opens a
visible window. CI environments must run under a virtual framebuffer
(Xvfb) or a Linux headed runner.
The extension ID regex `/^chrome-extension:\/\/([a-z0-9]+)\//`
matches only lowercase alphanumeric characters — Chromium generates
the ID from the extension's public-key hash. IDs are 32 characters
in practice but the regex stays length-flexible for forward
compatibility.
The default `launchPersistentContext` is resolved via a one-time
dynamic `import("cloakbrowser")`. The cache is module-private; tests
reset it via the internal `_resetDefaultLauncherCache()` export so
the `vi.doMock("cloakbrowser", …)` hook in the unit test can swap
the implementation on a fresh load.

View File

@@ -0,0 +1,171 @@
# extended-test
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`extendedTest` is the Playwright `test` function pre-extended with the
fixtures this package ships, and — critically — with a `browser`
worker fixture overridden to launch
[`cloakbrowser`](https://github.com/CloakHQ/cloakbrowser) instead of
the stock Playwright Chromium. Every spec built on top of this fixture
starts inside a real Chromium binary with 49 source-level fingerprint
patches: `navigator.webdriver === false`, real GPU/canvas/audio
fingerprints, normal `window.chrome`, no `HeadlessChrome` UA leak, no
CDP detectable. Cloudflare Turnstile, reCAPTCHA v3, FingerprintJS,
BrowserScan, and ShieldSquare see a normal browser.
Consumers import `test` from this module rather than `@playwright/test`
so every spec in the project starts with the same stealth fixture
surface and the same `apiContext` / `freshPage` helpers.
## Features
- `browser` (worker, overridden) — produced by
{@link launchBrowserWithCloak} backed by `cloakbrowser.launch`
- `cloak` (worker option) — cloakbrowser launch options. Default
`{ headless: true }`. Set per-project via
`use: { cloak: { headless: false, humanize: true, … } }`
- `apiContext` — a fresh `APIRequestContext` built via
`playwright.request.newContext()`; does NOT depend on `browser`,
so API-only tests never start Chromium
- `freshPage` — a brand-new browser context + page per test
- Forwards every upstream fixture; nothing is hidden or lost
- Launcher failures classify into `CloakLaunchError` with
`TESTING_E2E.CLOAK.{LAUNCH_FAILED|BINARY_MISSING|PROXY_INVALID}`
## Usage: Basic Example
```ts
import { extendedTest as test, expect } from "@suaveplan/testing-e2e/fixtures";
test("login flow against an anti-bot wall", async ({ freshPage }) => {
await freshPage.goto("https://app.example.com/login");
// Cloudflare Turnstile would block stock Playwright here.
await expect(freshPage).toHaveTitle(/login/i);
});
```
## Usage: Advanced Example
```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
use: {
// Cloakbrowser launch options for the default `browser` fixture.
cloak: {
headless: true,
humanize: true,
geoip: true,
proxy: "socks5://user:pass@residential-proxy:1080",
timezone: "America/New_York",
locale: "en-US",
},
},
projects: [
{
name: "stealth-headed",
use: { cloak: { headless: false, humanize: true } },
},
{
name: "stealth-headless",
use: { cloak: { headless: true } },
},
],
});
```
```ts
// some.spec.ts
import { extendedTest as test, expect } from "@suaveplan/testing-e2e/fixtures";
test("API seed + stealth UI handshake", async ({ apiContext, freshPage }) => {
const seed = await apiContext.post("/api/test/seed", {
data: { user: "alice" },
});
expect(seed.ok()).toBe(true);
await freshPage.goto("https://app.example.com/dashboard");
await expect(
freshPage.getByRole("heading", { name: /welcome alice/i })
).toBeVisible();
await apiContext.post("/api/test/reset");
});
```
```ts
// Programmatic launch outside the Playwright runner.
import {
launchBrowserWithCloak,
} from "@suaveplan/testing-e2e/fixtures";
const browser = await launchBrowserWithCloak({
humanize: true,
geoip: true,
proxy: "http://user:pass@proxy:8080",
});
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("https://bot.incolumitas.com");
console.log(await page.title());
await browser.close();
```
## API Reference
### `extendedTest`
A Playwright `test` function extended with the following fixtures:
- `browser: Browser` *(worker, overridden)* — stealth Chromium launched
via `cloakbrowser.launch`. Cleanup (`browser.close()`) runs once per
worker
- `cloak: CloakOptions` *(worker option)* — options forwarded to
`cloakbrowser.launch`. Default `{ headless: true }`
- `apiContext: APIRequestContext` — fresh per test, browser-free
- `freshPage: Page` — fresh context + page per test
- Every upstream Playwright fixture remains available
### `launchBrowserWithCloak(options?, injectedLaunch?): Promise<Browser>`
Standalone helper that powers the `browser` fixture. Use this when
you need a stealth Chromium outside the Playwright test runner (e.g.
in a vitest integration test). Pass `injectedLaunch` to swap the
launcher for testing.
### `CloakOptions`
Mirror of `cloakbrowser`'s `LaunchOptions` — `headless`, `proxy`,
`args`, `stealthArgs`, `timezone`, `locale`, `geoip`, `humanize`,
`humanPreset`, `humanConfig`, `launchOptions`.
### `expect`
Re-exported from `@playwright/test` for ergonomic one-import specs.
## Implementation Notes
The override is implemented via Playwright's `test.extend`
`browser` worker fixture slot — no private APIs touched, so upgrades
to `@playwright/test` don't require changes here. The launcher itself
is loaded via dynamic `import("cloakbrowser")` cached on first
resolution; tests reset the cache via the internal
`_resetDefaultLaunchCache()` export so `vi.doMock("cloakbrowser", …)`
can swap the implementation on a fresh load.
`apiContext` depends on Playwright's built-in `playwright` fixture
(not `browser`) so API-only tests never trigger the stealth Chromium
download or launch. The destructure pattern is pinned by a source-level
test to prevent regressions where someone adds `browser` to the
dependency list.
When `cloakbrowser.launch` throws, errors are classified by
`classifyCloakError` into one of three `TESTING_E2E.CLOAK.*` codes
and rethrown as a `CloakLaunchError`. Binary-missing failures include
the remediation hint `npx cloakbrowser install` directly in the
error message.

View File

@@ -0,0 +1,59 @@
# dismiss-dialog
## Purpose
`dismissDialog` attaches a one-shot handler to the next `page.on("dialog")` event, with the caller choosing between `accept` and `dismiss`. It's the small piece of glue that turns Playwright's event-driven dialog model into an imperative one-liner, which matches how most tests actually think about modal dialogs.
## Features
- Installs a single `page.on("dialog", …)` listener; auto-removes after it fires
- Choice of `accept` (OK/Allow) or `dismiss` (Cancel/Deny)
- Safe to register before the action that triggers the dialog
- Returns an unsubscribe function so the test can detach manually if the dialog never appears
## Usage: Basic Example
```ts
import { dismissDialog } from "@suaveplan/testing-e2e/helpers";
const off = dismissDialog(page, "accept");
await page.click("text=Delete");
// If a confirm() pops, it is accepted.
off(); // optional — the handler auto-removes after firing
```
## Usage: Advanced Example
```ts
import { dismissDialog } from "@suaveplan/testing-e2e/helpers";
import { expect } from "@playwright/test";
test("destructive action requires confirm", async ({ page }) => {
await page.goto("/account");
dismissDialog(page, "dismiss"); // cancel the confirm
await page.click("text=Delete account");
await expect(page.getByRole("alert")).toHaveText(/cancelled/i);
dismissDialog(page, "accept"); // now accept it
await page.click("text=Delete account");
await expect(page).toHaveURL("/goodbye");
});
```
## API Reference
### `dismissDialog(page, action): () => void`
- `page: Page` — Playwright page
- `action: "accept" | "dismiss"` — what to do when the dialog appears
- Returns an unsubscribe function; safe to call after the handler has already auto-removed
## Implementation Notes
Per the project-wide guidance around browser dialogs, tests should avoid triggering dialogs when possible — they block the entire Playwright event stream until dismissed. `dismissDialog` is for the cases where dismissal is unavoidable (e.g. testing a `beforeunload` prompt). The handler uses `{ once: true }` semantics so a stale handler from a prior test never intercepts the current one.
### Race Protection
`dismissDialog(page)` registers a one-shot `page.once("dialog", d => d.dismiss())` listener and immediately returns a disposer that removes the listener if the dialog never fires. Doing it as one-shot avoids leaking listeners across test boundaries — a repeated bug in the previous iteration. The helper also logs the dialog's `type` and `message` through the structured logger so trace viewers show exactly what was dismissed.

View File

@@ -0,0 +1,63 @@
# mock-clipboard
## Purpose
`mockClipboard` installs a deterministic, in-memory clipboard implementation on the navigator object for a Playwright page. The default clipboard shipped by Chromium headless requires explicit user-gesture permissions and does not work at all in some CI environments; this helper replaces it with a programmatic pair of `writeText` / `readText` that behaves identically in every environment.
## Features
- Installs via `page.addInitScript` so the mock is active before any app code runs
- In-memory state is per-page — different pages don't share clipboard state
- Matches the real `navigator.clipboard` API surface for `writeText` and `readText`
- Idempotent — calling twice on the same page just re-installs the same script
## Usage: Basic Example
```ts
import { mockClipboard } from "@suaveplan/testing-e2e/helpers";
await mockClipboard(page);
await page.goto("/settings");
await page.click("text=Copy API key");
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toMatch(/^sk_/);
```
## Usage: Advanced Example
```ts
import { mockClipboard } from "@suaveplan/testing-e2e/helpers";
import { expect } from "@playwright/test";
test("copy + paste round trip", async ({ page }) => {
await mockClipboard(page);
await page.goto("/notes/123");
await page.getByRole("button", { name: /copy link/i }).click();
const copied = await page.evaluate(() => navigator.clipboard.readText());
expect(copied).toBe("https://notes.example/123");
// Paste somewhere:
await page.goto("/notes/new");
await page.evaluate(async () => {
const text = await navigator.clipboard.readText();
(document.querySelector("textarea") as HTMLTextAreaElement).value = text;
});
await expect(page.locator("textarea")).toHaveValue("https://notes.example/123");
});
```
## API Reference
### `mockClipboard(page): Promise<void>`
- `page: Page` — Playwright page (must be called before any navigation for the init script to take effect)
- Returns `Promise<void>` that resolves after the init script is registered
## Implementation Notes
The init script defines `navigator.clipboard` using `Object.defineProperty` with `writable: true` so a test that wants to swap in a further spy can still do so. Because the mock is installed via `addInitScript`, it's reinstalled automatically on every navigation within the same page — tests don't need to reinstall after `page.goto`.
### Behaviour
`mockClipboard(page)` installs an `addInitScript` that replaces `navigator.clipboard.readText` / `writeText` with in-memory stubs whose state survives page navigations inside the same context. A companion `getClipboardValue(page)` reads the current mock value synchronously from the page's JS context so assertions do not need to await a clipboard-permissions prompt.

View File

@@ -0,0 +1,58 @@
# mock-geolocation
## Purpose
`mockGeolocation` pins a Playwright page to a fixed `{ latitude, longitude }` so tests that exercise location-aware features (maps, "find nearest store", distance calculations) see deterministic input regardless of the host machine's actual location or whether the CI runner has a geolocation permission at all. It wraps Playwright's `context.grantPermissions(["geolocation"])` + `context.setGeolocation(...)` into a single call that also deals with the context-vs-page ambiguity.
## Features
- Grants `geolocation` permission on the enclosing browser context
- Sets a fixed coordinate pair
- Accepts an optional `accuracy` for the emulated reading (meters)
- Safe to call multiple times — later calls replace earlier coordinates
## Usage: Basic Example
```ts
import { mockGeolocation } from "@suaveplan/testing-e2e/helpers";
await mockGeolocation(page, { latitude: 51.5074, longitude: -0.1278 });
await page.goto("/map");
```
## Usage: Advanced Example
```ts
import { mockGeolocation } from "@suaveplan/testing-e2e/helpers";
import { expect } from "@playwright/test";
test("distance widget uses my location", async ({ page }) => {
await mockGeolocation(
page,
{ latitude: 40.7128, longitude: -74.0060, accuracy: 5 }
);
await page.goto("/stores");
await expect(page.getByRole("heading", { name: /nearest store/i }))
.toBeVisible();
await expect(page.getByTestId("distance")).toContainText("mi");
// Re-pin for a different test scenario.
await mockGeolocation(page, { latitude: 34.0522, longitude: -118.2437 });
await page.reload();
await expect(page.getByTestId("distance")).toContainText("mi");
});
```
## API Reference
### `mockGeolocation(page, coords): Promise<void>`
- `page: Page`
- `coords.latitude: number`
- `coords.longitude: number`
- `coords.accuracy?: number` — default 10 (meters)
- Returns `Promise<void>` once the permission grant + coordinate set both resolve
## Implementation Notes
Playwright distinguishes context-level and page-level geolocation; the helper works against the context behind the supplied page so the permission grant covers every page in the same context. That matters for tests that open popups or child windows — without context-level granting, the child windows see the stock "permission denied" behaviour. The `accuracy` default of 10 meters matches the order of magnitude returned by a real GPS fix and is specific enough to distinguish "pinned by a test" from "real device" if the app logs it.

View File

@@ -0,0 +1,58 @@
# screenshot-on-failure
## Purpose
`screenshotOnFailure` captures a full-page screenshot when a Playwright test fails and attaches it to the `TestInfo` report so it appears inline in HTML / CI reports. It's the single best debugging affordance for flaky or environment-specific failures, and the default Playwright config (`createPlaywrightConfig`) sets `screenshot: "only-on-failure"` so the attachment is always present without a per-test opt-in.
## Features
- Uses `TestInfo.attachments` — screenshots land in the standard Playwright report
- Full-page capture (not just the viewport)
- Safe to call from an `afterEach` or `testInfo.attach` chain
- No-op when the test passed — avoids noise in green runs
## Usage: Basic Example
```ts
import { screenshotOnFailure } from "@suaveplan/testing-e2e/helpers";
import { test } from "@suaveplan/testing-e2e/fixtures";
test.afterEach(async ({ page }, testInfo) => {
await screenshotOnFailure(page, testInfo);
});
```
## Usage: Advanced Example
```ts
import { screenshotOnFailure } from "@suaveplan/testing-e2e/helpers";
import { test, expect } from "@suaveplan/testing-e2e/fixtures";
test.afterEach(async ({ page }, testInfo) => {
// Always capture state regardless of pass/fail — useful for debugging transient issues.
const snapshot = await page.screenshot({ fullPage: true });
await testInfo.attach("final-state.png", {
body: snapshot,
contentType: "image/png",
});
// Additionally, the built-in on-failure screenshot.
await screenshotOnFailure(page, testInfo);
});
test("critical flow", async ({ page }) => {
await page.goto("/checkout");
await expect(page.getByRole("heading", { name: /checkout/i })).toBeVisible();
});
```
## API Reference
### `screenshotOnFailure(page, testInfo): Promise<void>`
- `page: Page` — the Playwright page to screenshot
- `testInfo: TestInfo` — the current test's info object (available in `afterEach` / `test` fixture functions)
- Returns `Promise<void>` — completes once the attachment is written
## Implementation Notes
The `status` on `TestInfo` is `"passed"` / `"failed"` / `"timedOut"` / `"interrupted"` — the helper captures when `status !== "passed"` so flaky/hung tests also produce a screenshot. If `page` is closed by the time `afterEach` runs (e.g. a page.close() in the test body), the screenshot call rejects — the helper catches and logs rather than propagating, because failing in an `afterEach` masks the real test failure.

View File

@@ -0,0 +1,62 @@
# wait-for-network-idle
## Purpose
`waitForNetworkIdle` suspends until a Playwright page has had zero in-flight requests for at least `idleMs` consecutive milliseconds, or rejects with `WaitTimeoutError` after `timeoutMs`. It's a more permissive alternative to `page.waitForLoadState("networkidle")`, which in modern SPAs frequently times out because analytics/keepalive traffic keeps the count non-zero forever. The helper gives callers control over how quiet "quiet" has to be.
## Features
- Configurable idle window (`idleMs`, default 500) and overall timeout (`timeoutMs`, default 30_000)
- Tracks `request`, `requestfinished`, AND `requestfailed` — no hang on 4xx/5xx
- Non-negative in-flight counter via `Math.max(0, …)` — tolerates stray settle events
- Cleans up its event listeners in a `finally` block — never leaks even on reject
- Throws `WaitTimeoutError("TESTING_E2E.WAIT.TIMEOUT")` with the current in-flight count in `context`
## Usage: Basic Example
```ts
import { waitForNetworkIdle } from "@suaveplan/testing-e2e/helpers";
await page.goto("/dashboard");
await waitForNetworkIdle(page);
// Safe to query the DOM now.
```
## Usage: Advanced Example
```ts
import {
waitForNetworkIdle,
WaitTimeoutError,
TESTING_E2E_ERROR_CODES,
} from "@suaveplan/testing-e2e";
async function loadAndAssertIdle(page: Page, url: string): Promise<void> {
await page.goto(url);
try {
await waitForNetworkIdle(page, { idleMs: 1000, timeoutMs: 5000 });
} catch (err) {
if (
err instanceof WaitTimeoutError &&
err.code === TESTING_E2E_ERROR_CODES.WAIT_TIMEOUT
) {
console.warn("page never quieted", err.context);
}
throw err;
}
}
```
## API Reference
### `waitForNetworkIdle(page, options?): Promise<void>`
- `page: Page` — a Playwright `Page` (or a duck-typed compatible shape for tests)
- `options.idleMs?: number` — default 500
- `options.timeoutMs?: number` — default 30_000
- Resolves when the idle window is met
- Rejects with `WaitTimeoutError("TESTING_E2E.WAIT.TIMEOUT")` with `context.inFlight` on timeout
## Implementation Notes
The polling loop sleeps 50ms between checks, which is a reasonable balance between responsiveness and CPU cost. The `Math.max(0, inFlight - 1)` guard tolerates the case where a stray settle event arrives without a matching request — this can happen when a service worker hijacks a request and Playwright emits only half the pair. The `finally` block always detaches the three event listeners so even a reject from the timeout path leaves the page in a clean state for later waits.

View File

@@ -0,0 +1,109 @@
# create-multi-session
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createMultiSession` opens multiple isolated Playwright browser contexts against the same base URL, with each context navigated to a role-specific URL variant. It provides the foundation for multi-participant E2E tests covering WebRTC room scenarios, WebSocket-based collaboration, and BroadcastChannel coordination where multiple independent browser sessions must operate simultaneously within a single test.
`simulateServerFailure` sends an HTTP POST to a Hono testbed server's `/api/test-control/disable-route` endpoint mid-test to simulate backend route failures. Together these two functions make it possible to write deterministic chaos tests without modifying application source code or using network interception layers that are difficult to reset reliably.
## Features
- Opens N isolated browser contexts sequentially using `browser.newContext()`
- Each context gets its own page navigated to `${baseUrl}?role=${role}` or `${baseUrl}&role=${role}` depending on whether a query string is already present
- Roles are assigned positionally; out-of-bounds indices fall back to the numeric index as a string
- Returns a `teardown()` function that closes all contexts in parallel via `Promise.all`
- `simulateServerFailure` accepts an injectable `fetchFn` for full unit testability without network
- `simulateServerFailure` throws a descriptive error when the server does not return 204
## Usage: Basic Example
```typescript
import { createMultiSession } from "@suaveplan/testing-e2e/multi-session";
import { chromium } from "@playwright/test";
const browser = await chromium.launch();
const session = await createMultiSession({
browser,
count: 2,
baseUrl: "http://localhost:3200/room/abc123",
roles: ["host", "guest"],
});
// session.pages[0] is navigated to /room/abc123?role=host
// session.pages[1] is navigated to /room/abc123?role=guest
await session.teardown();
await browser.close();
```
## Usage: Advanced Example
```typescript
import { createMultiSession, simulateServerFailure } from "@suaveplan/testing-e2e/multi-session";
test("reconnects after server route is disabled mid-session", async ({ browser }) => {
const session = await createMultiSession({
browser,
count: 3,
baseUrl: "http://localhost:3200/network-webrtc?session=test-42",
roles: ["host", "peer", "peer"],
});
// Establish initial state on all pages
for (const page of session.pages) {
await page.waitForSelector("[data-testid=connected]");
}
// Disable the signalling route mid-test
await simulateServerFailure("http://localhost:3200", "/api/webrtc/signal");
// Verify reconnection UI appears
await session.pages[1]!.waitForSelector("[data-testid=reconnecting]");
await session.teardown();
});
```
## API Reference
### `createMultiSession(options: MultiSessionOptions): Promise<MultiSessionResult>`
Creates multiple isolated browser sessions pointing to role-scoped URLs.
**Parameters:**
- `options.browser` — Playwright `Browser` instance (from fixture or `chromium.launch()`)
- `options.count` — number of contexts/pages to open
- `options.baseUrl` — base URL to navigate each page to; `?role=<value>` or `&role=<value>` is appended
- `options.roles` — array of role strings, positionally aligned with page index
**Returns:** `MultiSessionResult`
- `.pages` — array of `Page` instances, one per context, in creation order
- `.contexts` — array of `BrowserContext` instances in creation order
- `.teardown()` — async function that closes all contexts in parallel
**Throws:** propagates any error thrown by `browser.newContext()`, `context.newPage()`, or `page.goto()`.
### `simulateServerFailure(apiBaseUrl: string, route: string, fetchFn?: typeof fetch): Promise<void>`
Sends a POST request to `${apiBaseUrl}/api/test-control/disable-route` with `{ route }` as the JSON body.
**Parameters:**
- `apiBaseUrl` — base URL of the running Hono testbed server
- `route` — the route path to disable (e.g. `"/api/storage"`)
- `fetchFn` — injectable fetch implementation (defaults to global `fetch`)
**Throws:** `Error` with message `simulateServerFailure: expected 204, got <status>` when the server responds with any status other than 204.
## Implementation Notes
The `?` vs `&` separator detection uses `baseUrl.includes("?")` — a deliberate simplification that works for all well-formed URLs used in testbed scenarios. Full URL parsing via the `URL` constructor would be more correct but adds latency and is unnecessary given that baseUrls in this context are always known-good strings constructed by test code.
Contexts are opened sequentially (not in parallel) to avoid race conditions on shared browser resources during test setup. Teardown uses `Promise.all` because context shutdown order does not matter and parallel close is faster.
`simulateServerFailure` is intentionally a thin HTTP shim — the testbed server owns the state machine for disabling routes. This approach keeps the test helper free of implementation details about how route disabling works server-side.

View File

@@ -0,0 +1,104 @@
# network-monitor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`attachNetworkMonitor` intercepts all network activity on a Playwright `Page` and records every HTTP 4xx/5xx response and every network-level request failure (connection refused, DNS failure, aborted). Its `assertNoFailures()` method fails the test if any failures were captured. Used as a standard fixture in every E2E spec to catch unexpected API errors and missing assets automatically, without requiring individual assertions for each network call. This creates a zero-escape-hatch guarantee: tests that make unintended requests to broken endpoints are caught immediately rather than producing silent wrong-state failures.
The monitor is intentionally passive — it observes without intercepting or blocking requests. Unlike Playwright route mocking, it lets the full application network stack run unmodified.
## Features
- Registers `page.on("response")` and `page.on("requestfailed")` listeners
- Records any response with HTTP status >= 400 as a `"status"` failure including the URL and status code
- Records `requestfailed` events (connection errors, aborts, DNS failures) as `"failed"` failures including the error text from `request.failure()`
- Passes 2xx and 3xx responses through without recording
- Handles `request.failure()` returning `null` gracefully — records the failure with `errorText: undefined`
- `assertNoFailures()` throws with a formatted multi-line list of all recorded failures
- Exposes the raw `failures` array for test-specific inspection
## Usage: Basic Example
```typescript
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
import { test } from "@playwright/test";
test("login page makes no failed requests", async ({ page }) => {
const monitor = attachNetworkMonitor(page);
await page.goto("http://localhost:3200/login");
await page.waitForLoadState("networkidle");
monitor.assertNoFailures();
});
```
## Usage: Advanced Example
```typescript
import { attachNetworkMonitor } from "@suaveplan/testing-e2e/network-monitor";
import { attachConsoleMonitor } from "@suaveplan/testing-e2e/console-monitor";
import { test } from "@playwright/test";
test("dashboard loads all assets and API calls succeed", async ({ page }) => {
const network = attachNetworkMonitor(page);
const console = attachConsoleMonitor(page, { failOn: ["error"] });
await page.goto("http://localhost:3200/dashboard");
await page.waitForSelector("[data-testid=dashboard-ready]");
// Inspect specific failures before blanket assertion
const apiFailures = network.failures.filter((f) =>
f.url.includes("/api/")
);
if (apiFailures.length > 0) {
console.log("API failures:", apiFailures);
}
network.assertNoFailures();
console.assertClean();
});
```
## API Reference
### `attachNetworkMonitor(page: Page): NetworkMonitor`
Attaches response and request-failed listeners to the given Playwright page.
**Parameters:**
- `page` — Playwright `Page` instance to monitor
**Returns:** `NetworkMonitor`
- `.failures` — read-only array of `NetworkFailure` objects collected since the monitor was attached
- `.assertNoFailures()` — throws `Error` listing all recorded failures if the array is non-empty
### `NetworkFailure` interface
```typescript
interface NetworkFailure {
url: string;
type: "status" | "failed";
status?: number; // set for type === "status"
errorText?: string; // set for type === "failed" when failure() is non-null
}
```
### `NetworkMonitor` interface
```typescript
interface NetworkMonitor {
readonly failures: NetworkFailure[];
assertNoFailures(): void;
}
```
## Implementation Notes
The status threshold of 400 is inclusive: 400 Bad Request through all 5xx server errors are recorded. 301/302 redirects and 304 Not Modified responses are not recorded — these are normal browser behaviour during page load. 401 and 403 are deliberately included because they almost always indicate a test environment misconfiguration (wrong auth token, missing seed data).
`request.failure()` returns `null` for requests that were cancelled by the browser's own navigation logic rather than failing at the network layer. The monitor records these as failures with `errorText: undefined` to preserve the URL for debugging, since they may indicate leaked requests from prior test steps.
The `failures` getter returns the live internal array. Like `console-monitor`, this avoids copy overhead and the live reference is safe because test assertions run synchronously after the monitored page actions complete.

View File

@@ -0,0 +1,66 @@
# observability/instrumented
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instrumented` is the gated OpenTelemetry wrapper for `@suaveplan/testing-e2e` helpers and fixtures. When `TESTING_E2E_OTEL_ENABLED` is set, wrapped calls emit counters for invocations, histograms for durations, and span events for failures — giving a Playwright test run the same structured telemetry as a production service. When the flag is off, `instrumented` returns the original function reference unchanged so test-run cost is identical to the unwrapped case.
## Features
- Gated at module import via `TESTING_E2E_OTEL_ENABLED` — no per-call env reads
- Uses `@opentelemetry/api` only (no SDK) — consumers wire exporters at bootstrap
- Preserves signature and return type of the wrapped function
- Handles sync and async fn forms, including rejection
- Zero overhead when disabled (identity pass-through)
## Usage: Basic Example
```ts
import { instrumented } from "@suaveplan/testing-e2e/observability";
const navigate = instrumented("testing_e2e.navigate", async (page: Page, url: string) => {
await page.goto(url);
});
```
## Usage: Advanced Example
```ts
import { instrumented } from "@suaveplan/testing-e2e/observability";
class DashboardPage {
public gotoInstrumented = instrumented(
"testing_e2e.page.dashboard.goto",
async (page: Page) => {
await page.goto("/dashboard");
await page.waitForLoadState("domcontentloaded");
}
);
public filterInstrumented = instrumented(
"testing_e2e.page.dashboard.filter",
async (page: Page, query: string) => {
await page.fill('input[name="q"]', query);
await page.press('input[name="q"]', "Enter");
}
);
}
```
## API Reference
### `instrumented<TFn>(name, fn): TFn`
- `name` — metric name suffix; the full name becomes `testing_e2e.<name>.count` / `duration_ms` / `errors`
- `fn` — any function to wrap
- Returns `TFn` with the same signature; instrumented only when the env flag is set
### Environment gating
`TESTING_E2E_OTEL_ENABLED` must equal `"1"` or `"true"` at module-import time.
## Implementation Notes
The env check runs once at module import — flipping the variable mid-run has no effect, which avoids per-call reads in the hot path. With instrumentation enabled but no OTel SDK registered, `@opentelemetry/api` returns no-op tracers and meters, so the overhead is a single no-op counter increment. The disabled branch returns the original function reference, so V8 inlines it as if the wrapper was never there.

View File

@@ -0,0 +1,87 @@
# page-object
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PageObject` is the abstract base class for Playwright Page Object Model implementations. It wires every subclass with a consistent `navigate` / `waitForReady` / `findByRole` / `findByLabel` / `findByText` surface so spec files stay declarative. Subclasses override the `path` and `waitForReady` specifics while inheriting the shared query conveniences.
## Features
- Abstract `path: string` — subclass declares the route
- `navigate(options?)` — delegates to `page.goto` with the configured path
- `waitForReady(options?)` — subclass overrides to assert page-specific readiness
- Query helpers: `findByRole`, `findByLabel`, `findByText` — thin wrappers over `page.getBy*`
- `exactOptionalPropertyTypes`-safe: `timeout` is forwarded only when set
## Usage: Basic Example
```ts
import { PageObject } from "@suaveplan/testing-e2e/page-objects";
class LoginPage extends PageObject {
public readonly path = "/login";
public async waitForReady(): Promise<void> {
await this.findByRole("heading", { name: /sign in/i });
}
}
// spec:
const login = new LoginPage(page);
await login.navigate();
await login.waitForReady();
```
## Usage: Advanced Example
```ts
import { PageObject } from "@suaveplan/testing-e2e/page-objects";
import { expect } from "@playwright/test";
class CheckoutPage extends PageObject {
public readonly path = "/checkout";
public async waitForReady({ timeoutMs }: { timeoutMs?: number } = {}): Promise<void> {
const headingFinder = this.findByRole("heading", { name: /checkout/i });
await expect(headingFinder).toBeVisible({
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
});
}
public async enterCardNumber(number: string): Promise<void> {
await this.findByLabel("Card number").fill(number);
}
public async submit(): Promise<void> {
await this.findByRole("button", { name: /pay now/i }).click();
}
}
// spec:
const checkout = new CheckoutPage(page);
await checkout.navigate({ timeoutMs: 10_000 });
await checkout.waitForReady();
await checkout.enterCardNumber("4242 4242 4242 4242");
await checkout.submit();
```
## API Reference
### `abstract class PageObject`
- `abstract readonly path: string`
- `constructor(page: Page)`
- `navigate(options?: { timeoutMs?: number }): Promise<void>`
- `waitForReady(options?: { timeoutMs?: number }): Promise<void>` — subclass override
- `findByRole(role, options?)` → `Locator`
- `findByLabel(label)` → `Locator`
- `findByText(text)` → `Locator`
## Implementation Notes
`navigate` calls `page.waitForLoadState("domcontentloaded", waitOptions)` where `waitOptions` is `undefined` when no timeout is supplied and `{ timeout }` when one is — this conditional construction is required by `exactOptionalPropertyTypes` which forbids passing `{ timeout: undefined }`. The class deliberately stays small; subclass-specific assertions belong on the subclass, not baked into a common helper that tries to anticipate every scenario.
### Base Class
`PageObject` is the minimal base class for Playwright page-object-model helpers. It carries a `page: Page` reference, a `baseUrl: string`, and exposes `goto(path?)` / `expectVisible(locator)` / `expectUrlMatches(regex)` helpers that every subclass uses. Subclasses narrow the locator surface to the page's specific components and keep tests focused on flows rather than selectors.

View File

@@ -0,0 +1,101 @@
# stealth-presets
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Ready-made `CloakOptions` bundles for the most common cloakbrowser
deployment shapes. Pulled from the official CloakBrowser README's
"Recommended config for anti-bot sites" section. The presets are
plain frozen objects (or proxy-parameterised factories) so they can
be dropped straight into `playwright.config.ts`'s
`use.cloak`, into a programmatic `launchBrowserWithCloak(...)` call,
or merged with per-test overrides via spread.
## Features
- `STEALTH_DEFAULT_CLOAK` — headless baseline; equivalent to no
options (cloakbrowser auto-seeds + applies stealth args by default)
- `STEALTH_HEADED_CLOAK` — headed Chromium + humanized input; for
CI under Xvfb
- `stealthMax(proxy)` — maximum-stealth preset matching the upstream
recommendation: residential proxy, `geoip: true`, `headless: false`,
`humanize: true`. Beats Cloudflare Turnstile, DataDome, Akamai,
Kasada, ShieldSquare, reCAPTCHA v3 Enterprise
- `stealthMaxHeadless(proxy)` — same configuration but headless, for
Linux runners without a virtual framebuffer
## Usage: Basic Example
```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
import { stealthMax } from "@suaveplan/testing-e2e";
export default defineConfig({
use: {
cloak: stealthMax(process.env.RESIDENTIAL_PROXY!),
},
});
```
## Usage: Advanced Example
```typescript
import {
extendedTest as test,
expect,
stealthMax,
launchBrowserWithCloak,
STEALTH_HEADED_CLOAK,
} from "@suaveplan/testing-e2e";
// Per-spec override — bump up to stealthMax just for this suite.
test.describe("checkout flow behind Cloudflare Turnstile", () => {
test.use({ cloak: stealthMax(process.env.PROXY!) });
test("checks out", async ({ freshPage }) => {
await freshPage.goto("https://shop.example.com");
await expect(freshPage).toHaveURL(/checkout/);
});
});
// Outside Playwright runner — same preset, programmatic launch.
const browser = await launchBrowserWithCloak({
...STEALTH_HEADED_CLOAK,
timezone: "America/New_York",
});
```
## API Reference
### `STEALTH_DEFAULT_CLOAK: Readonly<CloakOptions>`
`{ headless: true }`.
### `STEALTH_HEADED_CLOAK: Readonly<CloakOptions>`
`{ headless: false, humanize: true }`.
### `stealthMax(proxy: string): CloakOptions`
`{ headless: false, humanize: true, geoip: true, proxy }`.
### `stealthMaxHeadless(proxy: string): CloakOptions`
`{ headless: true, humanize: true, geoip: true, proxy }`.
## Implementation Notes
Static presets are produced via `Object.freeze(...)` so consumers
cannot accidentally mutate them between test runs. Factory functions
return fresh objects each call so the resulting `CloakOptions` can
be safely spread with per-suite overrides
(`{ ...stealthMax(proxy), timezone: 'Asia/Tokyo' }`).
`stealthMax` requires a proxy because the upstream recommendation
explicitly states "datacenter IPs get blocked by reputation alone";
the preset is intentionally non-default to prevent accidental use
without one.

View File

@@ -0,0 +1,47 @@
# @suaveplan/testing-integration-garage-s3 - Garage S3 v2.0.0 testcontainers fixture for @suaveplan/testing-integration
## Summary
Garage S3 v2.0.0 testcontainers fixture for `@suaveplan/testing-integration`.
Implements `ServiceFixture<GarageS3Config>` — manages a Garage container lifecycle including the 7-step init sequence (status, layout assign, layout apply, bucket create, key create, bucket allow, key info). Returns a plain S3 config; consumers bring their own `@aws-sdk/client-s3`.
<!-- begin:auto-docs -->
## Documentation tree
- [fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add -D @suaveplan/testing-integration-garage-s3
```
## Quick Start
```typescript
import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
const fixture = new GarageS3Fixture();
const config = await fixture.start();
// config.endpoint, config.adminEndpoint, config.region, config.bucket
// config.accessKeyId, config.secretAccessKey
await fixture.stop();
```
## Env-bridge passthrough
Set all 4 env vars to skip container startup:
```text
GARAGE_S3_ENDPOINT=http://localhost:3900
GARAGE_S3_ADMIN_ENDPOINT=http://localhost:3902
GARAGE_S3_ACCESS_KEY_ID=your-key-id
GARAGE_S3_SECRET_ACCESS_KEY=your-secret
```
## Docs
- [GarageS3Fixture](src/fixture/garage-s3-fixture.md)

View File

@@ -0,0 +1,126 @@
# GarageS3Fixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GarageS3Fixture` is an integration test fixture that manages the full lifecycle of a Garage S3 v2.0.0 container using testcontainers. It implements the `ServiceFixture<GarageS3Config>` interface from `@suaveplan/testing-integration`, providing a complete S3-compatible object storage backend for integration tests.
Garage requires a 7-step initialization sequence after startup: node status, layout assignment, layout application, bucket creation, key creation, bucket permission grant, and key credential retrieval. This fixture automates the entire sequence.
## Features
- **Env-bridge passthrough**: when all four vars (`GARAGE_S3_ENDPOINT`, `GARAGE_S3_ADMIN_ENDPOINT`, `GARAGE_S3_ACCESS_KEY_ID`, `GARAGE_S3_SECRET_ACCESS_KEY`) are set and non-empty, returns them directly. Partial sets fall through to container mode.
- **7-step init sequence**: fully automated post-startup initialization including layout and bucket provisioning.
- **Admin-port health check**: polls `GET /health` on the admin port (3902) not the S3 port (3900), which does not expose a health endpoint.
- **Inline health polling**: `waitForHealth()` polls before running the init sequence, using configurable `healthTimeoutMs` and `healthPollIntervalMs` for fast test timeouts.
- **Injectable exec function**: the `execFn` option replaces `container.exec()` for unit tests covering all 7 init steps.
- **Injectable fetch**: the `fetchFn` option replaces `globalThis.fetch` in health checks.
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without Docker.
- **Configurable bucket name**: the `bucket` option overrides the default `test-bucket`.
## Usage: Basic Example
```typescript
import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const fixture = new GarageS3Fixture({ bucket: "test-bucket" });
const config = await fixture.start();
const s3 = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: true,
});
await s3.send(new PutObjectCommand({
Bucket: config.bucket,
Key: "hello.txt",
Body: "world",
}));
await fixture.stop();
```
## Usage: Advanced Example
```typescript
import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
// Unit test using injected execFn and fetchFn
const fixture = new GarageS3Fixture({
containerFactory: fakeContainerFactory,
execFn: async (cmd) => {
if (cmd[1] === "status") return { stdout: "ID\tabc123\n", exitCode: 0 };
if (cmd[1] === "key" && cmd[2] === "info")
return { stdout: "Key ID: GK123\nSecret key: sk456\n", exitCode: 0 };
return { stdout: "", exitCode: 0 };
},
fetchFn: async () => ({ ok: true } as Response),
healthTimeoutMs: 100,
healthPollIntervalMs: 10,
});
const config = await fixture.start();
```
## API Reference
### `class GarageS3Fixture`
Implements `ServiceFixture<GarageS3Config>`.
#### `constructor(options?: GarageS3FixtureOptions)`
Creates a new fixture instance. All options are optional.
#### `name: string`
Always `"garage-s3"`. Used by `TestEnvironment` as the registry key.
#### `start(): Promise<GarageS3Config>`
Starts the service. In passthrough mode returns env var values immediately. Otherwise starts a container, waits for health, runs the 7-step init sequence, and returns the provisioned config.
#### `stop(): Promise<void>`
Stops the container. No-op in passthrough mode or when not started.
#### `healthCheck(): Promise<boolean>`
Fetches `GET http://localhost:<adminPort>/health` and returns `true` when the response is OK. Returns `false` if no container is active or the request fails.
### `interface GarageS3Config`
| Field | Type | Description |
|-------|------|-------------|
| `endpoint` | `string` | S3 API URL: `http://localhost:<port>` |
| `adminEndpoint` | `string` | Admin API URL: `http://localhost:<port>` |
| `region` | `string` | Always `"garage"` |
| `bucket` | `string` | Bucket name created during init |
| `accessKeyId` | `string` | S3 access key ID from `garage key info` |
| `secretAccessKey` | `string` | S3 secret access key from `garage key info` |
### `interface GarageS3FixtureOptions`
| Field | Type | Description |
|-------|------|-------------|
| `image?` | `string` | Docker image override. |
| `bucket?` | `string` | Bucket name override. Defaults to `"test-bucket"`. |
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. |
| `execFn?` | `(cmd: string[]) => Promise<{ stdout: string; exitCode: number }>` | Injectable executor for init sequence unit testing. |
| `fetchFn?` | `typeof fetch` | Injectable fetch for health check unit testing. |
| `healthTimeoutMs?` | `number` | Health check timeout. Defaults to 120s. |
| `healthPollIntervalMs?` | `number` | Health check poll interval. Defaults to 1s. |
## Implementation Notes
- **Why admin port for health check**: the Garage S3 API port (3900) serves S3 protocol only and does not expose `/health`. Only the admin API port (3902) does.
- **Why inline health poll in `start()`**: the `ServiceFixture` contract requires `start()` to return only after the service is ready. The 7-step init sequence requires a live Garage instance.
- **testcontainers exec return shape**: `ExecResult.output` contains combined stdout+stderr in testcontainers v11. The `getExecFn()` wrapper maps `.output` to `.stdout` for consistency with the `execFn` interface.
- **Garage node ID format**: the `garage status` output contains a row per node. The node ID is the first whitespace-separated token on the first non-header data line.

View File

@@ -0,0 +1,52 @@
# @suaveplan/testing-integration-mailpit - Mailpit testcontainers fixture + assertMail helper for @suaveplan/testing-integration
## Summary
Mailpit testcontainers fixture + `assertMail()` helper for `@suaveplan/testing-integration`.
Implements `ServiceFixture<MailpitConfig>` — manages a Mailpit container lifecycle with SMTP and API ports, env-bridge passthrough, and API health checks.
<!-- begin:auto-docs -->
## Documentation tree
- [fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_- [mailpit-fixture](./src/fixture/mailpit-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [helpers](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_ - [assert-mail](./src/helpers/assert-mail.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add -D @suaveplan/testing-integration-mailpit
```
## Quick Start
```typescript
import { MailpitFixture, assertMail } from "@suaveplan/testing-integration-mailpit";
const fixture = new MailpitFixture();
const config = await fixture.start();
// config.smtpUrl, config.apiUrl
// Send email to config.smtpUrl...
await assertMail(config.apiUrl, { to: "user@example.com", subject: "Welcome" });
await fixture.stop();
```
## Env-bridge passthrough
Set both env vars to skip container startup:
```text
MAILPIT_SMTP_URL=smtp://localhost:1025
MAILPIT_API_URL=http://localhost:8025
```
## Docs
- [MailpitFixture](src/fixture/mailpit-fixture.md)
- [assertMail](src/helpers/assert-mail.md)

View File

@@ -0,0 +1,110 @@
# MailpitFixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MailpitFixture` is an integration test fixture that manages the full lifecycle of a Mailpit container using testcontainers. It implements the `ServiceFixture<MailpitConfig>` interface from `@suaveplan/testing-integration`, providing an SMTP server and REST API for email testing. The fixture binds two ports: SMTP on 1025 and the web/API on 8025.
The fixture integrates with the `assertMail()` helper exported from the same package, enabling tests to send emails via SMTP and then verify delivery through the Mailpit REST API.
## Features
- **Env-bridge passthrough**: when both `MAILPIT_SMTP_URL` and `MAILPIT_API_URL` are set and non-empty, the fixture returns them directly. Partial env sets (only one variable) fall through to container mode.
- **Dual-port binding**: exposes both SMTP (1025) and API (8025) ports, mapping each to an ephemeral host port.
- **API health check**: polls `GET /api/v1/messages` on the web port. Accepts a 200 response as healthy.
- **Injectable fetch**: the `fetchFn` option replaces `globalThis.fetch` in health checks for unit testing.
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without Docker.
- **Idempotent stop**: safe to call on an already-stopped or passthrough fixture.
## Usage: Basic Example
```typescript
import { MailpitFixture } from "@suaveplan/testing-integration-mailpit";
const fixture = new MailpitFixture();
const config = await fixture.start();
// config.smtpUrl → "smtp://localhost:<randomSmtpPort>"
// config.apiUrl → "http://localhost:<randomApiPort>"
// Send an email to SMTP, then use assertMail() to verify it arrived.
await fixture.stop();
```
## Usage: Advanced Example
```typescript
import {
MailpitFixture,
assertMail,
} from "@suaveplan/testing-integration-mailpit";
import nodemailer from "nodemailer";
const fixture = new MailpitFixture({ image: "axllent/mailpit:latest" });
const config = await fixture.start();
const transporter = nodemailer.createTransport({ url: config.smtpUrl });
await transporter.sendMail({
from: "noreply@example.com",
to: "user@example.com",
subject: "Welcome to SuavePlan",
text: "Hello!",
});
await assertMail(config.apiUrl, {
to: "user@example.com",
subject: "Welcome",
});
await fixture.stop();
```
## API Reference
### `class MailpitFixture`
Implements `ServiceFixture<MailpitConfig>`.
#### `constructor(options?: MailpitFixtureOptions)`
Creates a new fixture instance. All options are optional.
#### `name: string`
Always `"mailpit"`. Used by `TestEnvironment` as the registry key.
#### `start(): Promise<MailpitConfig>`
Starts the service. Returns the runtime configuration. In passthrough mode, returns immediately with env var values. Otherwise starts a testcontainers container.
#### `stop(): Promise<void>`
Stops the container. No-op in passthrough mode or when not started.
#### `healthCheck(): Promise<boolean>`
Returns `true` when `GET <apiUrl>/api/v1/messages` returns an OK response. Returns `false` if no container is active or the request fails.
### `interface MailpitConfig`
| Field | Type | Description |
|-------|------|-------------|
| `smtpUrl` | `string` | SMTP URL: `smtp://localhost:<port>` |
| `apiUrl` | `string` | API URL: `http://localhost:<port>` |
### `interface MailpitFixtureOptions`
| Field | Type | Description |
|-------|------|-------------|
| `image?` | `string` | Docker image override. Defaults to `axllent/mailpit:latest`. |
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. |
| `fetchFn?` | `typeof fetch` | Injectable fetch for health check unit testing. |
## Implementation Notes
- **Why API health check over SMTP**: the Mailpit REST API provides a reliable readiness signal. Attempting an SMTP connect and transaction would be heavier and require parsing SMTP protocol responses.
- **Partial passthrough handling**: the env-bridge only activates when BOTH `MAILPIT_SMTP_URL` and `MAILPIT_API_URL` are set. This prevents silently returning an incomplete config if one variable is set accidentally.
- **Dynamic import of testcontainers**: deferred to `start()` time so unit tests that provide a `containerFactory` do not trigger testcontainers initialization.

View File

@@ -0,0 +1,104 @@
# assertMail
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`assertMail()` is a test assertion helper that polls the Mailpit REST API and fails tests that do not receive an expected email within a configurable timeout. It bridges the gap between sending an email via SMTP and verifying delivery in integration tests.
The helper is designed for use with `MailpitFixture` but works with any Mailpit instance accessible at a known HTTP URL. Its injectable `fetchFn` option makes it fully unit-testable without a live Mailpit server.
## Features
- **Polling-based**: retries at a configurable interval until the deadline, tolerating normal async email delivery latency.
- **Exact address matching**: verifies `To[].Address` exactly matches the provided `to` address.
- **Substring subject matching**: checks that the message `Subject` includes the provided `subject` string, allowing partial matches.
- **Configurable timeout and interval**: `timeoutMs` and `pollIntervalMs` options allow fast-failing in unit tests (e.g. 50ms timeout) and longer waits in real environments.
- **Injectable fetch**: the `fetchFn` option replaces `globalThis.fetch` for deterministic unit tests with no live network dependency.
- **Structured error message**: the timeout error includes the `to` and `subject` criteria and timeout value for easy debugging.
## Usage: Basic Example
```typescript
import { assertMail } from "@suaveplan/testing-integration-mailpit";
// After sending an email via SMTP...
await assertMail("http://localhost:8025", {
to: "user@example.com",
subject: "Welcome",
});
// Resolves when a matching message is found; throws after 10s otherwise.
```
## Usage: Advanced Example
```typescript
import {
MailpitFixture,
assertMail,
} from "@suaveplan/testing-integration-mailpit";
import nodemailer from "nodemailer";
const fixture = new MailpitFixture();
const config = await fixture.start();
const transporter = nodemailer.createTransport({ url: config.smtpUrl });
await transporter.sendMail({
from: "noreply@example.com",
to: "alice@example.com",
subject: "Password Reset Request",
text: "Click here to reset your password.",
});
// Custom timeout: wait up to 5 seconds, polling every 200ms
await assertMail(config.apiUrl, {
to: "alice@example.com",
subject: "Password Reset",
}, {
timeoutMs: 5000,
pollIntervalMs: 200,
});
await fixture.stop();
```
## API Reference
### `assertMail(apiUrl, criteria, options?): Promise<void>`
Polls the Mailpit API until a matching message is found or the timeout elapses.
#### Parameters
| Name | Type | Description |
|------|------|-------------|
| `apiUrl` | `string` | Mailpit API base URL (e.g. `http://localhost:8025`). |
| `criteria` | `AssertMailCriteria` | Matching criteria. |
| `options?` | `AssertMailOptions` | Optional configuration. |
#### Throws
`Error` when no matching message is found within `timeoutMs`. The message includes the `to` and `subject` values and elapsed timeout.
### `interface AssertMailCriteria`
| Field | Type | Description |
|-------|------|-------------|
| `to` | `string` | Exact recipient address to match in `To[].Address`. |
| `subject` | `string` | Subject substring to match via `String.includes()`. |
### `interface AssertMailOptions`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `timeoutMs?` | `number` | `10_000` | Total wait time in ms. |
| `pollIntervalMs?` | `number` | `500` | Polling interval in ms. |
| `fetchFn?` | `typeof fetch` | `globalThis.fetch` | Injectable fetch for unit tests. |
## Implementation Notes
- **Polling strategy**: the helper uses a `while (Date.now() < deadline)` loop with `setTimeout` between iterations. This provides predictable behavior without relying on `setInterval` side effects.
- **Mailpit API shape**: the `/api/v1/messages` endpoint returns `{ messages: Array<{ To: [{Address: string}], Subject: string, ... }> }`. The `messages` key may be absent on an empty inbox; the helper defaults to `[]`.
- **Partial subject matching rationale**: email subjects often contain dynamic content (timestamps, user names) appended to a fixed prefix. Substring matching lets tests assert on the stable prefix without hardcoding dynamic parts.
- **Exact address matching rationale**: recipient addresses are authoritative identifiers. Substring matching on addresses would be error-prone (e.g. `user@example.com` matching `user@example.company`).

View File

@@ -0,0 +1,64 @@
# @suaveplan/testing-integration-oauth-mock - Real-HTTP mock OAuth 2.0 IdP fixture with PKCE, primeUser/primeError control, and full request audit
Real-HTTP mock OAuth 2.0 Identity Provider for integration tests.
`createMockOauthIdp(opts)` boots a real `node:http` listener on an
ephemeral `127.0.0.1` port and implements:
- `GET /authorize` — issues an authorization code, captures PKCE
challenge, echoes state.
- `POST /token` — `authorization_code` and `refresh_token` grants;
PKCE `plain` + `S256` verification with rotated refresh tokens.
- `GET /userinfo` — Bearer-authenticated user payload shaped as
`github` (default), `google`, `generic`, or via a custom `userMapper`.
- `GET /.well-known/openid-configuration` + `GET /jwks.json` — gated
on `openid: true`.
Test-control levers:
- `primeUser({ … })` — FIFO queue of user payloads for the next
`/userinfo` response.
- `primeError("access_denied" | …)` — FIFO queue of RFC-6749 error
codes for the next `/authorize`.
- `lastRequest()` / `allRequests()` — frozen audit records of every
served HTTP request.
<!-- begin:auto-docs -->
## Documentation tree
- [audit](./src/control/audit.md) — `AuditLog` is the request-recorder that backs the `lastRequest()` and `allRequests()` accessors on the mock IdP handle.…
- [primer](./src/control/primer.md) — `Primer` is the in-memory control surface that lets a test author tell the mock IdP "the next user-info response should…
- [authorize](./src/endpoints/authorize.md) — `handleAuthorize` implements the OAuth 2.0 authorization-code endpoint (RFC-6749 §4.1.1) for the mock IdP. It issues a…
- [openid](./src/endpoints/openid.md) — `handleOpenIdConfig` serves the `/.well-known/openid-configuration` discovery document and `handleJwks` serves the matc…
- [token](./src/endpoints/token.md) — `handleToken` implements the OAuth 2.0 token endpoint (RFC-6749 §4.1.3 and §6) for the mock IdP. It supports two grant…
- [userinfo](./src/endpoints/userinfo.md) — `handleUserInfo` implements the OAuth/OIDC userinfo endpoint. Given a `Bearer` authorization header, it looks up the as…
- [error-codes](./src/error-codes/error-codes.md) — This module defines the closed registry of error codes raised by `@suaveplan/testing-integration-oauth-mock`. The packa…
- [error-codes](./src/error-codes/error-codes.md) — This module defines the closed registry of error codes raised by `@suaveplan/testing-integration-oauth-mock`. The packa…
- [server](./src/server/server.md) — `createMockOauthIdp` boots a real HTTP listener (Node's `http.createServer`) on an ephemeral port and routes OAuth 2.0…
- [server](./src/server/server.md) — `createMockOauthIdp` boots a real HTTP listener (Node's `http.createServer`) on an ephemeral port and routes OAuth 2.0…
- [generic](./src/shapes/generic.md) — `buildGenericUser` produces an OIDC-flavoured userinfo payload with the common `sub` / `email` / `name` claims. It is t…
- [github](./src/shapes/github.md) — `buildGithubUser` produces a userinfo payload that closely mirrors the `GET https://api.github.com/user` response GitHu…
- [google](./src/shapes/google.md) — `buildGoogleUser` produces a Google OIDC-style userinfo payload that matches the response Google's `/oauth2/v3/userinfo…
<!-- end:auto-docs -->
## Quick start
```ts
import { createMockOauthIdp } from "@suaveplan/testing-integration-oauth-mock";
const idp = await createMockOauthIdp({ userShape: "github" });
idp.primeUser({ id: 42, login: "alice" });
// drive a Better-Auth / next-auth GitHub provider against `idp.url`
await idp.stop();
```
## Compliance
- Rule 8 — testing-infrastructure: no `@suaveplan/*` runtime
dependencies; only `node:*` builtins.
- Rule 11 — test-runner imports go through
`@suaveplan/testing/runner`.
- Rule 2 — real HTTP, no mocking libraries.

View File

@@ -0,0 +1,65 @@
# Audit log
## Purpose
`AuditLog` is the request-recorder that backs the `lastRequest()` and
`allRequests()` accessors on the mock IdP handle. Every HTTP request the
server processes is captured as a frozen `AuditRecord` and appended to
the log in serve-order. Tests can then assert on the recorded headers,
query string, body, and ordering without re-running the flow.
The records are deep-frozen at insertion time so audit consumers cannot
accidentally mutate captured state through their assertions.
## Features
- Frozen `AuditRecord` snapshots with method, path, headers, query,
body, and `at` (millisecond epoch) timestamp.
- `last()` returns the most recently appended record or `undefined`.
- `all()` returns a frozen shallow-copy snapshot of the records array,
preserving insert-order iteration.
- `clear()` resets the log between scenarios.
## Usage: Basic
```ts
import { AuditLog } from "@suaveplan/testing-integration-oauth-mock/control";
const log = new AuditLog();
log.push({ method: "GET", path: "/authorize", headers: {}, query: {}, body: "", at: Date.now() });
log.last();
```
## Usage: Advanced
When asserting flows that involve multiple requests, snapshot the whole
array up front and walk it deterministically rather than calling
`last()` repeatedly:
```ts
const events = log.all();
expect(events.map((e) => `${e.method} ${e.path}`)).toEqual([
"GET /authorize",
"POST /token",
"GET /userinfo",
]);
```
## API Reference
- `interface AuditRecord` — `{ method, path, headers, query, body, at }`,
all read-only and frozen.
- `class AuditLog`
- `push(record: AuditRecord): void`
- `last(): AuditRecord | undefined`
- `all(): readonly AuditRecord[]`
- `clear(): void`
## Implementation Notes
`push` performs an `Object.freeze` not only on the record itself but on
the nested `headers` and `query` objects, because callers can legally
hold the input object and mutate it post-push otherwise. `all()` returns
a frozen *copy* via `slice()` so the public snapshot can never reach
back into the internal array — preventing test mutations from corrupting
later assertions.

View File

@@ -0,0 +1,65 @@
# Primer queues
## Purpose
`Primer` is the in-memory control surface that lets a test author tell
the mock IdP "the next user-info response should look like X" or "the
next `/authorize` call must fail with RFC-6749 error Y" without
restarting the HTTP server. It backs the public `primeUser` /
`primeError` levers on the `MockOauthIdpHandle` returned by
`createMockOauthIdp`.
Two independent FIFO queues — one for user payloads, one for forced
authorize-errors — guarantee deterministic playback across multiple
sequential round-trips inside a single test.
## Features
- Independent FIFO queues for primed users and primed errors.
- Validates RFC-6749 error codes at prime time and rejects unknown codes
with `OAUTH_MOCK.INVALID_OPTIONS`.
- `take*` consumers return `undefined` when the queue is empty so
endpoints can fall back to defaults gracefully.
- `peek*Count` accessors for tests asserting queue state.
## Usage: Basic
```ts
import { Primer } from "@suaveplan/testing-integration-oauth-mock/control";
const p = new Primer();
p.primeUser({ id: 42, login: "alice" });
p.primeError("access_denied");
```
## Usage: Advanced
The queues are independent, so a test can interleave errors and users
without worrying about consumption order:
```ts
const p = new Primer();
p.primeError("invalid_request"); // first /authorize fails
p.primeUser({ id: 7 }); // second /authorize → /userinfo returns id 7
```
## API Reference
- `class Primer`
- `primeUser(user: PrimedUser): void`
- `primeError(code: PrimerErrorCode): void`
- `takeUser(): PrimedUser | undefined`
- `takeError(): PrimerErrorCode | undefined`
- `peekUserCount(): number`
- `peekErrorCount(): number`
- `const PRIMER_ERRORS` — closed list of RFC-6749 error codes.
- `type PrimerErrorCode` — union over `PRIMER_ERRORS`.
## Implementation Notes
The class deliberately uses plain `Array<T>` with `push` / `shift` rather
than a linked-list deque. Test queues are short-lived and bounded by
test size; the constant-factor cost of `shift()` is irrelevant. Errors
are validated against a closed allowlist (RFC-6749 §4.1.2.1) at prime
time, not at consume time, so the test that supplies the bad code fails
fast and points at the line of the offending `primeError` call.

View File

@@ -0,0 +1,61 @@
# /authorize endpoint
## Purpose
`handleAuthorize` implements the OAuth 2.0 authorization-code endpoint
(RFC-6749 §4.1.1) for the mock IdP. It issues a fresh authorization
code, persists the PKCE challenge for later verification by the token
endpoint, and redirects the user-agent back to the caller's
`redirect_uri` with `code` and `state` parameters echoed.
If a test has primed an RFC-6749 error via `primeError`, the function
short-circuits and redirects with `?error=<code>` instead — letting test
authors drive the IdP's negative paths deterministically without
swapping out implementations.
## Features
- 302 redirect with `code` + echoed `state`.
- Captures PKCE `code_challenge` and `code_challenge_method`
(`plain` | `S256`), discarding unsupported methods.
- Primed error redirect honours RFC-6749 error parameter format.
- Pops the next primed user from the FIFO primer queue and stores it on
the auth-code record for later `/userinfo` retrieval.
## Usage: Basic
```ts
const result = handleAuthorize(state, {
redirect_uri: "http://app/cb",
state: "csrf",
code_challenge: "abc",
code_challenge_method: "S256",
});
// → 302 Location: http://app/cb?code=…&state=csrf
```
## Usage: Advanced
To force a primed error path:
```ts
state.primer.primeError("access_denied");
handleAuthorize(state, { redirect_uri: "http://app/cb" });
// → 302 Location: http://app/cb?error=access_denied
```
## API Reference
- `handleAuthorize(state, query): AuthorizeResult`
- `interface AuthorizeResult` — `{ status: 302, location: string,
code?: string, error?: string }`.
## Implementation Notes
`redirect_uri` is the only mandatory query parameter — when missing, the
function returns a sentinel `error` string and an empty `location`
because there is no safe URL to redirect to. Invalid PKCE methods are
silently downgraded to "no PKCE captured"; the token endpoint then
treats the exchange as PKCE-disabled. The primed user queue is consumed
even when no PKCE challenge is provided so the test author's intent
(prime then drive) is preserved in either flow.

View File

@@ -0,0 +1,57 @@
# OIDC discovery + JWKS endpoints
## Purpose
`handleOpenIdConfig` serves the `/.well-known/openid-configuration`
discovery document and `handleJwks` serves the matching `/jwks.json`
keyset. Together they let OIDC-aware libraries (Better-Auth's OIDC
provider, openid-client, oidc-provider clients) bootstrap from the mock
IdP without any hard-coded endpoint URLs.
The endpoints are only routed when the fixture is created with
`{ openid: true }`; the rest of the fixture remains a plain OAuth-2 IdP
otherwise.
## Features
- Spec-conforming discovery document with `issuer`,
`authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, and
`jwks_uri` all derived from the live `state.issuer` origin.
- Declares `response_types_supported: ["code"]`,
`grant_types_supported: ["authorization_code", "refresh_token"]`,
`code_challenge_methods_supported: ["plain", "S256"]`, and
`scopes_supported: ["openid", "profile", "email"]`.
- Static JWKS with a single HS256 mock key, sufficient for adapters that
only validate `id_token` signatures lazily.
## Usage: Basic
```ts
const config = handleOpenIdConfig(state);
config.body.token_endpoint; // → http://127.0.0.1:<port>/token
```
## Usage: Advanced
When wiring `openid-client` against the mock IdP:
```ts
const issuer = await Issuer.discover(`${idp.url}/.well-known/openid-configuration`);
const client = new issuer.Client({ client_id: "c", client_secret: "s" });
```
## API Reference
- `handleOpenIdConfig(state): OpenIdConfigResult` — `{ status: 200, body: <discovery> }`.
- `handleJwks(state): JwksResult` — `{ status: 200, body: { keys: […] } }`.
## Implementation Notes
The discovery document is regenerated on every request rather than
cached, because `state.issuer` is fixed at server-start time but the
fixture pattern occasionally re-issues per test. The JWKS is
deliberately small and static; consumers that need rotating asymmetric
keys should drive a real OIDC provider instead of this fixture. The
embedded HS256 key value is a base64-url-encoded placeholder labelled
"for-testing-only" — it has no security meaning and must never be used
to sign anything outside of a fixture.

View File

@@ -0,0 +1,63 @@
# /token endpoint
## Purpose
`handleToken` implements the OAuth 2.0 token endpoint (RFC-6749 §4.1.3
and §6) for the mock IdP. It supports two grant types:
`authorization_code` (exchanging a freshly issued code for an
access+refresh-token pair) and `refresh_token` (rotating an existing
refresh-token).
PKCE verification (RFC-7636) is enforced when the matching auth-code
record carries a `code_challenge`; both `plain` and `S256` challenge
methods are supported, and mismatches produce
`{ error: "invalid_grant" }` responses with HTTP 400 per spec.
## Features
- `authorization_code` grant with single-use code semantics.
- `refresh_token` grant with rotation — the previous refresh token is
revoked on each successful refresh.
- PKCE S256 verification using a real `node:crypto` `sha256` hash.
- Configurable `expires_in` via `accessTokenTtlSeconds`.
- RFC-6749 error envelopes: `invalid_request`, `invalid_grant`,
`unsupported_grant_type`.
## Usage: Basic
```ts
const tokenResponse = handleToken(state, {
grant_type: "authorization_code",
code: "abc",
code_verifier: "verifier-43-chars-min",
});
```
## Usage: Advanced
Rotate a refresh token (the original is revoked):
```ts
const refreshed = handleToken(state, {
grant_type: "refresh_token",
refresh_token: previous.refresh_token,
});
```
## API Reference
- `handleToken(state, form): TokenResult`
- `interface TokenSuccess` — `{ status: 200, body: { access_token,
token_type: "Bearer", expires_in, refresh_token, scope? } }`.
- `interface TokenError` — `{ status: 400|401, body: { error,
error_description? } }`.
## Implementation Notes
PKCE S256 base64url encoding is performed with `Buffer.toString('base64')`
followed by manual `=` stripping and `+`/`/` substitution — this matches
RFC-4648 §5 and is what every conforming client SDK produces. Auth codes
are marked `used = true` on first successful exchange, and reuse is
rejected as `invalid_grant`. Refresh tokens carry forward the original
`userPayload` so subsequent `/userinfo` calls keep returning the same
identity, modelling production IdP behaviour faithfully.

View File

@@ -0,0 +1,58 @@
# /userinfo endpoint
## Purpose
`handleUserInfo` implements the OAuth/OIDC userinfo endpoint. Given a
`Bearer` authorization header, it looks up the associated access token,
verifies it has not expired, and returns the matching user payload
shaped by the configured `userShape` (or by a custom `userMapper` when
the caller supplies one).
The endpoint backs the primary identity-fetch path that production OAuth
adapters use after a successful code-exchange, so it is the surface most
test scenarios assert against.
## Features
- `Bearer <token>` extraction with regex validation.
- Looks up the token in the in-memory access-token map and 401s on
unknown or expired tokens.
- Dispatches across `github` / `google` / `generic` shapes, or hands the
primed payload to a caller-supplied `userMapper`.
- All error responses use RFC-6750 `invalid_token` envelopes.
## Usage: Basic
```ts
const r = handleUserInfo(state, "Bearer at_abc");
// → { status: 200, body: { id: 42, login: "alice", … } }
```
## Usage: Advanced
Provide a custom mapper to return arbitrary claim shapes:
```ts
const state = {
...,
options: {
...,
userMapper: (primed) => ({ sub: String(primed?.id), groups: ["admins"] }),
},
};
```
## API Reference
- `handleUserInfo(state, authorizationHeader: string | undefined): UserInfoResult`
- `interface UserInfoResult` — `{ status: 200 | 401, body: Record<string, unknown> }`.
## Implementation Notes
The matcher uses `^Bearer\s+(.+)$` (case-sensitive, unicode flag) so
RFC-6750's exact-spelling requirement is enforced — "bearer" or
"Token" headers are rejected as malformed. Token expiry is checked with
`<= Date.now()` so a token whose `expiresAt` equals the current
millisecond is treated as expired (defensive boundary). The default
branch of the `userShape` switch is unreachable under the typed input
and is `/* c8 ignore */`d to keep coverage clean.

View File

@@ -0,0 +1,80 @@
# OAUTH_MOCK error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module defines the closed registry of error codes raised by
`@suaveplan/testing-integration-oauth-mock`. The package is rule-8
testing-infrastructure and so cannot depend on `@suaveplan/error`; instead
it rolls a minimal `OauthMockError` class that carries the same shape
(`code`, `message`, optional `cause`, optional immutable `context`) and
satisfies tests that need to assert on stable code strings without coupling
the fixture to the runtime error library.
Every internal failure path — invalid PKCE verifier, unknown auth code,
exhausted primer queue, unrecognised user shape — surfaces a code in this
registry rather than a bare `Error`, giving test authors a stable string
contract to assert against when they want to drive negative-path
scenarios through the mock IdP.
## Features
- Closed registry constant `OAUTH_MOCK_CODES` with eleven canonical codes.
- Branded `OauthMockErrorCode` union type for compile-time exhaustiveness.
- `OauthMockError extends Error` with `code`, frozen `context`, and
optional `cause` preserved via the standard ES2022 `Error.cause` slot.
- Zero `@suaveplan/*` runtime dependency (rule 8).
## Usage: Basic
```ts
import {
OAUTH_MOCK_CODES,
OauthMockError,
} from "@suaveplan/testing-integration-oauth-mock/error-codes";
throw new OauthMockError(
OAUTH_MOCK_CODES.UNKNOWN_AUTH_CODE,
"no such code",
);
```
## Usage: Advanced
Attach structured context and chain a cause when re-throwing across
endpoint boundaries:
```ts
try {
verifyPkce(challenge, verifier);
} catch (cause) {
throw new OauthMockError(
OAUTH_MOCK_CODES.INVALID_PKCE_VERIFIER,
"verifier hash mismatch",
{ cause, context: { method: "S256" } },
);
}
```
## API Reference
- `OAUTH_MOCK_CODES` — frozen string-keyed map of canonical codes.
- `OauthMockErrorCode` — union type of the registry values.
- `class OauthMockError extends Error` — constructor
`(code, message, options?: { cause?; context? })`. Exposes
`code: OauthMockErrorCode`, `context: Readonly<Record<string, unknown>>`,
and optional inherited `cause`.
## Implementation Notes
`context` is sealed with `Object.freeze` at construction time so callers
who pass a live reference cannot mutate the recorded context after the
error has propagated up the stack. The class extends the platform `Error`
so structured cloning, `instanceof`, and stack traces continue to behave
in the natural way. Codes use the dot-namespaced form
`OAUTH_MOCK.<CASE>` for grep-ability and to match the wider Suaveplan
code-namespace convention even though the package does not consume
`@suaveplan/error` directly.

View File

@@ -0,0 +1,95 @@
# createMockOauthIdp — server factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createMockOauthIdp` boots a real HTTP listener (Node's `http.createServer`)
on an ephemeral port and routes OAuth 2.0 + optional OIDC traffic
through the in-process endpoint handlers. The returned handle is the
single public-API surface of the package: `url`, `port`, `stop`,
`primeUser`, `primeError`, `lastRequest`, `allRequests`.
Unlike mocking libraries, this fixture exposes a real socket, so OAuth
adapters under test exercise their full network code path —
content-type negotiation, redirect handling, header parsing — instead of
short-circuiting against a stub. This is the keystone fixture for
Stage 6.3 (`api-auth-better-auth`) and Stage 8 (`api-plugin-auth`)
GitHub-OAuth round-trip tests.
## Features
- Ephemeral-port `127.0.0.1` listener; per-test isolation.
- Awaitable `stop()` that closes the socket and releases the port.
- Full OAuth 2.0 authorization-code + refresh-token flow with PKCE.
- Optional OIDC discovery + JWKS endpoints under `openid: true`.
- Configurable user shape (`github` / `google` / `generic`) or custom
mapper via `userMapper`.
- Injectable RNG for deterministic codes/tokens in tests.
- Request audit feed (`lastRequest`/`allRequests`) of every served HTTP
request as a frozen `AuditRecord`.
## Usage: Basic
```ts
import { createMockOauthIdp } from "@suaveplan/testing-integration-oauth-mock";
const idp = await createMockOauthIdp({ userShape: "github" });
idp.primeUser({ id: 1, login: "octocat" });
// drive your OAuth client against idp.url
await idp.stop();
```
## Usage: Advanced
Drive the full PKCE S256 round-trip end-to-end:
```ts
const idp = await createMockOauthIdp({ openid: true, rng: () => "det" });
idp.primeUser({ id: 42, login: "alice" });
const authUrl = new URL(`${idp.url}/authorize`);
authUrl.searchParams.set("redirect_uri", "http://app/cb");
authUrl.searchParams.set("code_challenge", sha256base64url(verifier));
authUrl.searchParams.set("code_challenge_method", "S256");
const authResp = await fetch(authUrl, { redirect: "manual" });
const code = new URL(authResp.headers.get("location")!).searchParams.get("code")!;
const tokenResp = await fetch(`${idp.url}/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: verifier,
}).toString(),
});
const { access_token } = await tokenResp.json();
const me = await fetch(`${idp.url}/userinfo`, {
headers: { authorization: `Bearer ${access_token}` },
}).then((r) => r.json());
```
## API Reference
- `createMockOauthIdp(options?: CreateMockOauthIdpOptions): Promise<MockOauthIdpHandle>`
- `interface CreateMockOauthIdpOptions` — `userShape`, `userMapper`,
`openid`, `accessTokenTtlSeconds`, `refreshTokenTtlSeconds`,
`clientId`, `clientSecret`, `rng`.
- `interface MockOauthIdpHandle` — `url`, `port`, `stop`, `primeUser`,
`primeError`, `lastRequest`, `allRequests`.
## Implementation Notes
The server is built with `node:http` rather than `Bun.serve` because the
package's vitest test runner executes under Node (`pool: "forks"`), and
`Bun.serve` is unavailable there. Functionally the listener is
indistinguishable — a real TCP socket bound to `127.0.0.1:<ephemeral>`
— and any OAuth client under test exercises its full network stack
against it. The `state.issuer` is finalised after the OS assigns a port,
so discovery URLs reflect the live origin instead of a placeholder. The
factory throws `OAUTH_MOCK.UNSUPPORTED_USER_SHAPE` when the caller
supplies an unknown `userShape` without a `userMapper` fallback — that
keeps tests honest rather than silently degrading to the generic shape.

View File

@@ -0,0 +1,64 @@
# Generic user shape
## Purpose
`buildGenericUser` produces an OIDC-flavoured userinfo payload with the
common `sub` / `email` / `name` claims. It is the universal fallback when
the consumer does not opt into a provider-specific shape; tests using
single-sign-on flows that are not GitHub or Google can lean on this
shape to drive their `/userinfo` assertions without hand-rolling claim
objects.
Every payload is sealed with `Object.freeze` so that fixture consumers
cannot mutate stored state by modifying the response object after it has
been served — keeping audit captures stable for assertion replay.
## Features
- Sensible defaults for every required OIDC core claim.
- Accepts a `number | string` `id` shortcut that fans out into `sub`.
- Passes through arbitrary extra claims (`custom_claim`, `groups`, …).
- Frozen return value.
## Usage: Basic
```ts
import { buildGenericUser } from "@suaveplan/testing-integration-oauth-mock/shapes";
const u = buildGenericUser({ id: 7, email: "lee@example.test" });
// → { sub: "7", email: "lee@example.test", email_verified: true, name: "User 7" }
```
## Usage: Advanced
Inject provider-specific claims (e.g. SCIM groups) by spreading them
through the input — they survive untouched on the returned payload:
```ts
const u = buildGenericUser({
sub: "user-1",
name: "Lee",
preferred_username: "lee",
picture: "https://cdn.example/lee.png",
groups: ["admins", "billing"],
});
```
## API Reference
- `interface GenericUser` — frozen OIDC-shaped object: `sub`, `email`,
`email_verified`, `name`, optional `preferred_username`, optional
`picture`, plus arbitrary extra claims.
- `interface PrimedGenericInput` — caller-supplied partial input.
- `function buildGenericUser(input?: PrimedGenericInput): GenericUser`.
## Implementation Notes
The function deliberately avoids any imports outside the local module so
it can be tree-shaken into very small bundles for downstream test
harnesses. `sub` is always coerced to a `string` because OIDC mandates
`sub` is a string claim even when the upstream provider exposes a
numeric user identifier. Optional fields are spread conditionally so the
emitted object key set is stable for snapshot assertions: callers that
omit `preferred_username` see the key absent rather than set to
`undefined`.

View File

@@ -0,0 +1,61 @@
# GitHub user shape
## Purpose
`buildGithubUser` produces a userinfo payload that closely mirrors the
`GET https://api.github.com/user` response GitHub serves to OAuth apps.
Adapters in the wider ecosystem (Better-Auth, next-auth, passport-github)
read fields by their GitHub-canonical names — `id`, `login`,
`avatar_url`, `node_id` — and this builder exists so those adapters can
exercise their full mapping logic against the mock IdP without writing
GitHub-specific assertions into every test.
The default payload is opinionated enough to be useful straight out of
the box while remaining trivial to override per call when a test needs
particular id/login pairs to drive primary-key-style scenarios.
## Features
- Canonical GitHub field set: `id`, `login`, `email`, `avatar_url`,
`name`, `node_id`, `type: "User"`, `site_admin`.
- String `id` coerced to number; falls back to `0` on unparseable input.
- Explicit `null` name supported (org-style users).
- Extra unknown claims preserved on the output object.
- Frozen return value.
## Usage: Basic
```ts
import { buildGithubUser } from "@suaveplan/testing-integration-oauth-mock/shapes";
const u = buildGithubUser({ id: 1, login: "octocat" });
```
## Usage: Advanced
Compose with the `primeUser` lever to drive role-based assertions:
```ts
import { createMockOauthIdp } from "@suaveplan/testing-integration-oauth-mock";
const idp = await createMockOauthIdp({ userShape: "github" });
idp.primeUser({ id: 9000, login: "ghost", site_admin: true });
```
## API Reference
- `interface GithubUser` — canonical GitHub-shaped output.
- `interface PrimedGithubInput` — partial caller input. Numeric or
string `id` accepted.
- `function buildGithubUser(input?: PrimedGithubInput): GithubUser`.
## Implementation Notes
`id` is normalised to `number` because the GitHub adapter ecosystem
treats it as the primary key — letting it be a `string` here would
silently break downstream `===` checks. Unparseable string ids deliberately
collapse to `0` rather than `NaN` so JSON serialisation remains lossless;
tests that care about that edge can pass a number directly. `name` is
left explicitly nullable to model GitHub's organisation-style accounts.
The default `node_id` interpolation uses a recognisable static prefix so
test failures involving node-id mismatches are easy to spot in diffs.

View File

@@ -0,0 +1,65 @@
# Google user shape
## Purpose
`buildGoogleUser` produces a Google OIDC-style userinfo payload that
matches the response Google's `/oauth2/v3/userinfo` endpoint serves for
authorised users. It exists so that adapters consuming Google IdP
identity claims — Better-Auth's Google provider, next-auth's OAuth/OIDC
provider, custom JWT pipelines — can run their full mapping logic
against the mock IdP without re-implementing Google-specific shapes in
every test.
The payload combines the OIDC core claims (`sub`, `email`, `name`) with
Google's vendor extensions (`given_name`, `family_name`, `locale`,
optional `hd` for hosted-domain GSuite tenants).
## Features
- Default OIDC claim set with sensible filler values.
- Composes `name` from `given_name` + `family_name` when omitted.
- Optional `hd` only emitted when supplied (matches Google's behaviour).
- Numeric `id` shortcut coerced into the canonical string `sub` claim.
- Frozen return value.
## Usage: Basic
```ts
import { buildGoogleUser } from "@suaveplan/testing-integration-oauth-mock/shapes";
const u = buildGoogleUser({ id: 42, given_name: "Ada", family_name: "Lovelace" });
// → sub: "42", name: "Ada Lovelace", …
```
## Usage: Advanced
Drive a multi-tenant scenario by passing `hd`:
```ts
const u = buildGoogleUser({
sub: "1001",
email: "ada@example.com",
hd: "example.com",
locale: "en-GB",
});
```
## API Reference
- `interface GoogleUser` — frozen output payload with required OIDC
claims plus Google extensions (`given_name`, `family_name`, `picture`,
`locale`) and optional `hd`.
- `interface PrimedGoogleInput` — partial caller input. `id` accepts
`number | string`.
- `function buildGoogleUser(input?: PrimedGoogleInput): GoogleUser`.
## Implementation Notes
`sub` is always a string (per OIDC) and is derived from `id` when only
`id` is supplied. `name` is reconstructed from `given_name` +
`family_name` if the caller does not pass one explicitly, so that test
authors who set just first-name + last-name still see a populated
full-name claim. `hd` is spread conditionally so the key is absent on
the output when the caller does not opt in — Google only includes `hd`
on Workspace accounts, and keeping that semantic preserves test fidelity
for tenancy assertions.

View File

@@ -0,0 +1,43 @@
# @suaveplan/testing-integration-postgres - PostgreSQL 16 testcontainers fixture for @suaveplan/testing-integration
## Summary
PostgreSQL 16 testcontainers fixture for `@suaveplan/testing-integration`.
Implements `ServiceFixture<PostgresConfig>` — manages a PostgreSQL container lifecycle with env-bridge passthrough and `pg_isready` health checks. Returns a plain config; consumers bring their own `pg` client.
<!-- begin:auto-docs -->
## Documentation tree
- [fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add -D @suaveplan/testing-integration-postgres
```
## Quick Start
```typescript
import { PostgresFixture } from "@suaveplan/testing-integration-postgres";
const fixture = new PostgresFixture();
const config = await fixture.start();
// config.url, config.host, config.port, config.database, config.username, config.password
await fixture.stop();
```
## Env-bridge passthrough
Set the env var to skip container startup:
```text
POSTGRES_URL=postgresql://test:test@localhost:5432/test
```
## Docs
- [PostgresFixture](src/fixture/postgres-fixture.md)

View File

@@ -0,0 +1,114 @@
# PostgresFixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PostgresFixture` is an integration test fixture that manages the full lifecycle of a PostgreSQL 16 container using testcontainers. It implements the `ServiceFixture<PostgresConfig>` interface from `@suaveplan/testing-integration`, providing a ready-to-use PostgreSQL instance for tests with env-bridge passthrough, in-container health checking via `pg_isready`, and full configuration control over database name, username, and password.
The fixture intentionally excludes the `pg` client from its dependencies — it returns a plain `PostgresConfig` object containing a connection URL and parsed components. Consumers provide their own PostgreSQL client, keeping the fixture lightweight and client-agnostic.
## Features
- **Env-bridge passthrough**: when `POSTGRES_URL` is set and non-empty, the fixture parses the URL into a `PostgresConfig` and returns immediately without starting Docker.
- **`pg_isready` health check**: runs `pg_isready -U postgres` inside the container — no external pg client or TCP connection required.
- **Injectable exec function**: the `execFn` option allows unit tests to stub the pg_isready call without a real PostgreSQL container.
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without Docker.
- **Configurable db/user/password**: override defaults via options; values flow into container environment variables and the returned connection URL.
- **URL parsing for passthrough**: `new URL(url)` extracts host, port, database, username, and password from the `POSTGRES_URL` env var.
## Usage: Basic Example
```typescript
import { PostgresFixture } from "@suaveplan/testing-integration-postgres";
const fixture = new PostgresFixture();
const config = await fixture.start();
// config.url → "postgresql://test:test@localhost:<randomPort>/test"
// config.host → "localhost"
// config.port → random ephemeral port
// config.database → "test"
// config.username → "test"
// config.password → "test"
await fixture.stop();
```
## Usage: Advanced Example
```typescript
import { PostgresFixture } from "@suaveplan/testing-integration-postgres";
import postgres from "postgres";
const fixture = new PostgresFixture({
database: "myapp",
username: "appuser",
password: "s3cr3t",
});
const config = await fixture.start();
const sql = postgres(config.url);
// Run migrations, seed data, run tests...
await sql`CREATE TABLE users (id serial PRIMARY KEY, name text)`;
await sql.end();
await fixture.stop();
```
## API Reference
### `class PostgresFixture`
Implements `ServiceFixture<PostgresConfig>`.
#### `constructor(options?: PostgresFixtureOptions)`
Creates a new fixture instance. All options are optional.
#### `name: string`
Always `"postgres"`. Used by `TestEnvironment` as the registry key.
#### `start(): Promise<PostgresConfig>`
Starts the service. Returns the runtime configuration. In passthrough mode (when `POSTGRES_URL` env var is set), returns immediately with parsed config. Otherwise starts a testcontainers container with the configured environment variables.
#### `stop(): Promise<void>`
Stops the container. No-op in passthrough mode or when not started.
#### `healthCheck(): Promise<boolean>`
Runs `pg_isready -U postgres` inside the container and returns `true` when exit code is `0`. Returns `false` if no container is active.
### `interface PostgresConfig`
| Field | Type | Description |
|-------|------|-------------|
| `url` | `string` | Full connection URL: `postgresql://user:pass@localhost:<port>/db` |
| `host` | `string` | Hostname |
| `port` | `number` | Port number |
| `database` | `string` | Database name |
| `username` | `string` | Username |
| `password` | `string` | Password |
### `interface PostgresFixtureOptions`
| Field | Type | Description |
|-------|------|-------------|
| `image?` | `string` | Docker image override. Defaults to `postgres:16-alpine`. |
| `database?` | `string` | Database name override. Defaults to `"test"`. |
| `username?` | `string` | Username override. Defaults to `"test"`. |
| `password?` | `string` | Password override. Defaults to `"test"`. |
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. |
| `execFn?` | `(cmd: string[]) => Promise<{ exitCode: number }>` | Injectable executor for health check unit testing. |
## Implementation Notes
- **Why `pg_isready` over TCP**: `pg_isready` is the canonical PostgreSQL health check tool, shipped in the official `postgres:16-alpine` image. It validates that PostgreSQL is accepting connections at the protocol level, which is more reliable than a bare TCP connect. It also avoids requiring a pg client in this fixture.
- **Dynamic import of testcontainers**: `GenericContainer` is deferred to `start()` time so unit tests that provide a `containerFactory` do not trigger testcontainers initialization.
- **URL construction**: the connection URL uses `postgresql://` (the PostgreSQL standard scheme) rather than `postgres://` to ensure compatibility with clients like `postgres` (node-postgres) and Prisma.
- **Passthrough URL parsing**: `pathname.slice(1)` strips the leading `/` from the URL path to extract the database name correctly.

View File

@@ -0,0 +1,43 @@
# @suaveplan/testing-integration-redis - Redis 7 testcontainers fixture for @suaveplan/testing-integration
## Summary
Redis 7 testcontainers fixture for `@suaveplan/testing-integration`.
Implements `ServiceFixture<RedisConfig>` — manages a Redis container lifecycle with env-bridge passthrough and TCP PING health checks. Returns a plain config; consumers bring their own Redis client.
<!-- begin:auto-docs -->
## Documentation tree
- [fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add -D @suaveplan/testing-integration-redis
```
## Quick Start
```typescript
import { RedisFixture } from "@suaveplan/testing-integration-redis";
const fixture = new RedisFixture();
const config = await fixture.start();
// config.url, config.host, config.port
await fixture.stop();
```
## Env-bridge passthrough
Set the env var to skip container startup:
```text
REDIS_URL=redis://localhost:6379
```
## Docs
- [RedisFixture](src/fixture/redis-fixture.md)

View File

@@ -0,0 +1,117 @@
# RedisFixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RedisFixture` is an integration test fixture that manages the full lifecycle of a Redis 7 container using testcontainers. It implements the `ServiceFixture<RedisConfig>` interface from `@suaveplan/testing-integration`, enabling consistent startup, health checking via TCP PING, and teardown as part of a `TestEnvironment` or standalone usage.
The fixture intentionally keeps `ioredis` and other Redis clients out of its dependencies — it returns a plain `RedisConfig` object with `url`, `host`, and `port`. Consumers bring their own Redis client library, keeping the fixture lightweight and framework-agnostic.
## Features
- **Env-bridge passthrough**: when `REDIS_URL` is set and non-empty, container startup is bypassed and the URL is parsed into a `RedisConfig` directly.
- **TCP PING health check**: connects to Redis via `node:net`, sends `PING\r\n`, and verifies the response starts with `+PONG`. No Redis client dependency required.
- **Injectable TCP ping function**: the `tcpPingFn` option allows unit tests to substitute the health check without a real network connection.
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without starting Docker.
- **URL parsing for passthrough**: `new URL(url)` extracts `hostname` and `port` from the `REDIS_URL` env var, ensuring `RedisConfig.host` and `RedisConfig.port` are always consistent with `RedisConfig.url`.
- **Idempotent stop**: calling `stop()` on an already-stopped or passthrough fixture is safe and does not throw.
## Usage: Basic Example
```typescript
import { RedisFixture } from "@suaveplan/testing-integration-redis";
const fixture = new RedisFixture();
const config = await fixture.start();
// config.url → "redis://localhost:<randomPort>"
// config.host → "localhost"
// config.port → random ephemeral port
// Use with ioredis:
// const redis = new Redis(config.url);
await fixture.stop();
```
## Usage: Advanced Example
```typescript
import { RedisFixture } from "@suaveplan/testing-integration-redis";
import { createClient } from "redis";
const fixture = new RedisFixture({
image: "redis:7-alpine",
tcpPingFn: async (port, host) => {
// Custom health check — connect and verify
const client = createClient({ socket: { host, port } });
try {
await client.connect();
const pong = await client.ping();
return pong === "PONG";
} catch {
return false;
} finally {
await client.disconnect();
}
},
});
const config = await fixture.start();
const client = createClient({ url: config.url });
await client.connect();
await client.set("key", "value");
await client.disconnect();
await fixture.stop();
```
## API Reference
### `class RedisFixture`
Implements `ServiceFixture<RedisConfig>`.
#### `constructor(options?: RedisFixtureOptions)`
Creates a new fixture instance. All options are optional.
#### `name: string`
Always `"redis"`. Used by `TestEnvironment` as the registry key.
#### `start(): Promise<RedisConfig>`
Starts the service. Returns the runtime configuration. In passthrough mode (when `REDIS_URL` env var is set), returns immediately with parsed config. Otherwise starts a testcontainers container.
#### `stop(): Promise<void>`
Stops the container. No-op in passthrough mode or when not started.
#### `healthCheck(): Promise<boolean>`
Returns `true` when a TCP PING to `localhost:<mappedPort>` returns `+PONG`. Returns `false` if no container is active or the TCP connection fails.
### `interface RedisConfig`
| Field | Type | Description |
|-------|------|-------------|
| `url` | `string` | Full Redis URL: `redis://localhost:<port>` |
| `host` | `string` | Hostname parsed from the URL |
| `port` | `number` | Port number parsed from the URL |
### `interface RedisFixtureOptions`
| Field | Type | Description |
|-------|------|-------------|
| `image?` | `string` | Docker image override. Defaults to `redis:7-alpine`. |
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. |
| `tcpPingFn?` | `(port: number, host: string) => Promise<boolean>` | Injectable TCP ping for health check unit testing. |
## Implementation Notes
- **Why TCP PING over Redis client**: including `ioredis` or `redis` in this fixture's dependencies would force consumers to use a specific client version. The TCP PING is protocol-level and works with any Redis server, requiring only Node.js's built-in `node:net` module.
- **Dynamic import of testcontainers**: the `GenericContainer` import is deferred to `start()` time so unit tests that provide a `containerFactory` do not trigger testcontainers initialization.
- **URL parsing for passthrough**: `new URL(url)` provides consistent host/port extraction. If the URL omits a port, the fixture defaults to `REDIS_CONTAINER_PORT` (6379).
- **Socket timeout**: the built-in `defaultTcpPing` uses a 2-second socket timeout to prevent health check hangs during container startup race conditions.

View File

@@ -0,0 +1,126 @@
# @suaveplan/testing-integration-step-ca - smallstep/step-ca testcontainers fixture for @suaveplan/testing-integration — real PKI for HTTPS/WebRTC integration tests
## Summary
A `ServiceFixture` for `smallstep/step-ca` that gives any integration test suite a real, ephemeral certificate authority — root cert, JWK provisioner, and an `issueCert()` helper that mints leaf certificates on demand.
<!-- begin:auto-docs -->
## Documentation tree
- [parse-pem-blocks](./src/fixture/parse-pem-blocks.md) — `parsePemBlocks` is a small, dependency-free PEM splitter used by `StepCaFixture.issueCert()` to separate a leaf certif…
- [step-ca-fixture](./src/fixture/step-ca-fixture.md) — `StepCaFixture` manages the full lifecycle of a `smallstep/step-ca` container for integration tests that need a real PK…
<!-- end:auto-docs -->
## Purpose
WebRTC, secure SMTPS/IMAPS, HTTPS edges (Caddy/nginx), and other integration scenarios refuse non-secure contexts. Without a real PKI chain you end up either rolling per-suite self-signed certs or sprinkling `rejectUnauthorized: false` everywhere. This fixture replaces both with a single `await fixture.start()` that returns a config with a trusted root cert, a fingerprint, and an `issueCert()` method backed by a JWK provisioner inside the container.
## Features
- `ServiceFixture<StepCaConfig>` implementation matching the `@suaveplan/testing-integration` contract — drops into `TestEnvironment` alongside `mailpit`, `garage-s3`, `postgres`, `redis`.
- 5-var env-bridge passthrough mode (`STEPCA_URL`, `STEPCA_ROOT_CERT_PEM`, `STEPCA_ROOT_CERT_PATH`, `STEPCA_FINGERPRINT`, `STEPCA_PROVISIONER_PASSWORD`); partial sets (4 of 5) fall through to container mode.
- Bake-init shell entrypoint runs `step ca init`, creates an EC P-256 JWK provisioner, merges it into `ca.json`, captures the root fingerprint, and `exec`s `step-ca` — all in one container start.
- Host-side root-cert tmpfile (`rootCertPath`) automatically materialised, available for `NODE_EXTRA_CA_CERTS`, Playwright `--ignore-certificate-errors-spki-list`, or volume-mounting into sibling containers.
- `issueCert({ commonName, sans?, ttl? })` shells `step ca certificate` against the JWK provisioner; returns `{ certPem, chainPem, keyPem, serial }`.
- Injectable `containerFactory`, `execFn`, and `fetchFn` for unit-testability of the fixture itself (100% Istanbul coverage on touched modules).
- HTTPS health probe with TLS verification disabled (root not yet trusted on the host).
## Basic example
```ts
import { StepCaFixture } from "@suaveplan/testing-integration-step-ca";
const ca = new StepCaFixture();
const config = await ca.start();
// Issue a leaf cert for our test server
const leaf = await ca.issueCert({
commonName: "host.test",
sans: ["host.test", "localhost"],
ttl: "24h",
});
// Use the root in a Node TLS context
import { createSecureContext } from "node:tls";
const trust = createSecureContext({ ca: config.rootCertPem });
await ca.stop();
```
## Advanced example — Playwright + secure context
```ts
import { TestEnvironment } from "@suaveplan/testing-integration";
import { StepCaFixture } from "@suaveplan/testing-integration-step-ca";
import { chromium } from "playwright";
const ca = new StepCaFixture();
const env = new TestEnvironment([ca]);
await env.startAll();
const cfg = ca.start ? await ca.start() : null;
// Mount the root into a sibling container or load it into the test process:
process.env.NODE_EXTRA_CA_CERTS = cfg!.rootCertPath;
// Issue an HTTPS cert for our app server
const { certPem, keyPem } = await ca.issueCert({
commonName: "app.test",
sans: ["app.test"],
ttl: "1h",
});
// Launch Playwright with the root trusted (Chromium will follow the system
// store; for tests, --ignore-certificate-errors-spki-list works too)
const browser = await chromium.launch({
args: [`--ignore-certificate-errors-spki-list=${cfg!.fingerprint}`],
});
await env.stopAll();
```
## API reference
### `class StepCaFixture implements ServiceFixture<StepCaConfig>`
#### `new StepCaFixture(options?: StepCaFixtureOptions)`
Options:
- `image?: string` — override the Docker image. Defaults to `STEPCA_IMAGE` (a pinned `smallstep/step-ca` tag).
- `caName?: string` — CA display name. Defaults to `"Suaveplan Test CA"`.
- `dnsNames?: string` — comma-separated DNS SANs for the CA. Defaults to `"localhost,step-ca"`.
- `provisionerName?: string` — JWK provisioner name. Defaults to `"api"`.
- `healthTimeoutMs?: number` / `healthPollIntervalMs?: number` — override health-poll timing.
- `containerFactory?`, `execFn?`, `fetchFn?` — injection points for unit-testing the fixture itself.
#### `start(): Promise<StepCaConfig>`
Returns `{ caUrl, rootCertPem, rootCertPath, fingerprint, provisionerName, provisionerPassword }`. In container mode, also materialises `rootCertPath` to a host-side tmpfile.
#### `stop(): Promise<void>`
Stops the container (idempotent) and unlinks the host-side root-cert tmpfile (best-effort).
#### `healthCheck(): Promise<boolean>`
`GET https://localhost:<mappedPort>/health` with TLS verification disabled. Returns `false` when no container has been started or on any network error.
#### `issueCert(opts: IssueCertOptions): Promise<IssuedCert>`
Issues a leaf via the JWK provisioner. Throws `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` on empty `commonName`, non-zero `step` exit, or read failures; throws `INTEGRATION.STEP_CA.PARSE_PEM_FAILED` on malformed PEM or invalid `step certificate inspect` JSON.
### `parsePemBlocks(text: string): { leaf: string; chain: string }`
Splits a PEM payload into the first `-----BEGIN CERTIFICATE-----`…`-----END CERTIFICATE-----` block (`leaf`) and the concatenation of remaining blocks (`chain`). Throws on empty input, non-PEM input, or unterminated blocks.
### `buildStepCaInitScript(args)`
Builds the bake-init shell script used as the container entrypoint. Exported for inspection in tests; not normally consumed directly.
## Implementation notes
- The container runs all init steps as part of its entrypoint (`sh -c "<initScript>"`) and `exec`s `step-ca` so health passes only after init has completed. The fixture then reads `root_ca.crt` and `secrets/fingerprint` via `container.exec`.
- `caName`, `dnsNames`, and `provisionerName` are validated against `^[A-Za-z0-9 _.-]{1,128}$` before being interpolated into the entrypoint script. Special characters are JSON-escaped via `JSON.stringify` so a malformed override cannot inject shell metacharacters.
- The default health-fetch uses `node:https.request` with `rejectUnauthorized: false`. The host trust store does not yet contain the test root, so TLS verification on the health probe must be off; this is the fixture's only HTTPS read of an untrusted endpoint.
- `parsePemBlocks` is a pure helper with no dependency on `step-ca` and is reused by `issueCert()` for splitting leaf-from-chain.
- Coverage gate: 100% Istanbul on every non-`*.types.ts`, non-barrel module. Container/`fetch` defaults are excluded via `/* istanbul ignore next */` because they're only exercisable in the integration smoke test.

View File

@@ -0,0 +1,63 @@
# parsePemBlocks
## Purpose
`parsePemBlocks` is a small, dependency-free PEM splitter used by `StepCaFixture.issueCert()` to separate a leaf certificate from its chain in a single concatenated PEM payload. The function is exported standalone because the same split is useful in any code that consumes `step ca certificate` output, where the leaf is followed by zero or more intermediate certs.
## Features
- Pure function — no I/O, no globals, no dependencies.
- Returns `{ leaf, chain }` where `leaf` is the first `-----BEGIN CERTIFICATE-----`…`-----END CERTIFICATE-----` block (inclusive of markers) and `chain` is the concatenation of every subsequent block separated by a single newline.
- Tolerant of arbitrary text between blocks (comments, blank lines, headers).
- Strict failures: empty / non-PEM input throws `"no PEM CERTIFICATE blocks found"`; a `BEGIN CERTIFICATE` line lacking a matching `END CERTIFICATE` throws `"unterminated PEM block"`.
## Usage: basic example
```ts
import { parsePemBlocks } from "@suaveplan/testing-integration-step-ca/fixture/step-ca-fixture";
const payload = `-----BEGIN CERTIFICATE-----
LEAF
-----END CERTIFICATE-----`;
const { leaf, chain } = parsePemBlocks(payload);
// leaf = full BEGIN/END block; chain = ""
```
## Usage: advanced example
```ts
const payload = [
"# Issued at 2026-04-28T10:33Z",
"-----BEGIN CERTIFICATE-----",
"LEAFLEAF",
"-----END CERTIFICATE-----",
"",
"-----BEGIN CERTIFICATE-----",
"INTERMEDIATE",
"-----END CERTIFICATE-----",
"-----BEGIN CERTIFICATE-----",
"ROOT",
"-----END CERTIFICATE-----",
].join("\n");
const { leaf, chain } = parsePemBlocks(payload);
// leaf = "-----BEGIN CERTIFICATE-----\nLEAFLEAF\n-----END CERTIFICATE-----"
// chain = "<INTERMEDIATE block>\n<ROOT block>"
```
## API reference
### `parsePemBlocks(text: string): { leaf: string; chain: string }`
- **Parameters:** `text` — the raw PEM payload to split.
- **Returns:** `{ leaf, chain }`. `chain` is the empty string when the payload contains exactly one block.
- **Throws:** `Error("StepCaFixture.parsePemBlocks: no PEM CERTIFICATE blocks found")` — payload contains no `BEGIN CERTIFICATE` line.
- **Throws:** `Error("StepCaFixture.parsePemBlocks: unterminated PEM block")` — payload has a `BEGIN` but no matching `END`.
## Implementation notes
- The implementation walks the payload by `indexOf("-----BEGIN CERTIFICATE-----")` then `indexOf("-----END CERTIFICATE-----")` — O(n) over the input, no regex backtracking risk.
- Blocks are sliced inclusive of both markers, so the returned strings are well-formed PEM and can be fed straight to `crypto.X509Certificate` or `tls.createSecureContext`.
- The chain separator is a single `\n` (not `\r\n`); callers that need OS-specific line endings should normalise after splitting.
- This function deliberately does NOT validate the base64 body or enforce 64-column wrapping — `step` and Node's TLS stack will reject malformed bodies on use, and tests of the splitter should not double-up on cryptographic validation.

View File

@@ -0,0 +1,86 @@
# StepCaFixture
## Purpose
`StepCaFixture` manages the full lifecycle of a `smallstep/step-ca` container for integration tests that need a real PKI chain — WebRTC, secure SMTPS/IMAPS, HTTPS edges (Caddy/nginx), and any scenario that refuses non-secure contexts. Implementing the `ServiceFixture<StepCaConfig>` contract from `@suaveplan/testing-integration`, it can be composed into a `TestEnvironment` alongside Mailpit, Garage S3, Postgres, Redis, and other fixtures, with parallel startup and reverse-order teardown.
## Features
- 5-var env-bridge passthrough mode (all of `STEPCA_URL`, `STEPCA_ROOT_CERT_PEM`, `STEPCA_ROOT_CERT_PATH`, `STEPCA_FINGERPRINT`, `STEPCA_PROVISIONER_PASSWORD`); a partial set (4 of 5) falls through to container mode.
- Bake-init shell entrypoint runs `step ca init`, generates an EC P-256 JWK provisioner, merges it into `ca.json`, captures the root fingerprint, and `exec`s `step-ca` — all in one container start.
- Host-side root-cert tmpfile (`rootCertPath`) materialised after health passes; suitable for `NODE_EXTRA_CA_CERTS`, Playwright trust args, or volume-mounting into sibling containers.
- HTTPS health probe with TLS verification disabled (root is not yet trusted on the host).
- `issueCert({ commonName, sans?, ttl? })` shells `step ca certificate` against the JWK provisioner; returns leaf + chain + key + serial.
- Injectable `containerFactory`, `execFn`, and `fetchFn` so the fixture itself is unit-testable at 100% Istanbul coverage.
## Usage: basic example
```ts
import { StepCaFixture } from "@suaveplan/testing-integration-step-ca";
const ca = new StepCaFixture();
const cfg = await ca.start();
console.log(cfg.fingerprint); // 64-hex SHA-256 of the root
console.log(cfg.rootCertPath); // host-side tmpfile path
const { certPem } = await ca.issueCert({ commonName: "host.test" });
await ca.stop();
```
## Usage: advanced example
```ts
import { TestEnvironment } from "@suaveplan/testing-integration";
import { StepCaFixture } from "@suaveplan/testing-integration-step-ca";
const ca = new StepCaFixture({
caName: "Edge Test CA",
dnsNames: "localhost,step-ca,edge.test",
provisionerName: "api",
healthTimeoutMs: 30_000,
});
const env = new TestEnvironment([ca]);
await env.startAll();
const cfg = await ca.start();
process.env.NODE_EXTRA_CA_CERTS = cfg.rootCertPath;
const leaf = await ca.issueCert({
commonName: "edge.test",
sans: ["edge.test", "edge.local"],
ttl: "24h",
});
await env.stopAll();
```
## API reference
### `new StepCaFixture(options?: StepCaFixtureOptions)`
`StepCaFixtureOptions` accepts `image`, `caName`, `dnsNames`, `provisionerName`, `healthTimeoutMs`, `healthPollIntervalMs`, plus the injectable test seams `containerFactory`, `execFn`, `fetchFn`.
### `start(): Promise<StepCaConfig>`
In env-bridge mode: returns the env-supplied config without starting a container. In container mode: starts the container with the bake-init entrypoint, polls `/health` until ok, reads `root_ca.crt` + `secrets/fingerprint` via exec, materialises the host-side tmpfile, and returns `{ caUrl, rootCertPem, rootCertPath, fingerprint, provisionerName, provisionerPassword }`. Throws on container failure; if `waitForHealth()` rejects the container is stopped and the reference cleared before the error propagates.
### `stop(): Promise<void>`
Idempotent. Removes the host-side root-cert tmpfile (best-effort) and stops the container exactly once.
### `healthCheck(): Promise<boolean>`
`GET https://localhost:<mappedPort>/health` via the injected `fetchFn` (default uses `node:https.request` with `rejectUnauthorized: false`). Returns `false` when no container is running, when the response is not `ok`, or on any network error.
### `issueCert(opts: IssueCertOptions): Promise<IssuedCert>`
Empty `commonName` → throws `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` without invoking exec. Non-zero `step` exit → `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` with stderr (or stdout fallback) attached as `cause`. Malformed PEM or `step certificate inspect` JSON failure → `INTEGRATION.STEP_CA.PARSE_PEM_FAILED`.
## Implementation notes
- The container runs init+serve in a single entrypoint (`sh -c "<initScript>"`); the script `exec`s `step-ca` so the running PID has the daemon, not the shell. Health passing therefore implies init has fully completed.
- `caName`, `dnsNames`, `provisionerName` are validated against `^[A-Za-z0-9 _.-]{1,128}$` and JSON-escaped via `JSON.stringify` before interpolation into the entrypoint script. This blocks shell-metacharacter injection at the caller boundary.
- The provisioner password is generated with `crypto.randomBytes(24).toString("hex")` (192 bits of entropy).
- `parsePemBlocks` (sibling helper) is the only PEM-aware code; `issueCert` delegates leaf/chain splitting to it.
- The `defaultHealthFetch` and `container.exec` fallback paths are marked `/* istanbul ignore next */` — they're only exercisable in the integration smoke test (gated by `INTEGRATION_DOCKER=1`), not unit tests.

View File

@@ -0,0 +1,45 @@
# @suaveplan/testing-integration-surrealdb - SurrealDB v3 testcontainers fixture for @suaveplan/testing-integration
## Summary
SurrealDB v3 testcontainers fixture for `@suaveplan/testing-integration`.
Implements `ServiceFixture<SurrealDBConfig>` — manages a SurrealDB container lifecycle with env-bridge passthrough, HTTP health checks, and random password generation per run.
<!-- begin:auto-docs -->
## Documentation tree
- [fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [surrealdb-fixture](./src/fixture/surrealdb-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add -D @suaveplan/testing-integration-surrealdb
```
## Quick Start
```typescript
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
const fixture = new SurrealDBFixture();
const config = await fixture.start();
// config.url, config.username, config.password, config.namespace, config.database
await fixture.stop();
```
## Env-bridge passthrough
Set all three env vars to skip container startup:
```text
SURREALDB_URL=ws://localhost:8000
SURREALDB_USERNAME=root
SURREALDB_PASSWORD=yourpassword
```
## Docs
- [SurrealDBFixture](src/fixture/surrealdb-fixture.md)

View File

@@ -0,0 +1,112 @@
# SurrealDBFixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SurrealDBFixture` is an integration test fixture that manages the full lifecycle of a SurrealDB v3 container using testcontainers. It implements the `ServiceFixture<SurrealDBConfig>` interface from `@suaveplan/testing-integration`, enabling consistent startup, health checking, and teardown as part of a `TestEnvironment` or standalone usage.
The fixture supports an env-bridge passthrough mode so developer workstations with live SurrealDB instances can skip container startup entirely — just set the three required environment variables and tests run against the real service without Docker overhead.
## Features
- **Env-bridge passthrough**: when `SURREALDB_URL`, `SURREALDB_USERNAME`, and `SURREALDB_PASSWORD` are all set and non-empty, container startup is bypassed and env values are returned directly.
- **HTTP health check**: polls `GET /health` on SurrealDB's HTTP port (same port as WebSocket) to verify the service is ready before returning from `start()`.
- **Random password per run**: generates a new `crypto.randomUUID()` password each time a container starts, preventing credential reuse across test suites.
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without starting Docker, achieving 100% Istanbul coverage without integration dependencies.
- **Configurable namespace and database**: override defaults via options to isolate test suites at the logical database level.
- **Idempotent stop**: calling `stop()` on an already-stopped or passthrough fixture is safe and does not throw.
## Usage: Basic Example
```typescript
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
const fixture = new SurrealDBFixture();
const config = await fixture.start();
// config.url → "ws://localhost:<randomPort>"
// config.username → "root"
// config.password → auto-generated UUID
// config.namespace → "test"
// config.database → "test"
await fixture.stop();
```
## Usage: Advanced Example
```typescript
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import Surreal from "surrealdb";
const fixture = new SurrealDBFixture({
image: "surrealdb/surrealdb:v3.0.4",
namespace: "myapp",
database: "integration",
});
const config = await fixture.start();
const db = new Surreal();
await db.connect(config.url);
await db.use({ namespace: config.namespace, database: config.database });
await db.signin({ username: config.username, password: config.password });
// run tests…
await db.close();
await fixture.stop();
```
## API Reference
### `class SurrealDBFixture`
Implements `ServiceFixture<SurrealDBConfig>`.
#### `constructor(options?: SurrealDBFixtureOptions)`
Creates a new fixture instance. All options are optional.
#### `name: string`
Always `"surrealdb"`. Used by `TestEnvironment` as the registry key.
#### `start(): Promise<SurrealDBConfig>`
Starts the service. Returns the runtime configuration on success. If all three env vars are set, returns passthrough config immediately. Otherwise starts a testcontainers container and waits for the HTTP health endpoint.
#### `stop(): Promise<void>`
Stops the container. No-op in passthrough mode or when `start()` was never called.
#### `healthCheck(): Promise<boolean>`
Returns `true` when `GET http://localhost:<port>/health` returns an OK response. Returns `false` if no container is active or the request fails.
### `interface SurrealDBConfig`
| Field | Type | Description |
|-------|------|-------------|
| `url` | `string` | WebSocket URL: `ws://localhost:<port>` |
| `namespace` | `string` | SurrealDB namespace |
| `database` | `string` | SurrealDB database name |
| `username` | `string` | Auth username (always `root` in container mode) |
| `password` | `string` | Auth password (random UUID in container mode) |
### `interface SurrealDBFixtureOptions`
| Field | Type | Description |
|-------|------|-------------|
| `image?` | `string` | Docker image override. Defaults to `surrealdb/surrealdb:v3.0.4`. |
| `namespace?` | `string` | Namespace override. Defaults to `"test"`. |
| `database?` | `string` | Database override. Defaults to `"test"`. |
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. When provided, replaces the real `GenericContainer` constructor. |
## Implementation Notes
- **Why HTTP health over WebSocket**: the `/health` HTTP endpoint is simpler to poll and does not require a SurrealDB client package in this fixture. WebSocket handshakes are stateful and require protocol negotiation; plain HTTP is sufficient for readiness signalling.
- **Why random password per run**: reusing a static password across test runs creates a security surface for tests running in CI with shared Docker networks. UUID-generation via `crypto.randomUUID()` (the Web Crypto API, universally available in modern Bun and Node.js) is both fast and cryptographically random.
- **Dynamic import of testcontainers**: `GenericContainer` is dynamically imported inside `start()` to keep the module side-effect-free and to allow the `containerFactory` injection path to bypass the import entirely for unit tests that provide their own factory.
- **Port mapping**: testcontainers allocates an ephemeral host port for each `withExposedPorts()` declaration. `getMappedPort(8000)` retrieves the actual host port after the container starts.

View File

@@ -0,0 +1,44 @@
# @suaveplan/testing-integration-typesense - Typesense 27 testcontainers fixture for @suaveplan/testing-integration
## Summary
Typesense 27 testcontainers fixture for `@suaveplan/testing-integration`.
Implements `ServiceFixture<TypesenseConfig>` — manages a Typesense container lifecycle with env-bridge passthrough, JSON health checks, and a random API key per run.
<!-- begin:auto-docs -->
## Documentation tree
- [fixture](./src/fixture/typesense-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [typesense-fixture](./src/fixture/typesense-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [typesense-fixture](./src/fixture/typesense-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [typesense-fixture](./src/fixture/typesense-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [typesense-fixture](./src/fixture/typesense-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [typesense-fixture](./src/fixture/typesense-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add -D @suaveplan/testing-integration-typesense
```
## Quick Start
```typescript
import { TypesenseFixture } from "@suaveplan/testing-integration-typesense";
const fixture = new TypesenseFixture();
const config = await fixture.start();
// config.url, config.apiKey
await fixture.stop();
```
## Env-bridge passthrough
Set both env vars to skip container startup:
```text
TYPESENSE_URL=http://localhost:8108
TYPESENSE_API_KEY=your-api-key
```
## Docs
- [TypesenseFixture](src/fixture/typesense-fixture.md)

View File

@@ -0,0 +1,103 @@
# TypesenseFixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TypesenseFixture` is an integration test fixture that manages the full lifecycle of a Typesense 27 container using testcontainers. It implements the `ServiceFixture<TypesenseConfig>` interface from `@suaveplan/testing-integration`, providing a ready-to-use Typesense search engine instance with a randomly generated API key, CORS enabled, and JSON health checking.
The fixture returns a plain `TypesenseConfig` with `url` and `apiKey` fields. Consumers bring their own Typesense client (`typesense` or `@typesense/instantsearch-adapter`), keeping the fixture dependency-free.
## Features
- **Env-bridge passthrough**: when both `TYPESENSE_URL` and `TYPESENSE_API_KEY` are set and non-empty, the fixture returns them directly. Partial sets fall through to container mode.
- **Random API key**: generates a UUID per run via `crypto.randomUUID()`, preventing credential reuse across test suites.
- **JSON health check**: fetches `GET /health`, parses the JSON response, and checks `data.ok === true`. Returns `false` on non-200 responses or parse errors.
- **`--enable-cors` flag**: the container command includes `--enable-cors` to support browser-based test consumers.
- **Injectable fetch**: the `fetchFn` option replaces `globalThis.fetch` in health checks for unit testing.
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without Docker.
## Usage: Basic Example
```typescript
import { TypesenseFixture } from "@suaveplan/testing-integration-typesense";
const fixture = new TypesenseFixture();
const config = await fixture.start();
// config.url → "http://localhost:<randomPort>"
// config.apiKey → auto-generated UUID
await fixture.stop();
```
## Usage: Advanced Example
```typescript
import { TypesenseFixture } from "@suaveplan/testing-integration-typesense";
import Typesense from "typesense";
const fixture = new TypesenseFixture({ image: "typesense/typesense:27.1" });
const config = await fixture.start();
const client = new Typesense.Client({
nodes: [{ url: config.url }],
apiKey: config.apiKey,
connectionTimeoutSeconds: 2,
});
await client.collections().create({
name: "products",
fields: [{ name: "name", type: "string" }],
});
await fixture.stop();
```
## API Reference
### `class TypesenseFixture`
Implements `ServiceFixture<TypesenseConfig>`.
#### `constructor(options?: TypesenseFixtureOptions)`
Creates a new fixture instance. All options are optional.
#### `name: string`
Always `"typesense"`. Used by `TestEnvironment` as the registry key.
#### `start(): Promise<TypesenseConfig>`
Starts the service. In passthrough mode returns env var values immediately. Otherwise starts a testcontainers container with a generated API key.
#### `stop(): Promise<void>`
Stops the container. No-op in passthrough mode or when not started.
#### `healthCheck(): Promise<boolean>`
Fetches `GET <url>/health`, parses JSON, and returns `true` when `response.ok === true`. Returns `false` if no container is active, the HTTP status is non-OK, or the response body does not include `{ ok: true }`.
### `interface TypesenseConfig`
| Field | Type | Description |
|-------|------|-------------|
| `url` | `string` | HTTP URL: `http://localhost:<port>` |
| `apiKey` | `string` | API key (UUID generated per run) |
### `interface TypesenseFixtureOptions`
| Field | Type | Description |
|-------|------|-------------|
| `image?` | `string` | Docker image override. Defaults to `typesense/typesense:27.1`. |
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. |
| `fetchFn?` | `typeof fetch` | Injectable fetch for health check unit testing. |
## Implementation Notes
- **Why parse `{ ok: true }` not just HTTP 200**: Typesense returns 200 with `{ ok: false }` during startup when the node is initializing but not yet ready to serve requests. Checking `data.ok === true` gives an accurate readiness signal.
- **Why `--enable-cors`**: browser-based consumers (e.g. instantsearch adapters in JSDOM environments or Playwright tests) require CORS headers. The flag is low-cost to add and avoids silent failures in those environments.
- **UUID API key rationale**: Typesense requires an API key on startup. Using a UUID ensures the key is unique per test run, preventing cross-run credential collisions in shared test environments.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.

Some files were not shown because too many files have changed in this diff Show More