Initial Commit
This commit is contained in:
25
packages/testkit/testing/README.md
Normal file
25
packages/testkit/testing/README.md
Normal 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 -->
|
||||
1
packages/testkit/testing/_intro.md
Normal file
1
packages/testkit/testing/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Testing runners, DOM helpers, E2E harnesses, and A/B testing.
|
||||
103
packages/testkit/testing/ab-testing/README.md
Normal file
103
packages/testkit/testing/ab-testing/README.md
Normal 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';
|
||||
```
|
||||
117
packages/testkit/testing/ab-testing/package.json
Normal file
117
packages/testkit/testing/ab-testing/package.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"name": "@suaveplan/ab-testing",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Enterprise A/B testing and experimentation: deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, and OTel instrumentation",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./bucketing": {
|
||||
"import": {
|
||||
"types": "./dist/bucketing/index.d.ts",
|
||||
"default": "./dist/bucketing/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/bucketing/index.d.ts",
|
||||
"default": "./dist/bucketing/index.js"
|
||||
}
|
||||
},
|
||||
"./targeting": {
|
||||
"import": {
|
||||
"types": "./dist/targeting/index.d.ts",
|
||||
"default": "./dist/targeting/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/targeting/index.d.ts",
|
||||
"default": "./dist/targeting/index.js"
|
||||
}
|
||||
},
|
||||
"./results": {
|
||||
"import": {
|
||||
"types": "./dist/results/index.d.ts",
|
||||
"default": "./dist/results/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/results/index.d.ts",
|
||||
"default": "./dist/results/index.js"
|
||||
}
|
||||
},
|
||||
"./providers": {
|
||||
"import": {
|
||||
"types": "./dist/providers/index.d.ts",
|
||||
"default": "./dist/providers/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/providers/index.d.ts",
|
||||
"default": "./dist/providers/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes.d.ts",
|
||||
"default": "./dist/error-codes.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/error-codes.d.ts",
|
||||
"default": "./dist/error-codes.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/logger": "workspace:^",
|
||||
"@suaveplan/feature-flags": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
116
packages/testkit/testing/ab-testing/src/bucketing/bucketing.md
Normal file
116
packages/testkit/testing/ab-testing/src/bucketing/bucketing.md
Normal 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.
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { bucketUser, normaliseWeights, selectVariant } from "./bucketing.js";
|
||||
|
||||
const makeVariant = (id: string, weight: number, isControl = false) => ({
|
||||
id,
|
||||
name: id,
|
||||
weight,
|
||||
isControl,
|
||||
});
|
||||
|
||||
describe("bucketUser", () => {
|
||||
it("returns a number in [0, 1)", { timeout: 5000 }, () => {
|
||||
const b = bucketUser("user-1", "exp-1");
|
||||
expect(b).toBeGreaterThanOrEqual(0);
|
||||
expect(b).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("is deterministic for the same inputs", { timeout: 5000 }, () => {
|
||||
const b1 = bucketUser("user-abc", "exp-xyz");
|
||||
const b2 = bucketUser("user-abc", "exp-xyz");
|
||||
expect(b1).toBe(b2);
|
||||
});
|
||||
|
||||
it("produces different values for different users", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b1 = bucketUser("user-1", "exp-1");
|
||||
const b2 = bucketUser("user-2", "exp-1");
|
||||
expect(b1).not.toBe(b2);
|
||||
});
|
||||
|
||||
it("produces different values for different experiments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const b1 = bucketUser("user-1", "exp-1");
|
||||
const b2 = bucketUser("user-1", "exp-2");
|
||||
expect(b1).not.toBe(b2);
|
||||
});
|
||||
|
||||
it("uses seed to differentiate buckets", { timeout: 5000 }, () => {
|
||||
const b1 = bucketUser("user-1", "exp-1", 0);
|
||||
const b2 = bucketUser("user-1", "exp-1", 12345);
|
||||
expect(b1).not.toBe(b2);
|
||||
});
|
||||
|
||||
it("handles empty userId", { timeout: 5000 }, () => {
|
||||
const b = bucketUser("", "exp-1");
|
||||
expect(b).toBeGreaterThanOrEqual(0);
|
||||
expect(b).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("handles empty experimentId", { timeout: 5000 }, () => {
|
||||
const b = bucketUser("user-1", "");
|
||||
expect(b).toBeGreaterThanOrEqual(0);
|
||||
expect(b).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("distributes roughly uniformly across many users", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const N = 10_000;
|
||||
let above50 = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (bucketUser(`u-${i}`, "exp") >= 0.5) above50++;
|
||||
}
|
||||
// Expect roughly 50% above 0.5 (within 5% tolerance)
|
||||
expect(above50 / N).toBeGreaterThan(0.45);
|
||||
expect(above50 / N).toBeLessThan(0.55);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normaliseWeights", () => {
|
||||
it("returns empty array for empty input", { timeout: 5000 }, () => {
|
||||
expect(normaliseWeights([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes zero-weight variants", { timeout: 5000 }, () => {
|
||||
const variants = [makeVariant("a", 0), makeVariant("b", 1)];
|
||||
const result = normaliseWeights(variants);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("excludes negative-weight variants", { timeout: 5000 }, () => {
|
||||
const variants = [makeVariant("a", -5), makeVariant("b", 10)];
|
||||
const result = normaliseWeights(variants);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.id).toBe("b");
|
||||
});
|
||||
|
||||
it("returns empty array when all weights are zero or negative", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const variants = [makeVariant("a", 0), makeVariant("b", -1)];
|
||||
expect(normaliseWeights(variants)).toEqual([]);
|
||||
});
|
||||
|
||||
it("last element has normalizedWeight exactly 1.0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const variants = [
|
||||
makeVariant("a", 1),
|
||||
makeVariant("b", 2),
|
||||
makeVariant("c", 3),
|
||||
];
|
||||
const result = normaliseWeights(variants);
|
||||
expect(result[result.length - 1]?.normalizedWeight).toBe(1.0);
|
||||
});
|
||||
|
||||
it("weights sum to 1 for equal-weight variants", { timeout: 5000 }, () => {
|
||||
const variants = [makeVariant("a", 1), makeVariant("b", 1)];
|
||||
const result = normaliseWeights(variants);
|
||||
expect(result[0]?.normalizedWeight).toBeCloseTo(0.5);
|
||||
expect(result[1]?.normalizedWeight).toBe(1.0);
|
||||
});
|
||||
|
||||
it("handles single variant", { timeout: 5000 }, () => {
|
||||
const variants = [makeVariant("only", 5)];
|
||||
const result = normaliseWeights(variants);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.normalizedWeight).toBe(1.0);
|
||||
});
|
||||
|
||||
it("preserves variant properties", { timeout: 5000 }, () => {
|
||||
const variants = [
|
||||
makeVariant("ctrl", 50, true),
|
||||
makeVariant("treat", 50),
|
||||
];
|
||||
const result = normaliseWeights(variants);
|
||||
expect(result[0]?.isControl).toBe(true);
|
||||
expect(result[1]?.isControl).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectVariant", () => {
|
||||
it("throws RangeError for empty array", { timeout: 5000 }, () => {
|
||||
expect(() => selectVariant([], 0.5)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it("selects single variant regardless of bucket", { timeout: 5000 }, () => {
|
||||
const variants = normaliseWeights([makeVariant("only", 1)]);
|
||||
expect(selectVariant(variants, 0)).toStrictEqual(
|
||||
expect.objectContaining({ id: "only" })
|
||||
);
|
||||
expect(selectVariant(variants, 0.99)).toStrictEqual(
|
||||
expect.objectContaining({ id: "only" })
|
||||
);
|
||||
});
|
||||
|
||||
it("selects first variant for bucket 0", { timeout: 5000 }, () => {
|
||||
const variants = normaliseWeights([
|
||||
makeVariant("a", 1),
|
||||
makeVariant("b", 1),
|
||||
]);
|
||||
const v = selectVariant(variants, 0);
|
||||
expect(v.id).toBe("a");
|
||||
});
|
||||
|
||||
it("selects last variant for bucket near 1", { timeout: 5000 }, () => {
|
||||
const variants = normaliseWeights([
|
||||
makeVariant("a", 1),
|
||||
makeVariant("b", 1),
|
||||
]);
|
||||
const v = selectVariant(variants, 0.9999);
|
||||
expect(v.id).toBe("b");
|
||||
});
|
||||
|
||||
it("respects unequal weights", { timeout: 5000 }, () => {
|
||||
const variants = normaliseWeights([
|
||||
makeVariant("small", 10),
|
||||
makeVariant("large", 90),
|
||||
]);
|
||||
// bucket 0.05 should be in "small" slice [0, 0.1)
|
||||
expect(selectVariant(variants, 0.05).id).toBe("small");
|
||||
// bucket 0.5 should be in "large" slice [0.1, 1.0)
|
||||
expect(selectVariant(variants, 0.5).id).toBe("large");
|
||||
});
|
||||
|
||||
it("statistical distribution matches weights", { timeout: 5000 }, () => {
|
||||
const variants = normaliseWeights([
|
||||
makeVariant("a", 25),
|
||||
makeVariant("b", 75),
|
||||
]);
|
||||
const N = 10_000;
|
||||
let countA = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const bucket = bucketUser(`u-${i}`, "exp-stat");
|
||||
if (selectVariant(variants, bucket).id === "a") countA++;
|
||||
}
|
||||
// a should be ~25% (within 3%)
|
||||
expect(countA / N).toBeGreaterThan(0.22);
|
||||
expect(countA / N).toBeLessThan(0.28);
|
||||
});
|
||||
});
|
||||
116
packages/testkit/testing/ab-testing/src/bucketing/bucketing.ts
Normal file
116
packages/testkit/testing/ab-testing/src/bucketing/bucketing.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
DEFAULT_HASH_SEED,
|
||||
FNV_OFFSET_BASIS,
|
||||
FNV_PRIME,
|
||||
} from "../constants/index.js";
|
||||
import type { Variant } from "../types/common.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A variant augmented with its normalised cumulative weight for binary search. */
|
||||
export interface NormalisedVariant extends Variant {
|
||||
/** Normalised weight in `(0, 1]`. */
|
||||
readonly normalizedWeight: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FNV-1a hash
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute a deterministic FNV-1a 32-bit bucket for the given `userId` and
|
||||
* `experimentId`. Returns a float in `[0, 1)`.
|
||||
*
|
||||
* The hash basis is XOR'd with `seed` before iteration so that the same user
|
||||
* maps to independent buckets across experiments with different seeds.
|
||||
*
|
||||
* @param userId - User (or entity) identifier to bucket.
|
||||
* @param experimentId - Experiment identifier scoping the bucket.
|
||||
* @param seed - Optional integer seed XOR'd into the FNV offset basis.
|
||||
* @returns Float in `[0, 1)`.
|
||||
*/
|
||||
export function bucketUser(
|
||||
userId: string,
|
||||
experimentId: string,
|
||||
seed = DEFAULT_HASH_SEED
|
||||
): number {
|
||||
const input = `${userId}\0${experimentId}`;
|
||||
let hash = (FNV_OFFSET_BASIS ^ (seed >>> 0)) >>> 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, FNV_PRIME) >>> 0;
|
||||
}
|
||||
// Map [0, 2^32) → [0, 1)
|
||||
return hash / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weight normalisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Normalise variant weights into cumulative `[0, 1]` ranges.
|
||||
*
|
||||
* Variants with `weight <= 0` are excluded from the output. The returned
|
||||
* array is sorted in original order and each element's `normalizedWeight`
|
||||
* represents the **upper bound** of its cumulative slice (i.e. the last
|
||||
* element always has `normalizedWeight === 1.0`).
|
||||
*
|
||||
* @param variants - Raw variants with arbitrary positive weights.
|
||||
* @returns Variants with `normalizedWeight` set, excluding zero-weight entries.
|
||||
*/
|
||||
export function normaliseWeights(
|
||||
variants: ReadonlyArray<Variant>
|
||||
): NormalisedVariant[] {
|
||||
const eligible = variants.filter((v) => v.weight > 0);
|
||||
if (eligible.length === 0) return [];
|
||||
|
||||
const total = eligible.reduce((sum, v) => sum + v.weight, 0);
|
||||
let cumulative = 0;
|
||||
return eligible.map((v, i) => {
|
||||
cumulative += v.weight / total;
|
||||
// Force the last element to exactly 1.0 to avoid floating-point gaps
|
||||
const normalizedWeight = i === eligible.length - 1 ? 1.0 : cumulative;
|
||||
return { ...v, normalizedWeight };
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Select a variant for a given `bucket` using binary search over the
|
||||
* cumulative normalised weight array produced by {@link normaliseWeights}.
|
||||
*
|
||||
* @param variants - Normalised variants (output of `normaliseWeights`).
|
||||
* @param bucket - Float in `[0, 1)` from {@link bucketUser}.
|
||||
* @returns The matched variant.
|
||||
* @throws {RangeError} When `variants` is empty.
|
||||
*/
|
||||
export function selectVariant(
|
||||
variants: ReadonlyArray<NormalisedVariant>,
|
||||
bucket: number
|
||||
): Variant {
|
||||
if (variants.length === 0) {
|
||||
throw new RangeError("selectVariant: variants array must not be empty");
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = variants.length - 1;
|
||||
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const v = variants[mid];
|
||||
if (v !== undefined && bucket < v.normalizedWeight) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return variants[lo]!;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { NormalisedVariant } from "./bucketing.js";
|
||||
export {
|
||||
bucketUser,
|
||||
normaliseWeights,
|
||||
selectVariant,
|
||||
} from "./bucketing.js";
|
||||
32
packages/testkit/testing/ab-testing/src/constants/index.ts
Normal file
32
packages/testkit/testing/ab-testing/src/constants/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Default sample rate — include 100% of eligible users in the experiment. */
|
||||
export const DEFAULT_SAMPLE_RATE = 1.0;
|
||||
|
||||
/** Default FNV-1a seed offset XOR'd into the hash basis when no custom seed is provided. */
|
||||
export const DEFAULT_HASH_SEED = 0;
|
||||
|
||||
/** FNV-1a 32-bit offset basis. */
|
||||
export const FNV_OFFSET_BASIS = 2166136261;
|
||||
|
||||
/** FNV-1a 32-bit prime. */
|
||||
export const FNV_PRIME = 16777619;
|
||||
|
||||
/** Maximum allowed total weight for variants (normalisation denominator). */
|
||||
export const MAX_VARIANT_WEIGHT = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
/** Event bus topic emitted when a user is assigned to an experiment variant. */
|
||||
export const EVENT_AB_ASSIGNED = "ab:assigned";
|
||||
|
||||
/** Event bus topic emitted when a conversion event is tracked. */
|
||||
export const EVENT_AB_CONVERSION = "ab:conversion";
|
||||
|
||||
/** OTel meter name for all A/B testing instruments. */
|
||||
export const OTEL_METER_NAME = "@suaveplan/ab-testing";
|
||||
|
||||
/** OTel span name for the assign operation. */
|
||||
export const OTEL_SPAN_ASSIGN = "ab.assign";
|
||||
|
||||
/** OTel counter name tracking total variant assignments. */
|
||||
export const OTEL_COUNTER_ASSIGNMENTS = "ab.assignments_total";
|
||||
|
||||
/** OTel counter name tracking total conversion events. */
|
||||
export const OTEL_COUNTER_CONVERSIONS = "ab.conversions_total";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,521 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AB_CODES,
|
||||
AbTestingError,
|
||||
registerAbTestingErrorCodes,
|
||||
} from "../../error-codes.js";
|
||||
import type {
|
||||
ExperimentDefinition,
|
||||
ExperimentProvider,
|
||||
} from "../../types/common.js";
|
||||
import { createExperimentManager } from "./experiment-manager.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeExp = (
|
||||
overrides?: Partial<ExperimentDefinition>
|
||||
): ExperimentDefinition => ({
|
||||
id: "exp-1",
|
||||
name: "Test Experiment",
|
||||
status: "running",
|
||||
variants: [
|
||||
{ id: "ctrl", name: "Control", weight: 1, isControl: true },
|
||||
{ id: "treat", name: "Treatment", weight: 1 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const deterministicRng = (value: number) => ({
|
||||
nextFloat: () => value,
|
||||
nextUint32: () => Math.round(value * 0xffffffff),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// defineExperiment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("defineExperiment", () => {
|
||||
it("registers experiment successfully", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
expect(m.getExperiment("exp-1")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("replaces existing experiment", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp({ name: "Old" }));
|
||||
m.defineExperiment(makeExp({ name: "New" }));
|
||||
expect(m.getExperiment("exp-1")?.name).toBe("New");
|
||||
});
|
||||
|
||||
it("throws AB_INVALID_DEFINITION for invalid experiment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
expect(() =>
|
||||
m.defineExperiment({
|
||||
id: "",
|
||||
name: "x",
|
||||
status: "running",
|
||||
variants: [],
|
||||
})
|
||||
).toThrow(AbTestingError);
|
||||
});
|
||||
|
||||
it("preserves state when redefining experiment", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.assign("exp-1", "user-1");
|
||||
// Redefine — should not clear existing state
|
||||
m.defineExperiment(makeExp({ name: "Updated" }));
|
||||
// Already-assigned user still gets cached assignment
|
||||
const a = m.assign("exp-1", "user-1");
|
||||
expect(a.userId).toBe("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getExperiment / listExperiments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getExperiment", () => {
|
||||
it("returns null for missing experiment", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
expect(m.getExperiment("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns definition for registered experiment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
const def = m.getExperiment("exp-1");
|
||||
expect(def?.id).toBe("exp-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listExperiments", () => {
|
||||
it("returns empty array when none registered", { timeout: 5000 }, () => {
|
||||
expect(createExperimentManager().listExperiments()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns all registered experiments", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp({ id: "e1" }));
|
||||
m.defineExperiment(makeExp({ id: "e2" }));
|
||||
expect(m.listExperiments()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// assign
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("assign", () => {
|
||||
it("throws AB_EXPERIMENT_NOT_FOUND for unknown experiment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
expect(() => m.assign("missing", "u1")).toThrow(AbTestingError);
|
||||
try {
|
||||
m.assign("missing", "u1");
|
||||
} catch (e) {
|
||||
expect((e as AbTestingError).code).toBe(
|
||||
AB_CODES.EXPERIMENT_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AB_EXPERIMENT_INACTIVE for draft experiment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp({ status: "draft" }));
|
||||
expect(() => m.assign("exp-1", "u1")).toThrow(AbTestingError);
|
||||
try {
|
||||
m.assign("exp-1", "u1");
|
||||
} catch (e) {
|
||||
expect((e as AbTestingError).code).toBe(
|
||||
AB_CODES.EXPERIMENT_INACTIVE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AB_EXPERIMENT_INACTIVE for paused experiment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp({ status: "paused" }));
|
||||
expect(() => m.assign("exp-1", "u1")).toThrow(AbTestingError);
|
||||
});
|
||||
|
||||
it("throws AB_EXPERIMENT_INACTIVE for concluded experiment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp({ status: "concluded" }));
|
||||
expect(() => m.assign("exp-1", "u1")).toThrow(AbTestingError);
|
||||
});
|
||||
|
||||
it("returns inExperiment: false when targeting fails", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(
|
||||
makeExp({
|
||||
targeting: [
|
||||
{ attribute: "plan", operator: "eq", value: "premium" },
|
||||
],
|
||||
})
|
||||
);
|
||||
const a = m.assign("exp-1", "u1", { plan: "free" });
|
||||
expect(a.inExperiment).toBe(false);
|
||||
expect(a.variantId).toBe("");
|
||||
});
|
||||
|
||||
it("returns inExperiment: false when rng >= sampleRate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// rng always returns 1.0 → always excluded
|
||||
const m = createExperimentManager({ rng: deterministicRng(1.0) });
|
||||
m.defineExperiment(makeExp({ sampleRate: 0.5 }));
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.inExperiment).toBe(false);
|
||||
});
|
||||
|
||||
it("includes user when rng < sampleRate", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager({ rng: deterministicRng(0.0) });
|
||||
m.defineExperiment(makeExp({ sampleRate: 0.5 }));
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.inExperiment).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a variant with id and isControl set", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.inExperiment).toBe(true);
|
||||
expect(["ctrl", "treat"]).toContain(a.variantId);
|
||||
});
|
||||
|
||||
it("is deterministic for the same user", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
const a1 = m.assign("exp-1", "u1");
|
||||
const a2 = m.assign("exp-1", "u1");
|
||||
expect(a1.variantId).toBe(a2.variantId);
|
||||
});
|
||||
|
||||
it("stores assignment in cache", { timeout: 5000 }, () => {
|
||||
const clockFn = vi.fn().mockReturnValueOnce(100).mockReturnValue(200);
|
||||
const m = createExperimentManager({ clock: clockFn });
|
||||
m.defineExperiment(makeExp());
|
||||
const a1 = m.assign("exp-1", "u1");
|
||||
const a2 = m.assign("exp-1", "u1");
|
||||
// Second call returns cached assignment with original timestamp
|
||||
expect(a2.timestamp).toBe(a1.timestamp);
|
||||
});
|
||||
|
||||
it("uses clock for timestamp", { timeout: 5000 }, () => {
|
||||
const clock = () => 42_000;
|
||||
const m = createExperimentManager({ clock });
|
||||
m.defineExperiment(makeExp());
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.timestamp).toBe(42_000);
|
||||
});
|
||||
|
||||
it("emits ab:assigned event on eventBus", { timeout: 5000 }, () => {
|
||||
const emit = vi.fn();
|
||||
const m = createExperimentManager({ eventBus: { emit } as never });
|
||||
m.defineExperiment(makeExp());
|
||||
m.assign("exp-1", "u1");
|
||||
expect(emit).toHaveBeenCalledWith(
|
||||
"ab:assigned",
|
||||
expect.objectContaining({ userId: "u1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("calls provider.trackAssignment", { timeout: 5000 }, () => {
|
||||
const trackAssignment = vi.fn();
|
||||
const provider: ExperimentProvider = { name: "test", trackAssignment };
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.use(provider);
|
||||
m.assign("exp-1", "u1");
|
||||
expect(trackAssignment).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses provider.getAssignment when it returns non-null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider: ExperimentProvider = {
|
||||
name: "force",
|
||||
getAssignment: () => "treat",
|
||||
};
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.use(provider);
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.variantId).toBe("treat");
|
||||
});
|
||||
|
||||
it("skips provider.getAssignment when it returns null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider: ExperimentProvider = {
|
||||
name: "pass-through",
|
||||
getAssignment: () => null,
|
||||
};
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.use(provider);
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.inExperiment).toBe(true);
|
||||
expect(["ctrl", "treat"]).toContain(a.variantId);
|
||||
});
|
||||
|
||||
it("throws AB_INVALID_DEFINITION when all variants have zero weight", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(
|
||||
makeExp({
|
||||
variants: [
|
||||
{ id: "a", name: "A", weight: 0 },
|
||||
{ id: "b", name: "B", weight: -1 },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(() => m.assign("exp-1", "u1")).toThrow(AbTestingError);
|
||||
try {
|
||||
m.assign("exp-1", "u1");
|
||||
} catch (e) {
|
||||
expect((e as AbTestingError).code).toBe(
|
||||
AB_CODES.INVALID_DEFINITION
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit event or call provider for out-of-experiment assignment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const emit = vi.fn();
|
||||
const trackAssignment = vi.fn();
|
||||
const provider: ExperimentProvider = { name: "test", trackAssignment };
|
||||
const m = createExperimentManager({
|
||||
eventBus: { emit } as never,
|
||||
rng: deterministicRng(1.0),
|
||||
});
|
||||
m.defineExperiment(makeExp({ sampleRate: 0.5 }));
|
||||
m.use(provider);
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.inExperiment).toBe(false);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
expect(trackAssignment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles attributes default to empty object", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.inExperiment).toBe(true);
|
||||
});
|
||||
|
||||
it("provider getAssignment is only called when defined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider: ExperimentProvider = { name: "no-get" };
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.use(provider);
|
||||
// Should not throw
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.inExperiment).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// track
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("track", () => {
|
||||
it("throws AB_EXPERIMENT_NOT_FOUND for unknown experiment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
expect(() => m.track("missing", "u1", { name: "click" })).toThrow(
|
||||
AbTestingError
|
||||
);
|
||||
try {
|
||||
m.track("missing", "u1", { name: "click" });
|
||||
} catch (e) {
|
||||
expect((e as AbTestingError).code).toBe(
|
||||
AB_CODES.EXPERIMENT_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AB_METRIC_INVALID for invalid metric", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
expect(() => m.track("exp-1", "u1", { name: "" })).toThrow(
|
||||
AbTestingError
|
||||
);
|
||||
try {
|
||||
m.track("exp-1", "u1", { name: "" });
|
||||
} catch (e) {
|
||||
expect((e as AbTestingError).code).toBe(AB_CODES.METRIC_INVALID);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores metric events for user", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.assign("exp-1", "u1");
|
||||
m.track("exp-1", "u1", { name: "checkout" });
|
||||
const results = m.getResults("exp-1");
|
||||
expect(results.totalConversions).toBe(1);
|
||||
});
|
||||
|
||||
it("emits ab:conversion event on eventBus", { timeout: 5000 }, () => {
|
||||
const emit = vi.fn();
|
||||
const m = createExperimentManager({ eventBus: { emit } as never });
|
||||
m.defineExperiment(makeExp());
|
||||
m.assign("exp-1", "u1");
|
||||
m.track("exp-1", "u1", { name: "click" });
|
||||
expect(emit).toHaveBeenCalledWith(
|
||||
"ab:conversion",
|
||||
expect.objectContaining({ experimentId: "exp-1", userId: "u1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("calls provider.trackConversion", { timeout: 5000 }, () => {
|
||||
const trackConversion = vi.fn();
|
||||
const provider: ExperimentProvider = { name: "test", trackConversion };
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.use(provider);
|
||||
m.track("exp-1", "u1", { name: "checkout" });
|
||||
expect(trackConversion).toHaveBeenCalledWith("exp-1", "u1", {
|
||||
name: "checkout",
|
||||
});
|
||||
});
|
||||
|
||||
it("provider.trackConversion not called when not defined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const provider: ExperimentProvider = { name: "no-convert" };
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.use(provider);
|
||||
// Should not throw
|
||||
expect(() => m.track("exp-1", "u1", { name: "click" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getResults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getResults", () => {
|
||||
it("throws AB_EXPERIMENT_NOT_FOUND for unknown experiment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
expect(() => m.getResults("missing")).toThrow(AbTestingError);
|
||||
try {
|
||||
m.getResults("missing");
|
||||
} catch (e) {
|
||||
expect((e as AbTestingError).code).toBe(
|
||||
AB_CODES.EXPERIMENT_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns zero counts for experiment with no assignments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
const r = m.getResults("exp-1");
|
||||
expect(r.totalAssignments).toBe(0);
|
||||
expect(r.totalConversions).toBe(0);
|
||||
});
|
||||
|
||||
it("returns correct conversion rate", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(
|
||||
makeExp({ variants: [{ id: "only", name: "Only", weight: 1 }] })
|
||||
);
|
||||
m.assign("exp-1", "u1");
|
||||
m.assign("exp-1", "u2");
|
||||
m.track("exp-1", "u1", { name: "checkout" });
|
||||
const r = m.getResults("exp-1");
|
||||
expect(r.totalAssignments).toBe(2);
|
||||
expect(r.totalConversions).toBe(1);
|
||||
const v = r.variants.find((v) => v.variantId === "only");
|
||||
expect(v?.conversionRate).toBeCloseTo(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// use — chaining
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("use", () => {
|
||||
it("returns the manager for chaining", { timeout: 5000 }, () => {
|
||||
const m = createExperimentManager();
|
||||
const result = m.use({ name: "p1" });
|
||||
expect(result).toBe(m);
|
||||
});
|
||||
|
||||
it("accumulates multiple providers", { timeout: 5000 }, () => {
|
||||
const calls: string[] = [];
|
||||
const p1: ExperimentProvider = {
|
||||
name: "p1",
|
||||
trackAssignment: () => {
|
||||
calls.push("p1");
|
||||
},
|
||||
};
|
||||
const p2: ExperimentProvider = {
|
||||
name: "p2",
|
||||
trackAssignment: () => {
|
||||
calls.push("p2");
|
||||
},
|
||||
};
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.use(p1).use(p2);
|
||||
m.assign("exp-1", "u1");
|
||||
expect(calls).toEqual(["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("first non-null getAssignment wins", { timeout: 5000 }, () => {
|
||||
const p1: ExperimentProvider = {
|
||||
name: "p1",
|
||||
getAssignment: () => "ctrl",
|
||||
};
|
||||
const p2: ExperimentProvider = {
|
||||
name: "p2",
|
||||
getAssignment: () => "treat",
|
||||
};
|
||||
const m = createExperimentManager();
|
||||
m.defineExperiment(makeExp());
|
||||
m.use(p1).use(p2);
|
||||
const a = m.assign("exp-1", "u1");
|
||||
expect(a.variantId).toBe("ctrl");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// registerAbTestingErrorCodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("registerAbTestingErrorCodes", () => {
|
||||
it("is callable and idempotent", { timeout: 5000 }, () => {
|
||||
expect(() => registerAbTestingErrorCodes()).not.toThrow();
|
||||
expect(() => registerAbTestingErrorCodes()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
import type { Clock, EventBusLike, LoggerLike, Rng } from "@suaveplan/types";
|
||||
import {
|
||||
bucketUser,
|
||||
normaliseWeights,
|
||||
selectVariant,
|
||||
} from "../../bucketing/bucketing.js";
|
||||
import {
|
||||
DEFAULT_HASH_SEED,
|
||||
DEFAULT_SAMPLE_RATE,
|
||||
EVENT_AB_ASSIGNED,
|
||||
EVENT_AB_CONVERSION,
|
||||
OTEL_COUNTER_ASSIGNMENTS,
|
||||
OTEL_COUNTER_CONVERSIONS,
|
||||
OTEL_METER_NAME,
|
||||
OTEL_SPAN_ASSIGN,
|
||||
} from "../../constants/index.js";
|
||||
import { AB_CODES, AbTestingError } from "../../error-codes.js";
|
||||
import { computeResults } from "../../results/results.js";
|
||||
import { evaluateTargeting } from "../../targeting/targeting.js";
|
||||
import type {
|
||||
Assignment,
|
||||
ExperimentDefinition,
|
||||
ExperimentManager,
|
||||
ExperimentManagerOptions,
|
||||
ExperimentProvider,
|
||||
ExperimentResults,
|
||||
MetricEvent,
|
||||
UserAttributes,
|
||||
} from "../../types/common.js";
|
||||
import {
|
||||
ExperimentDefinitionSchema,
|
||||
MetricEventSchema,
|
||||
} from "../../types/schemas.js";
|
||||
import type { ExperimentState } from "./experiment-manager.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OTel instruments (lazy — no-op until an SDK provider is registered)
|
||||
// ---------------------------------------------------------------------------
|
||||
const meter = metrics.getMeter(OTEL_METER_NAME);
|
||||
const tracer = trace.getTracer(OTEL_METER_NAME);
|
||||
const assignmentsCounter = meter.createCounter(OTEL_COUNTER_ASSIGNMENTS);
|
||||
const conversionsCounter = meter.createCounter(OTEL_COUNTER_CONVERSIONS);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default clock / RNG
|
||||
// ---------------------------------------------------------------------------
|
||||
const defaultClock: Clock = () => Date.now();
|
||||
const defaultRng: Rng = {
|
||||
nextUint32: /* istanbul ignore next */ () =>
|
||||
(Math.random() * 0x1_0000_0000) >>> 0,
|
||||
nextFloat: () => Math.random(),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExperimentManagerImpl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ExperimentManagerImpl implements ExperimentManager {
|
||||
protected readonly logger: LoggerLike | undefined;
|
||||
protected readonly clock: Clock;
|
||||
protected readonly rng: Rng;
|
||||
protected readonly eventBus: EventBusLike | undefined;
|
||||
protected readonly hashSeed: number;
|
||||
|
||||
protected readonly experiments = new Map<string, ExperimentDefinition>();
|
||||
protected readonly state = new Map<string, ExperimentState>();
|
||||
protected readonly providers: ExperimentProvider[] = [];
|
||||
|
||||
constructor(opts: ExperimentManagerOptions = {}) {
|
||||
this.logger = opts.logger;
|
||||
this.clock = opts.clock ?? defaultClock;
|
||||
this.rng = opts.rng ?? defaultRng;
|
||||
this.eventBus = opts.eventBus;
|
||||
this.hashSeed = opts.hashSeed ?? DEFAULT_HASH_SEED;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// defineExperiment
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
defineExperiment(experiment: ExperimentDefinition): void {
|
||||
const parsed = ExperimentDefinitionSchema.safeParse(experiment);
|
||||
if (!parsed.success) {
|
||||
throw new AbTestingError(
|
||||
AB_CODES.INVALID_DEFINITION,
|
||||
`Invalid experiment definition: ${parsed.error.message}`,
|
||||
{ context: { id: experiment.id } }
|
||||
);
|
||||
}
|
||||
this.experiments.set(experiment.id, experiment);
|
||||
if (!this.state.has(experiment.id)) {
|
||||
this.state.set(experiment.id, {
|
||||
assignments: new Map(),
|
||||
conversions: new Map(),
|
||||
});
|
||||
}
|
||||
this.logger?.debug?.("ab-testing: defineExperiment", {
|
||||
experimentId: experiment.id,
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getExperiment / listExperiments
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
getExperiment(id: string): ExperimentDefinition | null {
|
||||
return this.experiments.get(id) ?? null;
|
||||
}
|
||||
|
||||
listExperiments(): ReadonlyArray<ExperimentDefinition> {
|
||||
return Array.from(this.experiments.values());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// assign
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
assign(
|
||||
experimentId: string,
|
||||
userId: string,
|
||||
attributes: UserAttributes = {}
|
||||
): Assignment {
|
||||
return tracer.startActiveSpan(OTEL_SPAN_ASSIGN, (span) => {
|
||||
try {
|
||||
return this._assign(experimentId, userId, attributes);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _assign(
|
||||
experimentId: string,
|
||||
userId: string,
|
||||
attributes: UserAttributes
|
||||
): Assignment {
|
||||
const exp = this.experiments.get(experimentId);
|
||||
if (!exp) {
|
||||
throw new AbTestingError(
|
||||
AB_CODES.EXPERIMENT_NOT_FOUND,
|
||||
`Experiment '${experimentId}' not found`,
|
||||
{ context: { experimentId } }
|
||||
);
|
||||
}
|
||||
if (exp.status !== "running") {
|
||||
throw new AbTestingError(
|
||||
AB_CODES.EXPERIMENT_INACTIVE,
|
||||
`Experiment '${experimentId}' is not running (status: ${exp.status})`,
|
||||
{ context: { experimentId, status: exp.status } }
|
||||
);
|
||||
}
|
||||
|
||||
const stateKey = `${experimentId}:${userId}`;
|
||||
const expState = this.state.get(experimentId)!;
|
||||
|
||||
// Return cached assignment for deterministic repeat calls
|
||||
const existing = expState.assignments.get(stateKey);
|
||||
if (existing) return existing;
|
||||
|
||||
// Check targeting
|
||||
const targeting = exp.targeting ?? [];
|
||||
const targeted = evaluateTargeting(targeting, attributes);
|
||||
if (!targeted) {
|
||||
const outAssignment = this._makeAssignment(
|
||||
experimentId,
|
||||
userId,
|
||||
"",
|
||||
false,
|
||||
false
|
||||
);
|
||||
expState.assignments.set(stateKey, outAssignment);
|
||||
return outAssignment;
|
||||
}
|
||||
|
||||
// Check sample rate
|
||||
const sampleRate = exp.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
||||
if (this.rng.nextFloat() >= sampleRate) {
|
||||
const outAssignment = this._makeAssignment(
|
||||
experimentId,
|
||||
userId,
|
||||
"",
|
||||
false,
|
||||
false
|
||||
);
|
||||
expState.assignments.set(stateKey, outAssignment);
|
||||
return outAssignment;
|
||||
}
|
||||
|
||||
// Check provider for forced variant
|
||||
let forcedVariantId: string | null = null;
|
||||
for (const provider of this.providers) {
|
||||
if (provider.getAssignment) {
|
||||
const forced = provider.getAssignment(experimentId, userId);
|
||||
if (forced !== null) {
|
||||
forcedVariantId = forced;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine variant
|
||||
let variant = forcedVariantId
|
||||
? exp.variants.find((v) => v.id === forcedVariantId)
|
||||
: undefined;
|
||||
|
||||
if (!variant) {
|
||||
const normalised = normaliseWeights(exp.variants);
|
||||
if (normalised.length === 0) {
|
||||
throw new AbTestingError(
|
||||
AB_CODES.INVALID_DEFINITION,
|
||||
`Experiment '${experimentId}' has no variants with positive weight`,
|
||||
{ context: { experimentId } }
|
||||
);
|
||||
}
|
||||
const bucket = bucketUser(userId, experimentId, this.hashSeed);
|
||||
variant = selectVariant(normalised, bucket);
|
||||
}
|
||||
|
||||
const assignment = this._makeAssignment(
|
||||
experimentId,
|
||||
userId,
|
||||
variant.id,
|
||||
variant.isControl ?? false,
|
||||
true
|
||||
);
|
||||
expState.assignments.set(stateKey, assignment);
|
||||
|
||||
// OTel counter
|
||||
assignmentsCounter.add(1, {
|
||||
experiment_id: experimentId,
|
||||
variant_id: variant.id,
|
||||
});
|
||||
|
||||
// Event bus
|
||||
this.eventBus?.emit?.(EVENT_AB_ASSIGNED, assignment);
|
||||
|
||||
// Provider tracking
|
||||
for (const provider of this.providers) {
|
||||
provider.trackAssignment?.(assignment);
|
||||
}
|
||||
|
||||
return assignment;
|
||||
}
|
||||
|
||||
private _makeAssignment(
|
||||
experimentId: string,
|
||||
userId: string,
|
||||
variantId: string,
|
||||
isControl: boolean,
|
||||
inExperiment: boolean
|
||||
): Assignment {
|
||||
return {
|
||||
experimentId,
|
||||
userId,
|
||||
variantId,
|
||||
isControl,
|
||||
timestamp: this.clock(),
|
||||
inExperiment,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// track
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
track(experimentId: string, userId: string, metric: MetricEvent): void {
|
||||
const exp = this.experiments.get(experimentId);
|
||||
if (!exp) {
|
||||
throw new AbTestingError(
|
||||
AB_CODES.EXPERIMENT_NOT_FOUND,
|
||||
`Experiment '${experimentId}' not found`,
|
||||
{ context: { experimentId } }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = MetricEventSchema.safeParse(metric);
|
||||
if (!parsed.success) {
|
||||
throw new AbTestingError(
|
||||
AB_CODES.METRIC_INVALID,
|
||||
`Invalid metric event: ${parsed.error.message}`,
|
||||
{ context: { experimentId, userId } }
|
||||
);
|
||||
}
|
||||
|
||||
const expState = this.state.get(experimentId)!;
|
||||
const events = expState.conversions.get(userId) ?? [];
|
||||
events.push(metric);
|
||||
expState.conversions.set(userId, events);
|
||||
|
||||
conversionsCounter.add(1, {
|
||||
experiment_id: experimentId,
|
||||
metric_name: metric.name,
|
||||
});
|
||||
|
||||
this.eventBus?.emit?.(EVENT_AB_CONVERSION, {
|
||||
experimentId,
|
||||
userId,
|
||||
metric,
|
||||
});
|
||||
|
||||
for (const provider of this.providers) {
|
||||
provider.trackConversion?.(experimentId, userId, metric);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getResults
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
getResults(experimentId: string): ExperimentResults {
|
||||
const exp = this.experiments.get(experimentId);
|
||||
if (!exp) {
|
||||
throw new AbTestingError(
|
||||
AB_CODES.EXPERIMENT_NOT_FOUND,
|
||||
`Experiment '${experimentId}' not found`,
|
||||
{ context: { experimentId } }
|
||||
);
|
||||
}
|
||||
|
||||
const expState = this.state.get(experimentId)!;
|
||||
const assignments = Array.from(expState.assignments.values());
|
||||
return computeResults(experimentId, assignments, expState.conversions);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// use
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use(provider: ExperimentProvider): this {
|
||||
this.providers.push(provider);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new {@link ExperimentManager} instance.
|
||||
*
|
||||
* @param options - Optional dependency injection overrides (logger, clock, rng,
|
||||
* eventBus, hashSeed).
|
||||
* @returns A fully-initialised experiment manager.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createExperimentManager } from '@suaveplan/ab-testing';
|
||||
*
|
||||
* const manager = createExperimentManager();
|
||||
*
|
||||
* manager.defineExperiment({
|
||||
* id: 'checkout-cta',
|
||||
* name: 'Checkout CTA',
|
||||
* status: 'running',
|
||||
* variants: [
|
||||
* { id: 'ctrl', name: 'Control', weight: 1, isControl: true },
|
||||
* { id: 'treat', name: 'Treatment', weight: 1 },
|
||||
* ],
|
||||
* });
|
||||
*
|
||||
* const assignment = manager.assign('checkout-cta', 'user-123');
|
||||
* console.log(assignment.variantId);
|
||||
* ```
|
||||
*/
|
||||
export function createExperimentManager(
|
||||
options?: ExperimentManagerOptions
|
||||
): ExperimentManager {
|
||||
return new ExperimentManagerImpl(options);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @internal — internal state stored per experiment. */
|
||||
export interface ExperimentState {
|
||||
/** Assignments keyed by `${experimentId}:${userId}`. */
|
||||
assignments: Map<string, import("../../types/common.js").Assignment>;
|
||||
/** Conversion events keyed by `userId`. */
|
||||
conversions: Map<string, import("../../types/common.js").MetricEvent[]>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createExperimentManager } from "./experiment-manager.js";
|
||||
59
packages/testkit/testing/ab-testing/src/error-codes.ts
Normal file
59
packages/testkit/testing/ab-testing/src/error-codes.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/ab-testing`.
|
||||
*
|
||||
* Each code is prefixed with `AB.` and resolved via `@suaveplan/error`'s
|
||||
* `defineCodes` registry. Callers can discriminate errors without `instanceof`
|
||||
* by inspecting the stable `error.code` string property.
|
||||
*/
|
||||
export const AB_CODES = defineCodes("AB", [
|
||||
"EXPERIMENT_NOT_FOUND",
|
||||
"EXPERIMENT_INACTIVE",
|
||||
"INVALID_DEFINITION",
|
||||
"VARIANT_NOT_FOUND",
|
||||
"METRIC_INVALID",
|
||||
] as const);
|
||||
|
||||
export type AbTestingErrorCode = (typeof AB_CODES)[keyof typeof AB_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/ab-testing` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` so callers can discriminate via `instanceof
|
||||
* AbTestingError` or the stable `error.code` property.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { AbTestingError, AB_CODES } from '@suaveplan/ab-testing/errors';
|
||||
*
|
||||
* try {
|
||||
* manager.assign('missing-exp', 'user-1');
|
||||
* } catch (err) {
|
||||
* if (err instanceof AbTestingError && err.code === AB_CODES.EXPERIMENT_NOT_FOUND) {
|
||||
* console.error('Experiment does not exist');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class AbTestingError extends SuaveplanError<AbTestingErrorCode> {
|
||||
public constructor(
|
||||
code: AbTestingErrorCode,
|
||||
message: string,
|
||||
options?: { cause?: Error; context?: Record<string, unknown> }
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "AbTestingError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all AB testing error codes with the global `@suaveplan/error`
|
||||
* registry. Safe to call multiple times — registration is idempotent.
|
||||
*/
|
||||
export function registerAbTestingErrorCodes(): void {
|
||||
// defineCodes registers the prefix+codes eagerly at module evaluation time;
|
||||
// this function exists as an explicit call-site for consumers that prefer
|
||||
// imperative registration (e.g. app bootstrap sequences).
|
||||
void AB_CODES;
|
||||
}
|
||||
55
packages/testkit/testing/ab-testing/src/index.ts
Normal file
55
packages/testkit/testing/ab-testing/src/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Core
|
||||
|
||||
// Bucketing
|
||||
export {
|
||||
bucketUser,
|
||||
type NormalisedVariant,
|
||||
normaliseWeights,
|
||||
selectVariant,
|
||||
} from "./bucketing/bucketing.js";
|
||||
export { createExperimentManager } from "./core/experiment-manager/experiment-manager.js";
|
||||
// Errors
|
||||
export {
|
||||
AB_CODES,
|
||||
AbTestingError,
|
||||
type AbTestingErrorCode,
|
||||
registerAbTestingErrorCodes,
|
||||
} from "./error-codes.js";
|
||||
// Providers
|
||||
export {
|
||||
ForceVariantProvider,
|
||||
LoggingProvider,
|
||||
type LoggingProviderOptions,
|
||||
} from "./providers/provider.js";
|
||||
|
||||
// Results
|
||||
export { computeResults } from "./results/results.js";
|
||||
// Targeting
|
||||
export { evaluateTargeting } from "./targeting/targeting.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
Assignment,
|
||||
ExperimentDefinition,
|
||||
ExperimentManager,
|
||||
ExperimentManagerOptions,
|
||||
ExperimentProvider,
|
||||
ExperimentResults,
|
||||
MetricEvent,
|
||||
TargetingRule,
|
||||
UserAttributes,
|
||||
Variant,
|
||||
VariantResults,
|
||||
} from "./types/common.js";
|
||||
|
||||
// Schemas
|
||||
export {
|
||||
type ExperimentDefinitionInput,
|
||||
ExperimentDefinitionSchema,
|
||||
type MetricEventInput,
|
||||
MetricEventSchema,
|
||||
type TargetingRuleInput,
|
||||
TargetingRuleSchema,
|
||||
type VariantInput,
|
||||
VariantSchema,
|
||||
} from "./types/schemas.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ForceVariantProvider,
|
||||
LoggingProvider,
|
||||
type LoggingProviderOptions,
|
||||
} from "./provider.js";
|
||||
116
packages/testkit/testing/ab-testing/src/providers/provider.md
Normal file
116
packages/testkit/testing/ab-testing/src/providers/provider.md
Normal 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.
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import type { Assignment, MetricEvent } from "../types/common.js";
|
||||
import { ForceVariantProvider, LoggingProvider } from "./provider.js";
|
||||
|
||||
const makeAssignment = (overrides?: Partial<Assignment>): Assignment => ({
|
||||
experimentId: "exp-1",
|
||||
userId: "user-1",
|
||||
variantId: "ctrl",
|
||||
isControl: true,
|
||||
timestamp: 1000,
|
||||
inExperiment: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeMetric = (overrides?: Partial<MetricEvent>): MetricEvent => ({
|
||||
name: "click",
|
||||
value: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("LoggingProvider", () => {
|
||||
it("has name 'logging-provider'", { timeout: 5000 }, () => {
|
||||
const p = new LoggingProvider();
|
||||
expect(p.name).toBe("logging-provider");
|
||||
});
|
||||
|
||||
it("calls onAssignment callback", { timeout: 5000 }, () => {
|
||||
const fn = vi.fn();
|
||||
const p = new LoggingProvider({ onAssignment: fn });
|
||||
const a = makeAssignment();
|
||||
p.trackAssignment(a);
|
||||
expect(fn).toHaveBeenCalledWith(a);
|
||||
});
|
||||
|
||||
it("calls onConversion callback", { timeout: 5000 }, () => {
|
||||
const fn = vi.fn();
|
||||
const p = new LoggingProvider({ onConversion: fn });
|
||||
const metric = makeMetric();
|
||||
p.trackConversion("exp-1", "user-1", metric);
|
||||
expect(fn).toHaveBeenCalledWith("exp-1", "user-1", metric);
|
||||
});
|
||||
|
||||
it("uses default console.log when no onAssignment provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const p = new LoggingProvider();
|
||||
p.trackAssignment(makeAssignment());
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses default console.log when no onConversion provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const p = new LoggingProvider();
|
||||
p.trackConversion("exp-1", "u1", makeMetric());
|
||||
expect(spy).toHaveBeenCalled();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ForceVariantProvider", () => {
|
||||
it("has name 'force-variant-provider'", { timeout: 5000 }, () => {
|
||||
const p = new ForceVariantProvider();
|
||||
expect(p.name).toBe("force-variant-provider");
|
||||
});
|
||||
|
||||
it("returns null when no overrides configured", { timeout: 5000 }, () => {
|
||||
const p = new ForceVariantProvider();
|
||||
expect(p.getAssignment("exp-1", "user-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when experiment not in overrides", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = new ForceVariantProvider({ "exp-2": { "user-1": "treat" } });
|
||||
expect(p.getAssignment("exp-1", "user-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns forced variant for specific user", { timeout: 5000 }, () => {
|
||||
const p = new ForceVariantProvider({
|
||||
"exp-1": { "qa-user": "treatment" },
|
||||
});
|
||||
expect(p.getAssignment("exp-1", "qa-user")).toBe("treatment");
|
||||
});
|
||||
|
||||
it("returns null for user not in overrides (no wildcard)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = new ForceVariantProvider({
|
||||
"exp-1": { "qa-user": "treatment" },
|
||||
});
|
||||
expect(p.getAssignment("exp-1", "other-user")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns wildcard variant for unlisted user", { timeout: 5000 }, () => {
|
||||
const p = new ForceVariantProvider({
|
||||
"exp-1": { "*": "treatment" },
|
||||
});
|
||||
expect(p.getAssignment("exp-1", "random-user")).toBe("treatment");
|
||||
});
|
||||
|
||||
it("user-specific override takes precedence over wildcard", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const p = new ForceVariantProvider({
|
||||
"exp-1": { "*": "treatment", "special-user": "control" },
|
||||
});
|
||||
expect(p.getAssignment("exp-1", "special-user")).toBe("control");
|
||||
expect(p.getAssignment("exp-1", "other-user")).toBe("treatment");
|
||||
});
|
||||
});
|
||||
131
packages/testkit/testing/ab-testing/src/providers/provider.ts
Normal file
131
packages/testkit/testing/ab-testing/src/providers/provider.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
Assignment,
|
||||
ExperimentProvider,
|
||||
MetricEvent,
|
||||
} from "../types/common.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LoggingProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for {@link LoggingProvider}.
|
||||
*/
|
||||
export interface LoggingProviderOptions {
|
||||
/**
|
||||
* Called when an assignment is tracked.
|
||||
* Defaults to `console.log`.
|
||||
*/
|
||||
onAssignment?: (assignment: Assignment) => void;
|
||||
/**
|
||||
* Called when a conversion is tracked.
|
||||
* Defaults to `console.log`.
|
||||
*/
|
||||
onConversion?: (
|
||||
experimentId: string,
|
||||
userId: string,
|
||||
metric: MetricEvent
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple {@link ExperimentProvider} that forwards assignment and conversion
|
||||
* events to configurable callbacks.
|
||||
*
|
||||
* Useful for debugging, test-time observation, or integration with custom
|
||||
* analytics pipelines that do not require a full provider implementation.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { LoggingProvider } from '@suaveplan/ab-testing/providers';
|
||||
*
|
||||
* const provider = new LoggingProvider({
|
||||
* onAssignment: (a) => analytics.track('Experiment Assigned', a),
|
||||
* onConversion: (expId, userId, metric) =>
|
||||
* analytics.track('Experiment Converted', { expId, userId, ...metric }),
|
||||
* });
|
||||
*
|
||||
* manager.use(provider);
|
||||
* ```
|
||||
*/
|
||||
export class LoggingProvider implements ExperimentProvider {
|
||||
readonly name = "logging-provider";
|
||||
|
||||
protected readonly opts: Required<LoggingProviderOptions>;
|
||||
|
||||
constructor(opts: LoggingProviderOptions = {}) {
|
||||
this.opts = {
|
||||
onAssignment:
|
||||
opts.onAssignment ??
|
||||
((a) => {
|
||||
console.log("[ab-testing] assigned", a);
|
||||
}),
|
||||
onConversion:
|
||||
opts.onConversion ??
|
||||
((expId, userId, metric) => {
|
||||
console.log(
|
||||
"[ab-testing] conversion",
|
||||
expId,
|
||||
userId,
|
||||
metric
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
trackAssignment(assignment: Assignment): void {
|
||||
this.opts.onAssignment(assignment);
|
||||
}
|
||||
|
||||
trackConversion(
|
||||
experimentId: string,
|
||||
userId: string,
|
||||
metric: MetricEvent
|
||||
): void {
|
||||
this.opts.onConversion(experimentId, userId, metric);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ForceVariantProvider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* An {@link ExperimentProvider} that forces specific users or all users in an
|
||||
* experiment to a pre-configured variant.
|
||||
*
|
||||
* Use this in QA, staging, or testing environments to verify specific variant
|
||||
* behaviour without relying on bucketing.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { ForceVariantProvider } from '@suaveplan/ab-testing/providers';
|
||||
*
|
||||
* // Force user 'qa-user' to 'treatment' in 'checkout-cta'
|
||||
* const provider = new ForceVariantProvider({
|
||||
* 'checkout-cta': { 'qa-user': 'treatment' },
|
||||
* });
|
||||
*
|
||||
* manager.use(provider);
|
||||
* ```
|
||||
*/
|
||||
export class ForceVariantProvider implements ExperimentProvider {
|
||||
readonly name = "force-variant-provider";
|
||||
|
||||
/** `{ [experimentId]: { [userId | '*']: variantId } }` */
|
||||
protected readonly overrides: Record<string, Record<string, string>>;
|
||||
|
||||
constructor(overrides: Record<string, Record<string, string>> = {}) {
|
||||
this.overrides = overrides;
|
||||
}
|
||||
|
||||
getAssignment(experimentId: string, userId: string): string | null {
|
||||
const expOverrides = this.overrides[experimentId];
|
||||
if (!expOverrides) return null;
|
||||
// User-specific override takes precedence over wildcard
|
||||
const userOverride = expOverrides[userId];
|
||||
if (userOverride !== undefined) return userOverride;
|
||||
const wildcard = expOverrides["*"];
|
||||
return wildcard ?? null;
|
||||
}
|
||||
}
|
||||
1
packages/testkit/testing/ab-testing/src/results/index.ts
Normal file
1
packages/testkit/testing/ab-testing/src/results/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { computeResults } from "./results.js";
|
||||
118
packages/testkit/testing/ab-testing/src/results/results.md
Normal file
118
packages/testkit/testing/ab-testing/src/results/results.md
Normal 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.
|
||||
182
packages/testkit/testing/ab-testing/src/results/results.test.ts
Normal file
182
packages/testkit/testing/ab-testing/src/results/results.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Assignment, MetricEvent } from "../types/common.js";
|
||||
import { computeResults } from "./results.js";
|
||||
|
||||
const makeAssignment = (
|
||||
userId: string,
|
||||
variantId: string,
|
||||
inExperiment = true,
|
||||
isControl = false
|
||||
): Assignment => ({
|
||||
experimentId: "exp-1",
|
||||
userId,
|
||||
variantId,
|
||||
isControl,
|
||||
timestamp: Date.now(),
|
||||
inExperiment,
|
||||
});
|
||||
|
||||
describe("computeResults", () => {
|
||||
it("returns empty variants when no assignments", { timeout: 5000 }, () => {
|
||||
const result = computeResults("exp-1", [], new Map());
|
||||
expect(result.experimentId).toBe("exp-1");
|
||||
expect(result.variants).toHaveLength(0);
|
||||
expect(result.totalAssignments).toBe(0);
|
||||
expect(result.totalConversions).toBe(0);
|
||||
});
|
||||
|
||||
it("excludes out-of-experiment assignments", { timeout: 5000 }, () => {
|
||||
const assignments = [
|
||||
makeAssignment("u1", "", false),
|
||||
makeAssignment("u2", "", false),
|
||||
];
|
||||
const result = computeResults("exp-1", assignments, new Map());
|
||||
expect(result.totalAssignments).toBe(0);
|
||||
expect(result.variants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("counts in-experiment assignments per variant", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const assignments = [
|
||||
makeAssignment("u1", "ctrl", true, true),
|
||||
makeAssignment("u2", "ctrl", true, true),
|
||||
makeAssignment("u3", "treat", true, false),
|
||||
];
|
||||
const result = computeResults("exp-1", assignments, new Map());
|
||||
expect(result.totalAssignments).toBe(3);
|
||||
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
|
||||
const treat = result.variants.find((v) => v.variantId === "treat");
|
||||
expect(ctrl?.assignments).toBe(2);
|
||||
expect(treat?.assignments).toBe(1);
|
||||
});
|
||||
|
||||
it("counts conversions for users with metric events", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const assignments = [
|
||||
makeAssignment("u1", "ctrl", true, true),
|
||||
makeAssignment("u2", "ctrl", true, true),
|
||||
];
|
||||
const conversions = new Map<string, MetricEvent[]>([
|
||||
["u1", [{ name: "checkout" }]],
|
||||
]);
|
||||
const result = computeResults("exp-1", assignments, conversions);
|
||||
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
|
||||
expect(ctrl?.conversions).toBe(1);
|
||||
expect(ctrl?.conversionRate).toBeCloseTo(0.5);
|
||||
expect(result.totalConversions).toBe(1);
|
||||
});
|
||||
|
||||
it("counts each user as at most one conversion", { timeout: 5000 }, () => {
|
||||
const assignments = [makeAssignment("u1", "ctrl", true, true)];
|
||||
const conversions = new Map<string, MetricEvent[]>([
|
||||
["u1", [{ name: "click" }, { name: "click" }, { name: "click" }]],
|
||||
]);
|
||||
const result = computeResults("exp-1", assignments, conversions);
|
||||
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
|
||||
expect(ctrl?.conversions).toBe(1);
|
||||
});
|
||||
|
||||
it("aggregates metric values correctly", { timeout: 5000 }, () => {
|
||||
const assignments = [makeAssignment("u1", "ctrl", true, true)];
|
||||
const conversions = new Map<string, MetricEvent[]>([
|
||||
[
|
||||
"u1",
|
||||
[
|
||||
{ name: "revenue", value: 50 },
|
||||
{ name: "revenue", value: 30 },
|
||||
],
|
||||
],
|
||||
]);
|
||||
const result = computeResults("exp-1", assignments, conversions);
|
||||
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
|
||||
const revenueMetric = ctrl?.metrics.find((m) => m.name === "revenue");
|
||||
expect(revenueMetric?.count).toBe(2);
|
||||
expect(revenueMetric?.sum).toBe(80);
|
||||
expect(revenueMetric?.avg).toBe(40);
|
||||
});
|
||||
|
||||
it("uses value 1 when event value is undefined", { timeout: 5000 }, () => {
|
||||
const assignments = [makeAssignment("u1", "ctrl", true, true)];
|
||||
const conversions = new Map<string, MetricEvent[]>([
|
||||
["u1", [{ name: "click" }]],
|
||||
]);
|
||||
const result = computeResults("exp-1", assignments, conversions);
|
||||
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
|
||||
const metric = ctrl?.metrics.find((m) => m.name === "click");
|
||||
expect(metric?.sum).toBe(1);
|
||||
expect(metric?.avg).toBe(1);
|
||||
});
|
||||
|
||||
it("conversionRate is 0 when no conversions", { timeout: 5000 }, () => {
|
||||
const assignments = [makeAssignment("u1", "ctrl", true, true)];
|
||||
const result = computeResults("exp-1", assignments, new Map());
|
||||
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
|
||||
expect(ctrl?.conversionRate).toBe(0);
|
||||
expect(ctrl?.conversions).toBe(0);
|
||||
});
|
||||
|
||||
it("handles user with empty conversions array", { timeout: 5000 }, () => {
|
||||
const assignments = [makeAssignment("u1", "ctrl", true, true)];
|
||||
const conversions = new Map<string, MetricEvent[]>([["u1", []]]);
|
||||
const result = computeResults("exp-1", assignments, conversions);
|
||||
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
|
||||
expect(ctrl?.conversions).toBe(0);
|
||||
});
|
||||
|
||||
it("handles multiple variants with multiple users", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const assignments = [
|
||||
makeAssignment("u1", "ctrl", true, true),
|
||||
makeAssignment("u2", "treat", true, false),
|
||||
makeAssignment("u3", "treat", true, false),
|
||||
];
|
||||
const conversions = new Map<string, MetricEvent[]>([
|
||||
["u2", [{ name: "purchase", value: 99 }]],
|
||||
["u3", [{ name: "purchase", value: 149 }]],
|
||||
]);
|
||||
const result = computeResults("exp-1", assignments, conversions);
|
||||
expect(result.totalAssignments).toBe(3);
|
||||
expect(result.totalConversions).toBe(2);
|
||||
const treat = result.variants.find((v) => v.variantId === "treat");
|
||||
expect(treat?.conversionRate).toBeCloseTo(1.0);
|
||||
const purchaseMetric = treat?.metrics.find(
|
||||
(m) => m.name === "purchase"
|
||||
);
|
||||
expect(purchaseMetric?.sum).toBe(248);
|
||||
expect(purchaseMetric?.avg).toBe(124);
|
||||
});
|
||||
|
||||
it("ignores conversions for users not in assignments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const assignments = [makeAssignment("u1", "ctrl", true, true)];
|
||||
const conversions = new Map<string, MetricEvent[]>([
|
||||
["u999", [{ name: "click" }]], // orphan conversion
|
||||
]);
|
||||
const result = computeResults("exp-1", assignments, conversions);
|
||||
expect(result.totalConversions).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores conversions for out-of-experiment users", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const assignments = [makeAssignment("u1", "", false)];
|
||||
const conversions = new Map<string, MetricEvent[]>([
|
||||
["u1", [{ name: "click" }]],
|
||||
]);
|
||||
const result = computeResults("exp-1", assignments, conversions);
|
||||
expect(result.totalConversions).toBe(0);
|
||||
});
|
||||
|
||||
it("avg is 0 when count is 0 (edge-case guard)", { timeout: 5000 }, () => {
|
||||
// This tests the defensive avg calculation when count is unexpectedly 0
|
||||
// We exercise this via a normal path since count always matches events processed
|
||||
const assignments = [makeAssignment("u1", "ctrl", true, true)];
|
||||
const result = computeResults("exp-1", assignments, new Map());
|
||||
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
|
||||
expect(ctrl?.metrics).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
121
packages/testkit/testing/ab-testing/src/results/results.ts
Normal file
121
packages/testkit/testing/ab-testing/src/results/results.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
Assignment,
|
||||
ExperimentResults,
|
||||
MetricEvent,
|
||||
VariantResults,
|
||||
} from "../types/common.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal aggregation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MetricAccumulator {
|
||||
count: number;
|
||||
sum: number;
|
||||
}
|
||||
|
||||
interface VariantAccumulator {
|
||||
assignments: number;
|
||||
conversions: number;
|
||||
metrics: Map<string, MetricAccumulator>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute aggregated experiment results from raw assignment and conversion
|
||||
* data stored in the experiment manager's in-memory state.
|
||||
*
|
||||
* This function is intentionally pure — it takes all required data as
|
||||
* arguments and returns a fully computed {@link ExperimentResults} object
|
||||
* with no side effects.
|
||||
*
|
||||
* @param experimentId - Stable experiment identifier.
|
||||
* @param assignments - All assignments ever recorded for this experiment.
|
||||
* @param conversions - Map from `userId` to the metric events recorded for them.
|
||||
* @returns Aggregated results grouped by variant.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { computeResults } from '@suaveplan/ab-testing/results';
|
||||
*
|
||||
* const results = computeResults(
|
||||
* 'checkout-cta',
|
||||
* assignments,
|
||||
* conversions
|
||||
* );
|
||||
* console.log(results.variants[0]?.conversionRate);
|
||||
* ```
|
||||
*/
|
||||
export function computeResults(
|
||||
experimentId: string,
|
||||
assignments: Assignment[],
|
||||
conversions: Map<string, MetricEvent[]>
|
||||
): ExperimentResults {
|
||||
// Build per-variant accumulators from in-experiment assignments
|
||||
// and immediately accumulate conversion events for each user.
|
||||
const accMap = new Map<string, VariantAccumulator>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
if (!assignment.inExperiment) continue;
|
||||
const { variantId, userId } = assignment;
|
||||
if (!accMap.has(variantId)) {
|
||||
accMap.set(variantId, {
|
||||
assignments: 0,
|
||||
conversions: 0,
|
||||
metrics: new Map(),
|
||||
});
|
||||
}
|
||||
const acc = accMap.get(variantId)!;
|
||||
acc.assignments++;
|
||||
|
||||
// Accumulate this user's conversion events
|
||||
const events = conversions.get(userId);
|
||||
if (!events || events.length === 0) continue;
|
||||
|
||||
acc.conversions++;
|
||||
for (const evt of events) {
|
||||
const metricKey = evt.name;
|
||||
if (!acc.metrics.has(metricKey)) {
|
||||
acc.metrics.set(metricKey, { count: 0, sum: 0 });
|
||||
}
|
||||
const m = acc.metrics.get(metricKey)!;
|
||||
m.count++;
|
||||
m.sum += evt.value ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Build final VariantResults
|
||||
const variants: VariantResults[] = [];
|
||||
let totalAssignments = 0;
|
||||
let totalConversions = 0;
|
||||
|
||||
for (const [variantId, acc] of accMap) {
|
||||
totalAssignments += acc.assignments;
|
||||
totalConversions += acc.conversions;
|
||||
|
||||
const metrics = Array.from(acc.metrics.entries()).map(([name, m]) => ({
|
||||
name,
|
||||
count: m.count,
|
||||
sum: m.sum,
|
||||
avg: m.sum / m.count,
|
||||
}));
|
||||
|
||||
variants.push({
|
||||
variantId,
|
||||
assignments: acc.assignments,
|
||||
conversions: acc.conversions,
|
||||
conversionRate: acc.conversions / acc.assignments,
|
||||
metrics,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
experimentId,
|
||||
variants,
|
||||
totalAssignments,
|
||||
totalConversions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { evaluateTargeting } from "./targeting.js";
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { evaluateTargeting } from "./targeting.js";
|
||||
|
||||
describe("evaluateTargeting", () => {
|
||||
it("returns true for empty rules", { timeout: 5000 }, () => {
|
||||
expect(evaluateTargeting([], {})).toBe(true);
|
||||
});
|
||||
|
||||
describe("eq operator", () => {
|
||||
it("matches equal string", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "plan", operator: "eq", value: "premium" }],
|
||||
{ plan: "premium" }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails on different value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "plan", operator: "eq", value: "premium" }],
|
||||
{ plan: "free" }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails on absent attribute", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "plan", operator: "eq", value: "premium" }],
|
||||
{}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches boolean", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "verified", operator: "eq", value: true }],
|
||||
{ verified: true }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches number", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "eq", value: 30 }],
|
||||
{ age: 30 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("neq operator", () => {
|
||||
it("matches when values differ", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "plan", operator: "neq", value: "banned" }],
|
||||
{ plan: "premium" }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when values are equal", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "plan", operator: "neq", value: "banned" }],
|
||||
{ plan: "banned" }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes when attribute is absent (undefined !== value)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "plan", operator: "neq", value: "banned" }],
|
||||
{}
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("in operator", () => {
|
||||
it("matches when value is in array", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[
|
||||
{
|
||||
attribute: "country",
|
||||
operator: "in",
|
||||
value: ["US", "CA"],
|
||||
},
|
||||
],
|
||||
{ country: "US" }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when value is not in array", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[
|
||||
{
|
||||
attribute: "country",
|
||||
operator: "in",
|
||||
value: ["US", "CA"],
|
||||
},
|
||||
],
|
||||
{ country: "GB" }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when value is absent", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "country", operator: "in", value: ["US"] }],
|
||||
{}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when rhs is not an array", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "country", operator: "in", value: "US" }],
|
||||
{ country: "US" }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notIn operator", () => {
|
||||
it("passes when value is not in array", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[
|
||||
{
|
||||
attribute: "plan",
|
||||
operator: "notIn",
|
||||
value: ["banned", "suspended"],
|
||||
},
|
||||
],
|
||||
{ plan: "premium" }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when value is in array", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[
|
||||
{
|
||||
attribute: "plan",
|
||||
operator: "notIn",
|
||||
value: ["banned"],
|
||||
},
|
||||
],
|
||||
{ plan: "banned" }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when rhs is not an array", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "plan", operator: "notIn", value: "banned" }],
|
||||
{ plan: "premium" }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gt operator", () => {
|
||||
it("passes when attribute > value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "gt", value: 18 }],
|
||||
{ age: 25 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when attribute === value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "gt", value: 18 }],
|
||||
{ age: 18 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when attribute < value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "gt", value: 18 }],
|
||||
{ age: 10 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when attribute is string", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "gt", value: 18 }],
|
||||
{ age: "25" as unknown as number }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when rhs is not number", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "gt", value: "18" }],
|
||||
{ age: 25 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lt operator", () => {
|
||||
it("passes when attribute < value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "lt", value: 65 }],
|
||||
{ age: 30 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when attribute >= value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "lt", value: 65 }],
|
||||
{ age: 65 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when attribute is not number", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "lt", value: 65 }],
|
||||
{ age: "young" as unknown as number }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails when rhs is not number", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "age", operator: "lt", value: "65" }],
|
||||
{ age: 30 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gte operator", () => {
|
||||
it("passes when attribute >= value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "score", operator: "gte", value: 100 }],
|
||||
{ score: 100 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when attribute < value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "score", operator: "gte", value: 100 }],
|
||||
{ score: 99 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lte operator", () => {
|
||||
it("passes when attribute <= value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "score", operator: "lte", value: 100 }],
|
||||
{ score: 100 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when attribute > value", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[{ attribute: "score", operator: "lte", value: 100 }],
|
||||
{ score: 101 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AND logic across multiple rules", () => {
|
||||
it("all pass → returns true", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[
|
||||
{ attribute: "plan", operator: "eq", value: "premium" },
|
||||
{
|
||||
attribute: "country",
|
||||
operator: "in",
|
||||
value: ["US", "CA"],
|
||||
},
|
||||
{ attribute: "age", operator: "gte", value: 18 },
|
||||
],
|
||||
{ plan: "premium", country: "CA", age: 25 }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("one fails → returns false", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[
|
||||
{ attribute: "plan", operator: "eq", value: "premium" },
|
||||
{
|
||||
attribute: "country",
|
||||
operator: "in",
|
||||
value: ["US", "CA"],
|
||||
},
|
||||
],
|
||||
{ plan: "premium", country: "GB" }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("short-circuits on first failure", { timeout: 5000 }, () => {
|
||||
// Both rules would fail, but function should return false on first
|
||||
expect(
|
||||
evaluateTargeting(
|
||||
[
|
||||
{ attribute: "plan", operator: "eq", value: "premium" },
|
||||
{ attribute: "age", operator: "gt", value: 100 },
|
||||
],
|
||||
{ plan: "free", age: 5 }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
103
packages/testkit/testing/ab-testing/src/targeting/targeting.ts
Normal file
103
packages/testkit/testing/ab-testing/src/targeting/targeting.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { TargetingRule, UserAttributes } from "../types/common.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compare two scalar values using the given ordering operator.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
function compareNumeric(
|
||||
lhs: number,
|
||||
rhs: unknown,
|
||||
op: "gt" | "lt" | "gte" | "lte"
|
||||
): boolean {
|
||||
if (typeof rhs !== "number") return false;
|
||||
switch (op) {
|
||||
case "gt":
|
||||
return lhs > rhs;
|
||||
case "lt":
|
||||
return lhs < rhs;
|
||||
case "gte":
|
||||
return lhs >= rhs;
|
||||
case "lte":
|
||||
return lhs <= rhs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single {@link TargetingRule} against the provided `attributes`.
|
||||
*
|
||||
* Returns `true` when the rule is satisfied, `false` otherwise (including
|
||||
* when the attribute is absent or the value types are incompatible).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
function evaluateRule(
|
||||
rule: TargetingRule,
|
||||
attributes: UserAttributes
|
||||
): boolean {
|
||||
const actual = attributes[rule.attribute];
|
||||
|
||||
switch (rule.operator) {
|
||||
case "eq":
|
||||
return actual === rule.value;
|
||||
case "neq":
|
||||
return actual !== rule.value;
|
||||
case "in": {
|
||||
if (!Array.isArray(rule.value)) return false;
|
||||
return (rule.value as unknown[]).includes(actual);
|
||||
}
|
||||
case "notIn": {
|
||||
if (!Array.isArray(rule.value)) return false;
|
||||
return !(rule.value as unknown[]).includes(actual);
|
||||
}
|
||||
case "gt":
|
||||
case "lt":
|
||||
case "gte":
|
||||
case "lte": {
|
||||
if (typeof actual !== "number") return false;
|
||||
return compareNumeric(actual, rule.value, rule.operator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate an ordered list of targeting rules against a set of user
|
||||
* attributes using AND logic — every rule must pass for the function
|
||||
* to return `true`.
|
||||
*
|
||||
* An empty `rules` array is treated as "no restriction" and always returns
|
||||
* `true`, making the function safe to call unconditionally in the assign
|
||||
* path even when `targeting` is absent on the experiment definition.
|
||||
*
|
||||
* @param rules - Targeting rules to evaluate.
|
||||
* @param attributes - User attributes to evaluate against.
|
||||
* @returns `true` when all rules pass (or when `rules` is empty).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { evaluateTargeting } from '@suaveplan/ab-testing/targeting';
|
||||
*
|
||||
* const eligible = evaluateTargeting(
|
||||
* [{ attribute: 'plan', operator: 'eq', value: 'premium' }],
|
||||
* { plan: 'premium', country: 'US' }
|
||||
* );
|
||||
* // eligible === true
|
||||
* ```
|
||||
*/
|
||||
export function evaluateTargeting(
|
||||
rules: ReadonlyArray<TargetingRule>,
|
||||
attributes: UserAttributes
|
||||
): boolean {
|
||||
for (const rule of rules) {
|
||||
if (!evaluateRule(rule, attributes)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
151
packages/testkit/testing/ab-testing/src/types/common.md
Normal file
151
packages/testkit/testing/ab-testing/src/types/common.md
Normal 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.
|
||||
199
packages/testkit/testing/ab-testing/src/types/common.ts
Normal file
199
packages/testkit/testing/ab-testing/src/types/common.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { Clock, EventBusLike, LoggerLike, Rng } from "@suaveplan/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core domain types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A single treatment group within an experiment.
|
||||
*
|
||||
* The `weight` field is a relative number — weights are normalised before
|
||||
* bucketing so they do not need to sum to any particular value.
|
||||
*/
|
||||
export interface Variant {
|
||||
/** Stable identifier used in assignments, analytics, and results. */
|
||||
readonly id: string;
|
||||
/** Human-readable display name. */
|
||||
readonly name: string;
|
||||
/** Relative traffic weight. Variants with weight ≤ 0 are skipped. */
|
||||
readonly weight: number;
|
||||
/** Mark this variant as the baseline control for statistical analysis. */
|
||||
readonly isControl?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single targeting predicate that gates experiment eligibility.
|
||||
*
|
||||
* All rules in a list are evaluated with AND logic — a user must satisfy
|
||||
* every rule to be considered eligible.
|
||||
*/
|
||||
export interface TargetingRule {
|
||||
/** Attribute key on the {@link UserAttributes} map. */
|
||||
readonly attribute: string;
|
||||
/** Comparison operator. */
|
||||
readonly operator:
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "in"
|
||||
| "notIn"
|
||||
| "gt"
|
||||
| "lt"
|
||||
| "gte"
|
||||
| "lte";
|
||||
/** Right-hand side value for the comparison. */
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full experiment definition as stored in the manager.
|
||||
*/
|
||||
export interface ExperimentDefinition {
|
||||
/** Stable unique identifier. */
|
||||
readonly id: string;
|
||||
/** Human-readable display name. */
|
||||
readonly name: string;
|
||||
/** Lifecycle status. Only `'running'` experiments accept new assignments. */
|
||||
readonly status: "draft" | "running" | "paused" | "concluded";
|
||||
/** Ordered list of treatment groups. */
|
||||
readonly variants: ReadonlyArray<Variant>;
|
||||
/** Optional targeting rules — all must pass for a user to be included. */
|
||||
readonly targeting?: TargetingRule[];
|
||||
/** ISO-8601 start date (informational, not enforced at runtime). */
|
||||
readonly startDate?: string;
|
||||
/** ISO-8601 end date (informational, not enforced at runtime). */
|
||||
readonly endDate?: string;
|
||||
/** Fraction of eligible users to include, `[0, 1]`. Defaults to `1.0`. */
|
||||
readonly sampleRate?: number;
|
||||
/** Primary conversion metric name for results aggregation. */
|
||||
readonly primaryMetric?: string;
|
||||
/** Additional metric names to aggregate in results. */
|
||||
readonly secondaryMetrics?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The assignment record produced for a single (experiment, user) pair.
|
||||
*/
|
||||
export interface Assignment {
|
||||
readonly experimentId: string;
|
||||
readonly userId: string;
|
||||
/** Empty string when `inExperiment` is `false`. */
|
||||
readonly variantId: string;
|
||||
/** `false` when `inExperiment` is `false`. */
|
||||
readonly isControl: boolean;
|
||||
/** Unix epoch milliseconds at time of assignment. */
|
||||
readonly timestamp: number;
|
||||
/** `false` when the user was excluded by targeting or sample rate. */
|
||||
readonly inExperiment: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single metric event tracked against an experiment.
|
||||
*/
|
||||
export interface MetricEvent {
|
||||
/** Metric name — should match `primaryMetric` or `secondaryMetrics`. */
|
||||
readonly name: string;
|
||||
/** Numeric value to aggregate. Defaults to `1` when omitted. */
|
||||
readonly value?: number;
|
||||
/** Unix epoch milliseconds. Defaults to `Date.now()` when omitted. */
|
||||
readonly timestamp?: number;
|
||||
/** Arbitrary key/value properties for downstream analytics. */
|
||||
readonly properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-variant aggregated results.
|
||||
*/
|
||||
export interface VariantResults {
|
||||
readonly variantId: string;
|
||||
readonly assignments: number;
|
||||
readonly conversions: number;
|
||||
readonly conversionRate: number;
|
||||
readonly metrics: ReadonlyArray<{
|
||||
name: string;
|
||||
count: number;
|
||||
sum: number;
|
||||
avg: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated results for a complete experiment.
|
||||
*/
|
||||
export interface ExperimentResults {
|
||||
readonly experimentId: string;
|
||||
readonly variants: ReadonlyArray<VariantResults>;
|
||||
readonly totalAssignments: number;
|
||||
readonly totalConversions: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manager API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Free-form user attribute map used for targeting evaluation. */
|
||||
export type UserAttributes = Record<string, string | number | boolean>;
|
||||
|
||||
/**
|
||||
* Construction options for the experiment manager.
|
||||
*/
|
||||
export interface ExperimentManagerOptions {
|
||||
/** Structured logger for lifecycle and error events. */
|
||||
readonly logger?: LoggerLike;
|
||||
/**
|
||||
* Injectable clock for deterministic timestamps in tests.
|
||||
* A `Clock` is a plain `() => number` function.
|
||||
*/
|
||||
readonly clock?: Clock;
|
||||
/** Injectable RNG for deterministic sample-rate decisions in tests. */
|
||||
readonly rng?: Rng;
|
||||
/** Event bus for publishing assignment and conversion events. */
|
||||
readonly eventBus?: EventBusLike;
|
||||
/** Seed offset XOR'd into the FNV-1a basis. */
|
||||
readonly hashSeed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pluggable provider that can override assignment logic or forward events
|
||||
* to an external A/B testing platform.
|
||||
*/
|
||||
export interface ExperimentProvider {
|
||||
/** Unique provider name for debugging. */
|
||||
readonly name: string;
|
||||
/** Return a forced variant ID or `null` to fall through to bucketing. */
|
||||
getAssignment?(experimentId: string, userId: string): string | null;
|
||||
/** Called after every successful assignment. */
|
||||
trackAssignment?(assignment: Assignment): void;
|
||||
/** Called after every conversion event. */
|
||||
trackConversion?(
|
||||
experimentId: string,
|
||||
userId: string,
|
||||
metric: MetricEvent
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The public `ExperimentManager` interface.
|
||||
*/
|
||||
export interface ExperimentManager {
|
||||
/** Register or replace an experiment definition. */
|
||||
defineExperiment(experiment: ExperimentDefinition): void;
|
||||
/** Retrieve a definition by id, or `null` if not found. */
|
||||
getExperiment(id: string): ExperimentDefinition | null;
|
||||
/** List all registered definitions. */
|
||||
listExperiments(): ReadonlyArray<ExperimentDefinition>;
|
||||
/**
|
||||
* Assign a user to a variant. Throws {@link AbTestingError} with
|
||||
* `AB_EXPERIMENT_NOT_FOUND` or `AB_EXPERIMENT_INACTIVE` on error.
|
||||
*/
|
||||
assign(
|
||||
experimentId: string,
|
||||
userId: string,
|
||||
attributes?: UserAttributes
|
||||
): Assignment;
|
||||
/** Record a metric conversion event for an (experiment, user) pair. */
|
||||
track(experimentId: string, userId: string, metric: MetricEvent): void;
|
||||
/** Return aggregated results for an experiment. */
|
||||
getResults(experimentId: string): ExperimentResults;
|
||||
/** Register an external provider; returns `this` for chaining. */
|
||||
use(provider: ExperimentProvider): this;
|
||||
}
|
||||
107
packages/testkit/testing/ab-testing/src/types/schemas.md
Normal file
107
packages/testkit/testing/ab-testing/src/types/schemas.md
Normal 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`).
|
||||
61
packages/testkit/testing/ab-testing/src/types/schemas.ts
Normal file
61
packages/testkit/testing/ab-testing/src/types/schemas.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import z from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VariantSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
weight: z.number(),
|
||||
isControl: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TargetingRule schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TargetingRuleSchema = z.object({
|
||||
attribute: z.string().min(1),
|
||||
operator: z.enum(["eq", "neq", "in", "notIn", "gt", "lt", "gte", "lte"]),
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExperimentDefinition schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ExperimentDefinitionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
status: z.enum(["draft", "running", "paused", "concluded"]),
|
||||
variants: z.array(VariantSchema).min(1),
|
||||
targeting: z.array(TargetingRuleSchema).optional(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
sampleRate: z.number().min(0).max(1).optional(),
|
||||
primaryMetric: z.string().optional(),
|
||||
secondaryMetrics: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MetricEvent schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MetricEventSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.number().optional(),
|
||||
timestamp: z.number().optional(),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inferred types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type VariantInput = z.infer<typeof VariantSchema>;
|
||||
export type TargetingRuleInput = z.infer<typeof TargetingRuleSchema>;
|
||||
export type ExperimentDefinitionInput = z.infer<
|
||||
typeof ExperimentDefinitionSchema
|
||||
>;
|
||||
export type MetricEventInput = z.infer<typeof MetricEventSchema>;
|
||||
168
packages/testkit/testing/ab-testing/tests/integration.test.ts
Normal file
168
packages/testkit/testing/ab-testing/tests/integration.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createExperimentManager } from "../src/core/experiment-manager/experiment-manager.js";
|
||||
import {
|
||||
ForceVariantProvider,
|
||||
LoggingProvider,
|
||||
} from "../src/providers/provider.js";
|
||||
import type { ExperimentDefinition } from "../src/types/common.js";
|
||||
|
||||
const runningExp = (
|
||||
id: string,
|
||||
overrides?: Partial<ExperimentDefinition>
|
||||
): ExperimentDefinition => ({
|
||||
id,
|
||||
name: `Experiment ${id}`,
|
||||
status: "running",
|
||||
variants: [
|
||||
{ id: "ctrl", name: "Control", weight: 1, isControl: true },
|
||||
{ id: "treat", name: "Treatment", weight: 1 },
|
||||
],
|
||||
primaryMetric: "conversion",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("end-to-end flow", () => {
|
||||
it("full happy path: define → assign → track → results", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const manager = createExperimentManager();
|
||||
manager.defineExperiment(runningExp("e2e"));
|
||||
|
||||
// Assign 10 users
|
||||
const assignments = Array.from({ length: 10 }, (_, i) =>
|
||||
manager.assign("e2e", `user-${i}`)
|
||||
);
|
||||
expect(assignments).toHaveLength(10);
|
||||
expect(assignments.every((a) => a.inExperiment)).toBe(true);
|
||||
|
||||
// Track conversions for first 5 users
|
||||
for (let i = 0; i < 5; i++) {
|
||||
manager.track("e2e", `user-${i}`, {
|
||||
name: "conversion",
|
||||
value: 10,
|
||||
});
|
||||
}
|
||||
|
||||
const results = manager.getResults("e2e");
|
||||
expect(results.totalAssignments).toBe(10);
|
||||
expect(results.totalConversions).toBe(5);
|
||||
expect(results.variants.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("targeting excludes ineligible users", { timeout: 5000 }, () => {
|
||||
const manager = createExperimentManager();
|
||||
manager.defineExperiment(
|
||||
runningExp("targeted", {
|
||||
targeting: [
|
||||
{ attribute: "plan", operator: "eq", value: "premium" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const included = manager.assign("targeted", "premium-user", {
|
||||
plan: "premium",
|
||||
});
|
||||
const excluded = manager.assign("targeted", "free-user", {
|
||||
plan: "free",
|
||||
});
|
||||
|
||||
expect(included.inExperiment).toBe(true);
|
||||
expect(excluded.inExperiment).toBe(false);
|
||||
expect(excluded.variantId).toBe("");
|
||||
});
|
||||
|
||||
it("sample rate excludes portion of users", { timeout: 5000 }, () => {
|
||||
// Use rng that always returns 0 → always included
|
||||
const alwaysIn = createExperimentManager({
|
||||
rng: { nextFloat: () => 0, nextUint32: () => 0 },
|
||||
});
|
||||
alwaysIn.defineExperiment(runningExp("sample", { sampleRate: 0.5 }));
|
||||
const a1 = alwaysIn.assign("sample", "u1");
|
||||
expect(a1.inExperiment).toBe(true);
|
||||
|
||||
// Use rng that always returns 1 → always excluded
|
||||
const alwaysOut = createExperimentManager({
|
||||
rng: { nextFloat: () => 1, nextUint32: () => 0xffffffff },
|
||||
});
|
||||
alwaysOut.defineExperiment(runningExp("sample", { sampleRate: 0.5 }));
|
||||
const a2 = alwaysOut.assign("sample", "u1");
|
||||
expect(a2.inExperiment).toBe(false);
|
||||
});
|
||||
|
||||
it("ForceVariantProvider overrides bucketing", { timeout: 5000 }, () => {
|
||||
const manager = createExperimentManager();
|
||||
manager.defineExperiment(runningExp("force-test"));
|
||||
manager.use(
|
||||
new ForceVariantProvider({ "force-test": { "qa-user": "treat" } })
|
||||
);
|
||||
|
||||
const a = manager.assign("force-test", "qa-user");
|
||||
expect(a.variantId).toBe("treat");
|
||||
expect(a.isControl).toBe(false);
|
||||
});
|
||||
|
||||
it("LoggingProvider receives assignment events", { timeout: 5000 }, () => {
|
||||
const received: string[] = [];
|
||||
const manager = createExperimentManager();
|
||||
manager.defineExperiment(runningExp("logging-test"));
|
||||
manager.use(
|
||||
new LoggingProvider({
|
||||
onAssignment: (a) => {
|
||||
received.push(a.variantId);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
manager.assign("logging-test", "u1");
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("LoggingProvider receives conversion events", { timeout: 5000 }, () => {
|
||||
const received: string[] = [];
|
||||
const manager = createExperimentManager();
|
||||
manager.defineExperiment(runningExp("conv-test"));
|
||||
manager.use(
|
||||
new LoggingProvider({
|
||||
onConversion: (expId) => {
|
||||
received.push(expId);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
manager.track("conv-test", "u1", { name: "click" });
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toBe("conv-test");
|
||||
});
|
||||
|
||||
it("multiple experiments run independently", { timeout: 5000 }, () => {
|
||||
const manager = createExperimentManager();
|
||||
manager.defineExperiment(runningExp("exp-a"));
|
||||
manager.defineExperiment(runningExp("exp-b"));
|
||||
|
||||
const aA = manager.assign("exp-a", "user-shared");
|
||||
const aB = manager.assign("exp-b", "user-shared");
|
||||
|
||||
// Same user can be in different variants in different experiments
|
||||
expect(aA.experimentId).toBe("exp-a");
|
||||
expect(aB.experimentId).toBe("exp-b");
|
||||
});
|
||||
|
||||
it("hashSeed changes variant assignment", { timeout: 5000 }, () => {
|
||||
const m1 = createExperimentManager({ hashSeed: 0 });
|
||||
const m2 = createExperimentManager({ hashSeed: 999999 });
|
||||
|
||||
m1.defineExperiment(runningExp("seed-test"));
|
||||
m2.defineExperiment(runningExp("seed-test"));
|
||||
|
||||
// Run many users and verify the distributions differ (not just one user)
|
||||
const results1 = new Set<string>();
|
||||
const results2 = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
results1.add(m1.assign("seed-test", `s-${i}`).variantId);
|
||||
results2.add(m2.assign("seed-test", `s-${i}`).variantId);
|
||||
}
|
||||
// Both should have multiple variants
|
||||
expect(results1.size).toBeGreaterThan(0);
|
||||
expect(results2.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
125
packages/testkit/testing/ab-testing/tests/property.test.ts
Normal file
125
packages/testkit/testing/ab-testing/tests/property.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
bucketUser,
|
||||
normaliseWeights,
|
||||
selectVariant,
|
||||
} from "../src/bucketing/bucketing.js";
|
||||
import { evaluateTargeting } from "../src/targeting/targeting.js";
|
||||
|
||||
describe("property: bucketUser", () => {
|
||||
it("always returns [0,1) for arbitrary inputs", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string(),
|
||||
fc.string(),
|
||||
fc.integer({ min: -1_000_000, max: 1_000_000 }),
|
||||
(userId, experimentId, seed) => {
|
||||
const b = bucketUser(userId, experimentId, seed);
|
||||
return b >= 0 && b < 1;
|
||||
}
|
||||
),
|
||||
{ numRuns: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it("is deterministic for the same inputs", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), fc.string(), (userId, experimentId) => {
|
||||
const a = bucketUser(userId, experimentId);
|
||||
const b = bucketUser(userId, experimentId);
|
||||
return a === b;
|
||||
}),
|
||||
{ numRuns: 1000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property: normaliseWeights + selectVariant", () => {
|
||||
const positiveVariantArb = fc.record({
|
||||
id: fc.string({ minLength: 1 }),
|
||||
name: fc.string({ minLength: 1 }),
|
||||
weight: fc.float({
|
||||
min: Math.fround(0.001),
|
||||
max: Math.fround(1000),
|
||||
noNaN: true,
|
||||
}),
|
||||
});
|
||||
|
||||
it("selected variant always belongs to input", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(positiveVariantArb, { minLength: 1, maxLength: 10 }),
|
||||
fc.float({
|
||||
min: Math.fround(0),
|
||||
max: Math.fround(0.9999),
|
||||
noNaN: true,
|
||||
}),
|
||||
(rawVariants, bucket) => {
|
||||
const normalised = normaliseWeights(rawVariants);
|
||||
if (normalised.length === 0) return true; // skip degenerate
|
||||
const selected = selectVariant(normalised, bucket);
|
||||
return rawVariants.some((v) => v.id === selected.id);
|
||||
}
|
||||
),
|
||||
{ numRuns: 500 }
|
||||
);
|
||||
});
|
||||
|
||||
it("last normalised weight is always exactly 1.0", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(positiveVariantArb, { minLength: 1, maxLength: 20 }),
|
||||
(rawVariants) => {
|
||||
const normalised = normaliseWeights(rawVariants);
|
||||
if (normalised.length === 0) return true;
|
||||
return (
|
||||
normalised[normalised.length - 1]?.normalizedWeight ===
|
||||
1.0
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 500 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property: evaluateTargeting", () => {
|
||||
it("empty rules always returns true", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.dictionary(
|
||||
fc.string(),
|
||||
fc.oneof(fc.string(), fc.integer(), fc.boolean())
|
||||
),
|
||||
(attrs) => {
|
||||
return evaluateTargeting([], attrs) === true;
|
||||
}
|
||||
),
|
||||
{ numRuns: 500 }
|
||||
);
|
||||
});
|
||||
|
||||
it("eq rule is reflexive for present attributes", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1 }),
|
||||
fc.oneof(fc.string(), fc.integer()),
|
||||
(key, value) => {
|
||||
const attrs = { [key]: value };
|
||||
const rule = {
|
||||
attribute: key,
|
||||
operator: "eq" as const,
|
||||
value,
|
||||
};
|
||||
return evaluateTargeting([rule], attrs) === true;
|
||||
}
|
||||
),
|
||||
{ numRuns: 500 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
12
packages/testkit/testing/ab-testing/tsconfig.lib.json
Normal file
12
packages/testkit/testing/ab-testing/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
40
packages/testkit/testing/ab-testing/vite.config.ts
Normal file
40
packages/testkit/testing/ab-testing/vite.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/ab-testing",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"@suaveplan/events",
|
||||
"@suaveplan/logger",
|
||||
"@suaveplan/feature-flags",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
38
packages/testkit/testing/ab-testing/vitest.config.ts
Normal file
38
packages/testkit/testing/ab-testing/vitest.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/constants/**",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
154
packages/testkit/testing/testing-dom/README.md
Normal file
154
packages/testkit/testing/testing-dom/README.md
Normal 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 |
|
||||
|
||||
117
packages/testkit/testing/testing-dom/package.json
Normal file
117
packages/testkit/testing/testing-dom/package.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"name": "@suaveplan/testing-dom",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./dist/setup/polyfills.js",
|
||||
"./dist/matchers/matchers.js"
|
||||
],
|
||||
"description": "DOM/React testing — vitest+bun-test conditional re-exports, createTestConfig, polyfills, render helpers, accessibility-first queries, jest-dom matchers, user-event v14",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"vitest": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"bun": {
|
||||
"types": "./dist/runner/bun-test.d.ts",
|
||||
"default": "./dist/runner/bun-test.js"
|
||||
},
|
||||
"default": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config/index.d.ts",
|
||||
"default": "./dist/config/index.js"
|
||||
},
|
||||
"./polyfills": "./dist/setup/polyfills.js",
|
||||
"./matchers": "./dist/matchers/matchers.js",
|
||||
"./errors": {
|
||||
"types": "./dist/errors/index.d.ts",
|
||||
"default": "./dist/errors/index.js"
|
||||
},
|
||||
"./render": {
|
||||
"types": "./dist/render/index.d.ts",
|
||||
"default": "./dist/render/index.js"
|
||||
},
|
||||
"./queries": {
|
||||
"types": "./dist/queries/queries.d.ts",
|
||||
"default": "./dist/queries/queries.js"
|
||||
},
|
||||
"./user": {
|
||||
"types": "./dist/user/setup-user.d.ts",
|
||||
"default": "./dist/user/setup-user.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@testing-library/dom": "catalog:testing",
|
||||
"@testing-library/jest-dom": "catalog:testing",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@testing-library/user-event": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"tslib": "catalog:build",
|
||||
"vitest": "catalog:testing",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitejs/plugin-react": "*",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitejs/plugin-react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Build a bun-test config from caller intent. Bun's runner accepts a
|
||||
* narrower surface than vitest; this shape captures what's currently
|
||||
* configurable, plus a `warnings` channel so calls to vitest-only
|
||||
* features can surface a notice.
|
||||
*/
|
||||
|
||||
import type { TestEnvironment } from "./detect-environment.js";
|
||||
|
||||
export interface BunTestConfig {
|
||||
readonly callerDir: string;
|
||||
readonly environment: TestEnvironment;
|
||||
readonly testTimeoutMs: number;
|
||||
readonly coverageEnabled: boolean;
|
||||
readonly preload: readonly string[];
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export interface BunTestConfigOverrides {
|
||||
readonly environment?: TestEnvironment;
|
||||
readonly testTimeout?: number;
|
||||
readonly coverage?: boolean | Record<string, unknown>;
|
||||
readonly preload?: readonly string[];
|
||||
}
|
||||
|
||||
export function createBunTestConfig(
|
||||
callerDir: string,
|
||||
overrides: BunTestConfigOverrides
|
||||
): BunTestConfig {
|
||||
const warnings: string[] = [];
|
||||
const environment = overrides.environment ?? "node";
|
||||
if (environment === "happy-dom" || environment === "jsdom") {
|
||||
warnings.push(
|
||||
`bun:test does not implement DOM environments natively (got "${environment}"); consider switching to vitest for browser-like tests`
|
||||
);
|
||||
}
|
||||
return {
|
||||
callerDir,
|
||||
environment,
|
||||
testTimeoutMs: overrides.testTimeout ?? 5000,
|
||||
coverageEnabled: overrides.coverage === true,
|
||||
preload: overrides.preload ?? ["@suaveplan/testing-dom/polyfills"],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
125
packages/testkit/testing/testing-dom/src/config/config.md
Normal file
125
packages/testkit/testing/testing-dom/src/config/config.md
Normal 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.
|
||||
171
packages/testkit/testing/testing-dom/src/config/config.test.ts
Normal file
171
packages/testkit/testing/testing-dom/src/config/config.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { TESTING_DOM_ERROR_CODES } from "../errors/codes.js";
|
||||
import {
|
||||
type ConfigError,
|
||||
TestingDomError,
|
||||
} from "../errors/testing-dom-error.js";
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
import { createBunTestConfig } from "./bun-test-config.js";
|
||||
import { detectEnvironment } from "./detect-environment.js";
|
||||
import { detectRunner } from "./detect-runner.js";
|
||||
import { createTestConfig } from "./index.js";
|
||||
import { createVitestConfig } from "./vitest-config.js";
|
||||
|
||||
describe("detectRunner", () => {
|
||||
it("uses explicit override", { timeout: 5000 }, () => {
|
||||
expect(detectRunner("bun:test", {})).toBe("bun:test");
|
||||
expect(detectRunner("vitest", {})).toBe("vitest");
|
||||
});
|
||||
|
||||
it("detects bun:test from BUN_TEST env", { timeout: 5000 }, () => {
|
||||
const original = process.env.BUN_TEST;
|
||||
process.env.BUN_TEST = "1";
|
||||
try {
|
||||
expect(detectRunner(undefined, {})).toBe("bun:test");
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.BUN_TEST;
|
||||
} else {
|
||||
process.env.BUN_TEST = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("detects bun:test from package scripts", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
detectRunner(undefined, {
|
||||
scripts: { test: "bun test --timeout 5000" },
|
||||
})
|
||||
).toBe("bun:test");
|
||||
});
|
||||
|
||||
it("defaults to vitest when nothing detected", { timeout: 5000 }, () => {
|
||||
expect(detectRunner(undefined, {})).toBe("vitest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectEnvironment", () => {
|
||||
it("uses explicit override", { timeout: 5000 }, () => {
|
||||
expect(detectEnvironment({}, "jsdom")).toBe("jsdom");
|
||||
});
|
||||
|
||||
it("returns happy-dom for react deps", { timeout: 5000 }, () => {
|
||||
expect(detectEnvironment({ dependencies: { react: "^19.0.0" } })).toBe(
|
||||
"happy-dom"
|
||||
);
|
||||
expect(
|
||||
detectEnvironment({
|
||||
peerDependencies: { "react-dom": "^19.0.0" },
|
||||
})
|
||||
).toBe("happy-dom");
|
||||
});
|
||||
|
||||
it("returns jsdom when jsdom is a dep", { timeout: 5000 }, () => {
|
||||
expect(detectEnvironment({ devDependencies: { jsdom: "^27" } })).toBe(
|
||||
"jsdom"
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to node", { timeout: 5000 }, () => {
|
||||
expect(detectEnvironment({})).toBe("node");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVitestConfig", () => {
|
||||
it("forces pool=forks and includes polyfill setupFile", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", { environment: "node" });
|
||||
expect(cfg.test.pool).toBe("forks");
|
||||
expect(cfg.test.setupFiles).toContain(
|
||||
"@suaveplan/testing-dom/polyfills"
|
||||
);
|
||||
});
|
||||
|
||||
it("disables fileParallelism for happy-dom by default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", {
|
||||
environment: "happy-dom",
|
||||
});
|
||||
expect(cfg.test.fileParallelism).toBe(false);
|
||||
});
|
||||
|
||||
it("enables fileParallelism for node", { timeout: 5000 }, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", { environment: "node" });
|
||||
expect(cfg.test.fileParallelism).toBe(true);
|
||||
});
|
||||
|
||||
it("coverage:true enables istanbul defaults", { timeout: 5000 }, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", { coverage: true });
|
||||
const cov = cfg.test.coverage as Record<string, unknown>;
|
||||
expect(cov.provider).toBe("istanbul");
|
||||
expect((cov.thresholds as Record<string, number>).statements).toBe(95);
|
||||
});
|
||||
|
||||
it("coverage object is passed through as-is", { timeout: 5000 }, () => {
|
||||
const custom: Record<string, unknown> = {
|
||||
provider: "v8",
|
||||
reporter: ["text"],
|
||||
};
|
||||
const cfg = createVitestConfig("/tmp/x", { coverage: custom });
|
||||
expect(cfg.test.coverage).toBe(custom);
|
||||
});
|
||||
|
||||
it("setupFiles overrides extend the polyfills", { timeout: 5000 }, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", {
|
||||
setupFiles: ["./extra.ts"],
|
||||
});
|
||||
expect(cfg.test.setupFiles).toEqual([
|
||||
"@suaveplan/testing-dom/polyfills",
|
||||
"./extra.ts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBunTestConfig", () => {
|
||||
it("returns config with default preload", { timeout: 5000 }, () => {
|
||||
const cfg = createBunTestConfig("/tmp/x", {});
|
||||
expect(cfg.preload).toContain("@suaveplan/testing-dom/polyfills");
|
||||
expect(cfg.coverageEnabled).toBe(false);
|
||||
expect(cfg.testTimeoutMs).toBe(5000);
|
||||
});
|
||||
|
||||
it("warns when happy-dom is requested", { timeout: 5000 }, () => {
|
||||
const cfg = createBunTestConfig("/tmp/x", {
|
||||
environment: "happy-dom",
|
||||
});
|
||||
expect(cfg.warnings.length).toBeGreaterThan(0);
|
||||
expect(cfg.warnings[0]).toContain("bun:test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTestConfig", () => {
|
||||
it("returns a vitest config by default", { timeout: 5000 }, async () => {
|
||||
const cfg = await createTestConfig();
|
||||
expect(cfg).toBeDefined();
|
||||
// Vitest defineConfig returns an object with `test`.
|
||||
expect((cfg as { test?: unknown }).test).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns a BunTestConfig when runner=bun:test", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cfg = await createTestConfig({ runner: "bun:test" });
|
||||
expect((cfg as { preload?: unknown }).preload).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws CONFIG_UNKNOWN_RUNNER for an unknown runner", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
try {
|
||||
await createTestConfig({
|
||||
runner: "nope" as never,
|
||||
});
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TestingDomError);
|
||||
expect((err as ConfigError).code).toBe(
|
||||
TESTING_DOM_ERROR_CODES.CONFIG_UNKNOWN_RUNNER
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Detect the appropriate vitest `environment` for a package.
|
||||
*
|
||||
* Order of precedence:
|
||||
* 1. Explicit override
|
||||
* 2. Package depends on `react` / `react-dom` → `happy-dom`
|
||||
* 3. Package depends on `jsdom` → `jsdom`
|
||||
* 4. Default: `node`
|
||||
*/
|
||||
|
||||
export type TestEnvironment = "node" | "happy-dom" | "jsdom";
|
||||
|
||||
interface MinimalPackageJson {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function detectEnvironment(
|
||||
pkg: MinimalPackageJson,
|
||||
override?: TestEnvironment
|
||||
): TestEnvironment {
|
||||
if (override) return override;
|
||||
const deps = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
...(pkg.peerDependencies ?? {}),
|
||||
};
|
||||
if ("react" in deps || "react-dom" in deps) return "happy-dom";
|
||||
if ("jsdom" in deps) return "jsdom";
|
||||
return "node";
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Detect which test runner a consumer is using based on its
|
||||
* `package.json` and process environment. Used by {@link createTestConfig}
|
||||
* to route between vitest and bun-test.
|
||||
*/
|
||||
|
||||
/** Supported test runners. */
|
||||
export type TestRunner = "vitest" | "bun:test";
|
||||
|
||||
interface MinimalPackageJson {
|
||||
devDependencies?: Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
scripts?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the runner from caller context. Order of precedence:
|
||||
* 1. Explicit `override` argument
|
||||
* 2. `BUN_TEST` env truthy → `bun:test`
|
||||
* 3. Package's scripts mention `bun test` → `bun:test`
|
||||
* 4. Package depends on `vitest` → `vitest`
|
||||
* 5. Default: `vitest`
|
||||
*/
|
||||
export function detectRunner(
|
||||
override: TestRunner | undefined,
|
||||
pkg: MinimalPackageJson
|
||||
): TestRunner {
|
||||
if (override) return override;
|
||||
if (typeof process !== "undefined" && process.env["BUN_TEST"]) {
|
||||
return "bun:test";
|
||||
}
|
||||
const scripts = pkg.scripts ?? {};
|
||||
for (const value of Object.values(scripts)) {
|
||||
if (value.includes("bun test")) return "bun:test";
|
||||
}
|
||||
const deps = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
};
|
||||
if ("vitest" in deps) return "vitest";
|
||||
return "vitest";
|
||||
}
|
||||
120
packages/testkit/testing/testing-dom/src/config/index.ts
Normal file
120
packages/testkit/testing/testing-dom/src/config/index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* `createTestConfig()` — vitest / bun-test config factory absorbing
|
||||
* WSL2-safe defaults and conditional React-plugin loading.
|
||||
*
|
||||
* Most consumers will write:
|
||||
*
|
||||
* ```ts
|
||||
* import { createTestConfig } from "@suaveplan/testing/config";
|
||||
* export default await createTestConfig({ environment: "happy-dom" });
|
||||
* ```
|
||||
*
|
||||
* The factory reads the caller's `package.json` to detect runner +
|
||||
* environment if not explicitly overridden.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { TESTING_DOM_ERROR_CODES } from "../errors/codes.js";
|
||||
import { ConfigError } from "../errors/testing-dom-error.js";
|
||||
import { type BunTestConfig, createBunTestConfig } from "./bun-test-config.js";
|
||||
import {
|
||||
detectEnvironment,
|
||||
type TestEnvironment,
|
||||
} from "./detect-environment.js";
|
||||
import { detectRunner, type TestRunner } from "./detect-runner.js";
|
||||
import {
|
||||
createVitestConfig,
|
||||
type VitestConfigOverrides,
|
||||
} from "./vitest-config.js";
|
||||
|
||||
export interface TestConfigOptions extends VitestConfigOverrides {
|
||||
/** Force a specific runner (overrides auto-detection). */
|
||||
readonly runner?: TestRunner;
|
||||
}
|
||||
|
||||
interface MinimalPackageJson {
|
||||
name?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
scripts?: Record<string, string>;
|
||||
}
|
||||
|
||||
function readPackageJson(dir: string): MinimalPackageJson {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(resolve(dir, "package.json"), "utf-8")
|
||||
) as MinimalPackageJson;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a runner-appropriate test config. Returns either a vitest
|
||||
* `UserConfig` (wrapped via `defineConfig`) or a `BunTestConfig`. The
|
||||
* caller's runner choice is detected from `package.json` if not
|
||||
* overridden.
|
||||
*/
|
||||
export async function createTestConfig(
|
||||
options: TestConfigOptions = {}
|
||||
): Promise<ReturnType<typeof defineConfig> | BunTestConfig> {
|
||||
const callerDir = process.cwd();
|
||||
const pkg = readPackageJson(callerDir);
|
||||
const runner = detectRunner(options.runner, pkg);
|
||||
|
||||
if (runner === "bun:test") {
|
||||
const config = createBunTestConfig(callerDir, options);
|
||||
for (const warning of config.warnings) {
|
||||
console.warn(`[@suaveplan/testing] ${warning}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
if (runner !== "vitest") {
|
||||
throw new ConfigError(
|
||||
TESTING_DOM_ERROR_CODES.CONFIG_UNKNOWN_RUNNER,
|
||||
`createTestConfig: unknown runner "${String(runner)}"`,
|
||||
{ context: { runner } }
|
||||
);
|
||||
}
|
||||
|
||||
const environment: TestEnvironment =
|
||||
options.environment ?? detectEnvironment(pkg);
|
||||
const vitestConfig = createVitestConfig(callerDir, {
|
||||
...options,
|
||||
environment,
|
||||
});
|
||||
|
||||
if (environment === "happy-dom") {
|
||||
try {
|
||||
// Dynamic import via a string literal hidden from TS so a
|
||||
// missing optional peer doesn't break typecheck. The plugin
|
||||
// is loaded only when both happy-dom is requested AND the
|
||||
// peer is installed in the consumer.
|
||||
const moduleName = "@vitejs/plugin-react";
|
||||
const reactModule = (await import(
|
||||
/* @vite-ignore */ moduleName
|
||||
)) as { default?: () => unknown };
|
||||
const reactPlugin = reactModule.default ?? reactModule;
|
||||
return defineConfig({
|
||||
...vitestConfig,
|
||||
plugins: [(reactPlugin as () => unknown)()],
|
||||
} as Parameters<typeof defineConfig>[0]);
|
||||
} catch {
|
||||
return defineConfig(vitestConfig);
|
||||
}
|
||||
}
|
||||
|
||||
return defineConfig(vitestConfig);
|
||||
}
|
||||
|
||||
export type {
|
||||
BunTestConfig,
|
||||
BunTestConfigOverrides,
|
||||
} from "./bun-test-config.js";
|
||||
export type { TestEnvironment } from "./detect-environment.js";
|
||||
export type { TestRunner } from "./detect-runner.js";
|
||||
export type { VitestConfigOverrides } from "./vitest-config.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Build a vitest user-config object with WSL2-safe defaults
|
||||
* (`pool: "forks"`, polyfill setupFiles, optional happy-dom React plugin).
|
||||
*
|
||||
* Returned shape is intentionally untyped from vitest internals so
|
||||
* downstream consumers can spread/merge without tight coupling.
|
||||
*/
|
||||
|
||||
import type { TestEnvironment } from "./detect-environment.js";
|
||||
|
||||
export interface VitestConfigOverrides {
|
||||
/** Vitest test environment (`node`, `happy-dom`, `jsdom`). */
|
||||
readonly environment?: TestEnvironment;
|
||||
/** Per-test timeout in milliseconds. */
|
||||
readonly testTimeout?: number;
|
||||
/** Coverage block (true → enable defaults; object → custom). */
|
||||
readonly coverage?: boolean | Record<string, unknown>;
|
||||
/** Extra setup files concatenated after the polyfills. */
|
||||
readonly setupFiles?: readonly string[];
|
||||
/** Extra include globs concatenated after the defaults. */
|
||||
readonly include?: readonly string[];
|
||||
/** Extra exclude globs concatenated after the defaults. */
|
||||
readonly exclude?: readonly string[];
|
||||
/** When `false`, run files sequentially in the same fork. */
|
||||
readonly fileParallelism?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_INCLUDE = ["src/**/*.test.ts", "src/**/*.test.tsx"];
|
||||
const DEFAULT_EXCLUDE = ["src/__benchmarks__/**", "dist/**", "node_modules/**"];
|
||||
|
||||
/**
|
||||
* Build the inner `test` block for vitest. Caller is expected to wrap
|
||||
* it in `defineConfig({ test: ... })`. Returning an unwrapped object
|
||||
* keeps the function easy to compose.
|
||||
*/
|
||||
export function createVitestConfig(
|
||||
callerDir: string,
|
||||
overrides: VitestConfigOverrides
|
||||
): { test: Record<string, unknown> } {
|
||||
const environment: TestEnvironment = overrides.environment ?? "node";
|
||||
const fileParallelism =
|
||||
overrides.fileParallelism ?? environment !== "happy-dom";
|
||||
const include = [...DEFAULT_INCLUDE, ...(overrides.include ?? [])];
|
||||
const exclude = [...DEFAULT_EXCLUDE, ...(overrides.exclude ?? [])];
|
||||
const setupFiles = [
|
||||
"@suaveplan/testing-dom/polyfills",
|
||||
...(overrides.setupFiles ?? []),
|
||||
];
|
||||
|
||||
const test: Record<string, unknown> = {
|
||||
environment,
|
||||
pool: "forks",
|
||||
fileParallelism,
|
||||
include,
|
||||
exclude,
|
||||
setupFiles,
|
||||
testTimeout: overrides.testTimeout ?? 5000,
|
||||
// callerDir is preserved for potential downstream resolution use.
|
||||
root: callerDir,
|
||||
};
|
||||
|
||||
if (overrides.coverage) {
|
||||
test["coverage"] =
|
||||
overrides.coverage === true
|
||||
? {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 95,
|
||||
branches: 95,
|
||||
functions: 95,
|
||||
lines: 95,
|
||||
},
|
||||
}
|
||||
: overrides.coverage;
|
||||
}
|
||||
|
||||
return { test };
|
||||
}
|
||||
66
packages/testkit/testing/testing-dom/src/errors/codes.md
Normal file
66
packages/testkit/testing/testing-dom/src/errors/codes.md
Normal 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).
|
||||
19
packages/testkit/testing/testing-dom/src/errors/codes.ts
Normal file
19
packages/testkit/testing/testing-dom/src/errors/codes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Stable error-code constants for `@suaveplan/testing-dom`.
|
||||
*/
|
||||
|
||||
export const TESTING_DOM_ERROR_CODES = {
|
||||
CONFIG_UNKNOWN_RUNNER: "TESTING_DOM.CONFIG.UNKNOWN_RUNNER",
|
||||
CONFIG_INVALID_OPTIONS: "TESTING_DOM.CONFIG.INVALID_OPTIONS",
|
||||
RENDER_SETUP_FAILED: "TESTING_DOM.RENDER.SETUP_FAILED",
|
||||
RENDER_CLEANUP_FAILED: "TESTING_DOM.RENDER.CLEANUP_FAILED",
|
||||
MATCHER_NOT_INSTALLED: "TESTING_DOM.MATCHER.NOT_INSTALLED",
|
||||
USER_NOT_AVAILABLE: "TESTING_DOM.USER.NOT_AVAILABLE",
|
||||
ABORTED: "TESTING_DOM.ABORTED",
|
||||
} as const;
|
||||
|
||||
export type TestingDomErrorCode =
|
||||
(typeof TESTING_DOM_ERROR_CODES)[keyof typeof TESTING_DOM_ERROR_CODES];
|
||||
|
||||
export const TESTING_DOM_ERROR_CODE_PATTERN =
|
||||
/^TESTING_DOM\.[A-Z]+\.[A-Z_]+$|^TESTING_DOM\.ABORTED$/;
|
||||
14
packages/testkit/testing/testing-dom/src/errors/index.ts
Normal file
14
packages/testkit/testing/testing-dom/src/errors/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
TESTING_DOM_ERROR_CODE_PATTERN,
|
||||
TESTING_DOM_ERROR_CODES,
|
||||
type TestingDomErrorCode,
|
||||
} from "./codes.js";
|
||||
export {
|
||||
AbortedError,
|
||||
ConfigError,
|
||||
MatcherError,
|
||||
RenderError,
|
||||
TestingDomError,
|
||||
type TestingDomErrorOptions,
|
||||
UserError,
|
||||
} from "./testing-dom-error.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
import {
|
||||
TESTING_DOM_ERROR_CODE_PATTERN,
|
||||
TESTING_DOM_ERROR_CODES,
|
||||
} from "./codes.js";
|
||||
import {
|
||||
AbortedError,
|
||||
ConfigError,
|
||||
MatcherError,
|
||||
RenderError,
|
||||
TestingDomError,
|
||||
UserError,
|
||||
} from "./testing-dom-error.js";
|
||||
|
||||
describe("TestingDomError base", () => {
|
||||
it("stores code, message, cause, context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("u");
|
||||
const err = new TestingDomError(
|
||||
TESTING_DOM_ERROR_CODES.RENDER_SETUP_FAILED,
|
||||
"render fail",
|
||||
{ cause, context: { x: 1 } }
|
||||
);
|
||||
expect(err.code).toBe("TESTING_DOM.RENDER.SETUP_FAILED");
|
||||
expect(err.message).toBe("render fail");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ x: 1 });
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(TestingDomError);
|
||||
});
|
||||
|
||||
it("omits context/cause when not provided", { timeout: 5000 }, () => {
|
||||
const err = new TestingDomError(TESTING_DOM_ERROR_CODES.ABORTED, "x");
|
||||
expect(err.cause).toBeUndefined();
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Subclasses", () => {
|
||||
it("each preserves instanceof TestingDomError", { timeout: 5000 }, () => {
|
||||
const cases = [
|
||||
new ConfigError(TESTING_DOM_ERROR_CODES.CONFIG_UNKNOWN_RUNNER, "x"),
|
||||
new RenderError(TESTING_DOM_ERROR_CODES.RENDER_SETUP_FAILED, "x"),
|
||||
new MatcherError(
|
||||
TESTING_DOM_ERROR_CODES.MATCHER_NOT_INSTALLED,
|
||||
"x"
|
||||
),
|
||||
new UserError(TESTING_DOM_ERROR_CODES.USER_NOT_AVAILABLE, "x"),
|
||||
];
|
||||
for (const e of cases) expect(e).toBeInstanceOf(TestingDomError);
|
||||
});
|
||||
|
||||
it("AbortedError hardcodes ABORTED code", { timeout: 5000 }, () => {
|
||||
const err = new AbortedError("x");
|
||||
expect(err.code).toBe(TESTING_DOM_ERROR_CODES.ABORTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Code pattern", () => {
|
||||
it("every value matches the pattern", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(TESTING_DOM_ERROR_CODES)) {
|
||||
expect(code).toMatch(TESTING_DOM_ERROR_CODE_PATTERN);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Structured error hierarchy for `@suaveplan/testing-dom`.
|
||||
*
|
||||
* Mirrors the shape of `@suaveplan/testing`'s `TestingError` but with
|
||||
* `TESTING_DOM.<DOMAIN>.<REASON>` codes. Local because Layer 0 testing-
|
||||
* infrastructure packages forbid `@suaveplan/*` workspace deps.
|
||||
*/
|
||||
|
||||
import type { TestingDomErrorCode } from "./codes.js";
|
||||
|
||||
export interface TestingDomErrorOptions {
|
||||
readonly cause?: unknown;
|
||||
readonly context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class TestingDomError extends Error {
|
||||
public override readonly name: string = "TestingDomError";
|
||||
public readonly code: TestingDomErrorCode;
|
||||
public override readonly cause: unknown;
|
||||
public readonly context: Record<string, unknown> | undefined;
|
||||
|
||||
constructor(
|
||||
code: TestingDomErrorCode,
|
||||
message: string,
|
||||
options: TestingDomErrorOptions = {}
|
||||
) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.cause = options.cause;
|
||||
this.context = options.context;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends TestingDomError {
|
||||
public override readonly name = "ConfigError";
|
||||
}
|
||||
|
||||
export class RenderError extends TestingDomError {
|
||||
public override readonly name = "RenderError";
|
||||
}
|
||||
|
||||
export class MatcherError extends TestingDomError {
|
||||
public override readonly name = "MatcherError";
|
||||
}
|
||||
|
||||
export class UserError extends TestingDomError {
|
||||
public override readonly name = "UserError";
|
||||
}
|
||||
|
||||
export class AbortedError extends TestingDomError {
|
||||
public override readonly name = "AbortedError";
|
||||
constructor(message: string, options?: TestingDomErrorOptions) {
|
||||
super("TESTING_DOM.ABORTED", message, options);
|
||||
}
|
||||
}
|
||||
83
packages/testkit/testing/testing-dom/src/index.ts
Normal file
83
packages/testkit/testing/testing-dom/src/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* `@suaveplan/testing-dom` — DOM/React testing infrastructure.
|
||||
*
|
||||
* - Test runner adapter (vitest re-exports under default; bun-test under
|
||||
* the `bun` condition)
|
||||
* - `createTestConfig()` factory and runner/environment detectors
|
||||
* - Polyfills setup (`./polyfills`)
|
||||
* - Render helpers (`renderComponent` over @testing-library/react)
|
||||
* - Accessibility-first query aliases
|
||||
* - User events via @testing-library/user-event v14
|
||||
* - jest-dom matchers (`./matchers` side-effect import)
|
||||
* - Local `TestingDomError` hierarchy
|
||||
*
|
||||
* Layer 0 — no `@suaveplan/*` runtime dependencies.
|
||||
*/
|
||||
|
||||
export type {
|
||||
TestingDomErrorCode,
|
||||
TestingDomErrorOptions,
|
||||
} from "./errors/index.js";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
AbortedError,
|
||||
ConfigError,
|
||||
MatcherError,
|
||||
RenderError,
|
||||
TESTING_DOM_ERROR_CODE_PATTERN,
|
||||
TESTING_DOM_ERROR_CODES,
|
||||
TestingDomError,
|
||||
UserError,
|
||||
} from "./errors/index.js";
|
||||
// Observability
|
||||
export { instrumented } from "./observability/index.js";
|
||||
// Queries
|
||||
export {
|
||||
byLabelText,
|
||||
byPlaceholderText,
|
||||
byRole,
|
||||
byText,
|
||||
findAllByLabelText,
|
||||
findAllByRole,
|
||||
findByLabelText,
|
||||
findByRole,
|
||||
findByText,
|
||||
findRole,
|
||||
queryAllByLabelText,
|
||||
queryAllByRole,
|
||||
queryByLabelText,
|
||||
queryByRole,
|
||||
queryByText,
|
||||
queryRole,
|
||||
screen,
|
||||
within,
|
||||
} from "./queries/queries.js";
|
||||
// Render
|
||||
export {
|
||||
type RenderComponentOptions,
|
||||
type RenderComponentResult,
|
||||
renderComponent,
|
||||
} from "./render/index.js";
|
||||
export type { RunnerMock, SpyHandle } from "./runner/index.js";
|
||||
// Runner primitives (default + vitest condition)
|
||||
export {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
fakeClock,
|
||||
fn,
|
||||
it,
|
||||
mock,
|
||||
restoreMocks,
|
||||
restoreTimers,
|
||||
spy,
|
||||
stub,
|
||||
test,
|
||||
vi,
|
||||
} from "./runner/index.js";
|
||||
// User events
|
||||
export { type SetupUserOptions, setupUser } from "./user/setup-user.js";
|
||||
@@ -0,0 +1 @@
|
||||
export {} from "./matchers.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Spec-conformance pin for the jest-dom ambient augmentation.
|
||||
*
|
||||
* After `import "./matchers.js"`, TypeScript MUST see the jest-dom
|
||||
* matcher names on `expect(node)`. The presence of this file passing
|
||||
* `tsc --noEmit` is the type-level guarantee.
|
||||
*/
|
||||
|
||||
import { renderComponent } from "../render/render-component.js";
|
||||
import { describe, expect, expectTypeOf, it } from "../runner/index.js";
|
||||
import "./matchers.js";
|
||||
|
||||
describe("jest-dom matchers ambient augmentation", () => {
|
||||
it("expect(element).toBeInTheDocument is typed as a function", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { getByText } = renderComponent(<p>typed</p>);
|
||||
const el = getByText("typed");
|
||||
// Type-level pin: if the ambient augmentation is missing,
|
||||
// this expression has no `.toBeInTheDocument` and tsc fails.
|
||||
expectTypeOf(expect(el).toBeInTheDocument).toBeFunction();
|
||||
expect(el).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("every common matcher is typed as a function on expect(...)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { getByRole } = renderComponent(
|
||||
<button type="button">go</button>
|
||||
);
|
||||
const node = getByRole("button");
|
||||
expectTypeOf(expect(node).toBeInTheDocument).toBeFunction();
|
||||
expectTypeOf(expect(node).toHaveTextContent).toBeFunction();
|
||||
expectTypeOf(expect(node).toBeDisabled).toBeFunction();
|
||||
expectTypeOf(expect(node).toBeEnabled).toBeFunction();
|
||||
expectTypeOf(expect(node).toHaveAttribute).toBeFunction();
|
||||
expectTypeOf(expect(node).toBeVisible).toBeFunction();
|
||||
expectTypeOf(expect(node).toHaveClass).toBeFunction();
|
||||
expectTypeOf(expect(node).toHaveValue).toBeFunction();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { renderComponent } from "../render/render-component.js";
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
// Side-effect import — extends `expect` with jest-dom matchers.
|
||||
import "./matchers.js";
|
||||
|
||||
describe("jest-dom matchers", () => {
|
||||
it("toBeInTheDocument", { timeout: 5000 }, () => {
|
||||
const { getByText } = renderComponent(<p>hello</p>);
|
||||
expect(getByText("hello")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toHaveTextContent", { timeout: 5000 }, () => {
|
||||
const { getByRole } = renderComponent(
|
||||
<button type="button">Submit</button>
|
||||
);
|
||||
expect(getByRole("button")).toHaveTextContent("Submit");
|
||||
});
|
||||
|
||||
it("toBeDisabled / toBeEnabled", { timeout: 5000 }, () => {
|
||||
const { getByRole } = renderComponent(
|
||||
<button type="button" disabled>
|
||||
no
|
||||
</button>
|
||||
);
|
||||
expect(getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("toHaveAttribute", { timeout: 5000 }, () => {
|
||||
const { getByRole } = renderComponent(<a href="/path">link</a>);
|
||||
expect(getByRole("link")).toHaveAttribute("href", "/path");
|
||||
});
|
||||
|
||||
it("toHaveClass", { timeout: 5000 }, () => {
|
||||
const { getByText } = renderComponent(
|
||||
<span className="alpha beta">x</span>
|
||||
);
|
||||
expect(getByText("x")).toHaveClass("alpha");
|
||||
expect(getByText("x")).toHaveClass("beta");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Side-effect import that extends vitest's `expect` with the
|
||||
* `@testing-library/jest-dom` matcher set.
|
||||
*
|
||||
* Import once per test file or via vitest `setupFiles`:
|
||||
*
|
||||
* ```ts
|
||||
* import "@suaveplan/testing-dom/matchers";
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Ambient augmentation: the `@testing-library/jest-dom/vitest` subpath
|
||||
// declares `module 'vitest'` so consumers see `toBeInTheDocument`,
|
||||
// `toHaveTextContent`, etc. on `expect(...)` after this side-effect
|
||||
// import without having to add `@testing-library/jest-dom` to their
|
||||
// own tsconfig types.
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import * as matchers from "@testing-library/jest-dom/matchers";
|
||||
import { expect } from "vitest";
|
||||
|
||||
expect.extend(matchers);
|
||||
@@ -0,0 +1 @@
|
||||
export { instrumented } from "./instrumented.js";
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Branch-coverage tests for `instrumented` — exercises the
|
||||
* `performance.now` fallback and the cached-instrument branches that
|
||||
* the main test file leaves uncovered.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "../runner/index.js";
|
||||
import { instrumented } from "./instrumented.js";
|
||||
|
||||
const ORIGINAL_GATE = process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_GATE === undefined) {
|
||||
delete process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
} else {
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] = ORIGINAL_GATE;
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("instrumented — performance.now fallback", () => {
|
||||
beforeEach(() => {
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] = "1";
|
||||
});
|
||||
|
||||
it("falls back to Date.now when performance is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.stubGlobal("performance", undefined as unknown as Performance);
|
||||
const wrapped = instrumented(
|
||||
"edge.no-performance",
|
||||
(x: number) => x + 1
|
||||
);
|
||||
expect(wrapped(1)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "../runner/index.js";
|
||||
import { instrumented } from "./instrumented.js";
|
||||
|
||||
const ORIGINAL_GATE = process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_GATE === undefined) {
|
||||
delete process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
} else {
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] = ORIGINAL_GATE;
|
||||
}
|
||||
});
|
||||
|
||||
describe("instrumented — gate disabled (default)", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
});
|
||||
|
||||
it("returns the function unchanged when gate is off", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inner = (x: number): number => x + 1;
|
||||
const wrapped = instrumented("test.add", inner);
|
||||
expect(wrapped).toBe(inner);
|
||||
expect(wrapped(1)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instrumented — gate enabled", () => {
|
||||
beforeEach(() => {
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] = "1";
|
||||
});
|
||||
|
||||
it("wraps the function and preserves return value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wrapped = instrumented("test.add2", (x: number) => x * 2);
|
||||
expect(wrapped(3)).toBe(6);
|
||||
});
|
||||
|
||||
it("rethrows errors from wrapped functions", { timeout: 5000 }, () => {
|
||||
const wrapped = instrumented("test.throw", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(() => wrapped()).toThrow("boom");
|
||||
});
|
||||
|
||||
it("rethrows non-Error throws (string)", { timeout: 5000 }, () => {
|
||||
const wrapped = instrumented("test.throw-string", () => {
|
||||
throw "raw";
|
||||
});
|
||||
expect(() => wrapped()).toThrow("raw");
|
||||
});
|
||||
|
||||
it("caches counters/histograms when called twice with the same name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = instrumented("test.cache", (x: number) => x);
|
||||
const b = instrumented("test.cache", (x: number) => x * 2);
|
||||
expect(a(1)).toBe(1);
|
||||
expect(b(1)).toBe(2);
|
||||
});
|
||||
|
||||
it("ends span only after async fn settles (resolved)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wrapped = instrumented(
|
||||
"test.async-resolve",
|
||||
async (x: number) => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return x + 1;
|
||||
}
|
||||
);
|
||||
const start = Date.now();
|
||||
const result = await wrapped(2);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(result).toBe(3);
|
||||
// The wrapper awaited the async fn before recording duration —
|
||||
// observable proof the span did not end synchronously.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("ends span only after async fn settles (rejected)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wrapped = instrumented("test.async-reject", async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
throw new Error("async-boom");
|
||||
});
|
||||
await expect(wrapped()).rejects.toThrow("async-boom");
|
||||
});
|
||||
|
||||
it("ends span only after async fn settles (rejected with non-Error)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wrapped = instrumented("test.async-reject-string", async () => {
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
return Promise.reject("raw-async");
|
||||
});
|
||||
await expect(wrapped()).rejects.toBe("raw-async");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* OTEL instrumentation for `@suaveplan/testing-dom` public entrypoints.
|
||||
*
|
||||
* Gated behind `process.env.TESTING_DOM_OTEL_ENABLED === "1"`. When the
|
||||
* gate is false, the wrapper is a no-op and there is zero overhead. When
|
||||
* true, counters / histograms emit via `@opentelemetry/api`'s default
|
||||
* meter (which itself is a no-op unless the consumer has registered a
|
||||
* provider).
|
||||
*/
|
||||
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
|
||||
const TRACER_NAME = "@suaveplan/testing-dom";
|
||||
|
||||
function isEnabled(): boolean {
|
||||
return (
|
||||
typeof process !== "undefined" &&
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] === "1"
|
||||
);
|
||||
}
|
||||
|
||||
const tracer = trace.getTracer(TRACER_NAME);
|
||||
const meter = metrics.getMeter(TRACER_NAME);
|
||||
|
||||
const counters = new Map<string, ReturnType<typeof meter.createCounter>>();
|
||||
const histograms = new Map<string, ReturnType<typeof meter.createHistogram>>();
|
||||
|
||||
function getCounter(name: string): ReturnType<typeof meter.createCounter> {
|
||||
let counter = counters.get(name);
|
||||
if (!counter) {
|
||||
counter = meter.createCounter(name);
|
||||
counters.set(name, counter);
|
||||
}
|
||||
return counter;
|
||||
}
|
||||
|
||||
function getHistogram(name: string): ReturnType<typeof meter.createHistogram> {
|
||||
let histogram = histograms.get(name);
|
||||
if (!histogram) {
|
||||
histogram = meter.createHistogram(name, { unit: "ms" });
|
||||
histograms.set(name, histogram);
|
||||
}
|
||||
return histogram;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a function with optional OTEL counter + duration histogram.
|
||||
* No-op (returns the function unchanged) when the env gate is off.
|
||||
*/
|
||||
export function instrumented<TArgs extends unknown[], TReturn>(
|
||||
name: string,
|
||||
fn: (...args: TArgs) => TReturn
|
||||
): (...args: TArgs) => TReturn {
|
||||
if (!isEnabled()) return fn;
|
||||
const counter = getCounter(`testing_dom.${name}.count`);
|
||||
const histogram = getHistogram(`testing_dom.${name}.duration_ms`);
|
||||
return function instrumentedWrapper(...args: TArgs): TReturn {
|
||||
const start = now();
|
||||
counter.add(1);
|
||||
return tracer.startActiveSpan(`testing_dom.${name}`, (span) => {
|
||||
try {
|
||||
const result = fn(...args);
|
||||
if (isPromiseLike(result)) {
|
||||
return result.then(
|
||||
(value) => {
|
||||
span.end();
|
||||
histogram.record(now() - start);
|
||||
return value;
|
||||
},
|
||||
(err: unknown) => {
|
||||
span.recordException(
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error(String(err))
|
||||
);
|
||||
span.end();
|
||||
histogram.record(now() - start);
|
||||
throw err;
|
||||
}
|
||||
) as TReturn;
|
||||
}
|
||||
span.end();
|
||||
histogram.record(now() - start);
|
||||
return result;
|
||||
} catch (err) {
|
||||
span.recordException(
|
||||
err instanceof Error ? err : new Error(String(err))
|
||||
);
|
||||
span.end();
|
||||
histogram.record(now() - start);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function isPromiseLike<T>(value: unknown): value is Promise<T> {
|
||||
return (
|
||||
value !== null &&
|
||||
(typeof value === "object" || typeof value === "function") &&
|
||||
typeof (value as { then?: unknown }).then === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function now(): number {
|
||||
return typeof performance !== "undefined" &&
|
||||
typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
20
packages/testkit/testing/testing-dom/src/queries/index.ts
Normal file
20
packages/testkit/testing/testing-dom/src/queries/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
byLabelText,
|
||||
byPlaceholderText,
|
||||
byRole,
|
||||
byText,
|
||||
findAllByLabelText,
|
||||
findAllByRole,
|
||||
findByLabelText,
|
||||
findByRole,
|
||||
findByText,
|
||||
findRole,
|
||||
queryAllByLabelText,
|
||||
queryAllByRole,
|
||||
queryByLabelText,
|
||||
queryByRole,
|
||||
queryByText,
|
||||
queryRole,
|
||||
screen,
|
||||
within,
|
||||
} from "./queries";
|
||||
64
packages/testkit/testing/testing-dom/src/queries/queries.md
Normal file
64
packages/testkit/testing/testing-dom/src/queries/queries.md
Normal 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.
|
||||
@@ -0,0 +1,69 @@
|
||||
import { renderComponent } from "../render/render-component.js";
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
import {
|
||||
byLabelText,
|
||||
byPlaceholderText,
|
||||
byRole,
|
||||
byText,
|
||||
findRole,
|
||||
queryRole,
|
||||
screen,
|
||||
within,
|
||||
} from "./queries.js";
|
||||
|
||||
describe("Query aliases", () => {
|
||||
it("byRole returns the matching element", { timeout: 5000 }, () => {
|
||||
renderComponent(<button type="button">go</button>);
|
||||
const btn = byRole("button");
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn.textContent).toBe("go");
|
||||
});
|
||||
|
||||
it("byText matches text content", { timeout: 5000 }, () => {
|
||||
renderComponent(<p>hello world</p>);
|
||||
expect(byText("hello world")).toBeDefined();
|
||||
});
|
||||
|
||||
it("byLabelText matches an associated label", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(
|
||||
<label>
|
||||
Username
|
||||
<input type="text" />
|
||||
</label>
|
||||
);
|
||||
expect(byLabelText("Username")).toBeDefined();
|
||||
});
|
||||
|
||||
it("byPlaceholderText matches input placeholder", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(<input type="text" placeholder="search" />);
|
||||
expect(byPlaceholderText("search")).toBeDefined();
|
||||
});
|
||||
|
||||
it("queryRole returns null on miss", { timeout: 5000 }, () => {
|
||||
renderComponent(<div>no roles here</div>);
|
||||
expect(queryRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("findRole resolves async-ly to the element", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
renderComponent(<button type="button">later</button>);
|
||||
const btn = await findRole("button");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
it("screen and within are re-exported", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<div>
|
||||
<span data-testid="inner">x</span>
|
||||
</div>
|
||||
);
|
||||
const root = screen.getByTestId("inner");
|
||||
const scoped = within(root.parentElement!);
|
||||
expect(scoped.getByTestId("inner")).toBe(root);
|
||||
});
|
||||
});
|
||||
81
packages/testkit/testing/testing-dom/src/queries/queries.ts
Normal file
81
packages/testkit/testing/testing-dom/src/queries/queries.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Re-exports `@testing-library/react`'s queries plus accessibility-first
|
||||
* aliases. Prefer `byRole` / `byLabelText` / `byText` over `getByTestId`
|
||||
* for resilient component tests.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ByRoleOptions,
|
||||
findAllByLabelText,
|
||||
findAllByRole,
|
||||
findByLabelText,
|
||||
findByRole,
|
||||
findByText,
|
||||
queryAllByLabelText,
|
||||
queryAllByRole,
|
||||
queryByLabelText,
|
||||
queryByRole,
|
||||
queryByText,
|
||||
screen,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
|
||||
export {
|
||||
findAllByLabelText,
|
||||
findAllByRole,
|
||||
findByLabelText,
|
||||
findByRole,
|
||||
findByText,
|
||||
queryAllByLabelText,
|
||||
queryAllByRole,
|
||||
queryByLabelText,
|
||||
queryByRole,
|
||||
queryByText,
|
||||
screen,
|
||||
within,
|
||||
};
|
||||
|
||||
/** Strict role query — throws if not found. */
|
||||
export function byRole(
|
||||
role: Parameters<typeof screen.getByRole>[0],
|
||||
options?: ByRoleOptions
|
||||
): HTMLElement {
|
||||
return screen.getByRole(role, options);
|
||||
}
|
||||
|
||||
/** Async role query — waits up to vitest's defaults. */
|
||||
export function findRole(
|
||||
role: Parameters<typeof screen.findByRole>[0],
|
||||
options?: ByRoleOptions
|
||||
): Promise<HTMLElement> {
|
||||
return screen.findByRole(role, options);
|
||||
}
|
||||
|
||||
/** Optional role query — returns `null` if not found. */
|
||||
export function queryRole(
|
||||
role: Parameters<typeof screen.queryByRole>[0],
|
||||
options?: ByRoleOptions
|
||||
): HTMLElement | null {
|
||||
return screen.queryByRole(role, options);
|
||||
}
|
||||
|
||||
/** Strict label-text query. */
|
||||
export function byLabelText(
|
||||
text: Parameters<typeof screen.getByLabelText>[0]
|
||||
): HTMLElement {
|
||||
return screen.getByLabelText(text);
|
||||
}
|
||||
|
||||
/** Strict text-content query. */
|
||||
export function byText(
|
||||
text: Parameters<typeof screen.getByText>[0]
|
||||
): HTMLElement {
|
||||
return screen.getByText(text);
|
||||
}
|
||||
|
||||
/** Strict placeholder-text query. */
|
||||
export function byPlaceholderText(
|
||||
text: Parameters<typeof screen.getByPlaceholderText>[0]
|
||||
): HTMLElement {
|
||||
return screen.getByPlaceholderText(text);
|
||||
}
|
||||
5
packages/testkit/testing/testing-dom/src/render/index.ts
Normal file
5
packages/testkit/testing/testing-dom/src/render/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type RenderComponentOptions,
|
||||
type RenderComponentResult,
|
||||
renderComponent,
|
||||
} from "./render-component.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { TESTING_DOM_ERROR_CODES } from "../errors/codes.js";
|
||||
import { RenderError, TestingDomError } from "../errors/testing-dom-error.js";
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
import { renderComponent } from "./render-component.js";
|
||||
|
||||
describe("renderComponent — happy path", () => {
|
||||
it("renders simple JSX and exposes user", { timeout: 5000 }, () => {
|
||||
const result = renderComponent(<button type="button">Click me</button>);
|
||||
expect(result.getByRole("button")).toBeDefined();
|
||||
expect(result.user).toBeDefined();
|
||||
});
|
||||
|
||||
it("dispatches a click via the returned user", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let count = 0;
|
||||
const result = renderComponent(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
count++;
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</button>
|
||||
);
|
||||
await result.user.click(result.getByRole("button"));
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("accepts a wrapper component", { timeout: 5000 }, () => {
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<section data-wrapped="true">{children}</section>
|
||||
);
|
||||
const result = renderComponent(<span>inside</span>, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
expect(result.container.querySelector("section")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderComponent — error path", () => {
|
||||
it("throws RenderError with cause when render explodes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const Bad = (): React.ReactElement => {
|
||||
throw new Error("component blew up");
|
||||
};
|
||||
try {
|
||||
renderComponent(<Bad />);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TestingDomError);
|
||||
expect(err).toBeInstanceOf(RenderError);
|
||||
expect((err as RenderError).code).toBe(
|
||||
TESTING_DOM_ERROR_CODES.RENDER_SETUP_FAILED
|
||||
);
|
||||
expect((err as RenderError).cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `renderComponent(ui, options?)` — thin wrapper over
|
||||
* `@testing-library/react`'s `render` that auto-cleans on each test
|
||||
* and returns a `RenderResult` extended with a `userEvent` instance
|
||||
* for ergonomic event dispatching.
|
||||
*
|
||||
* Throws `RenderError("TESTING_DOM.RENDER.SETUP_FAILED")` on
|
||||
* synchronous render failure with the original error preserved as
|
||||
* `cause`.
|
||||
*/
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
type RenderResult,
|
||||
type RenderOptions as RtlRenderOptions,
|
||||
render,
|
||||
} from "@testing-library/react";
|
||||
import userEvent, { type UserEvent } from "@testing-library/user-event";
|
||||
import type { ReactElement } from "react";
|
||||
import { TESTING_DOM_ERROR_CODES } from "../errors/codes.js";
|
||||
import { RenderError } from "../errors/testing-dom-error.js";
|
||||
|
||||
/** Options for {@link renderComponent}. */
|
||||
export interface RenderComponentOptions extends RtlRenderOptions {
|
||||
/**
|
||||
* Pre-configured user-event instance. If omitted, a default
|
||||
* happy-dom-friendly instance is created (`pointerEventsCheck: 0`).
|
||||
*/
|
||||
readonly user?: UserEvent;
|
||||
}
|
||||
|
||||
/** Return shape of {@link renderComponent}. */
|
||||
export interface RenderComponentResult extends RenderResult {
|
||||
/** User-event instance for dispatching synthetic events. */
|
||||
readonly user: UserEvent;
|
||||
}
|
||||
|
||||
export function renderComponent(
|
||||
ui: ReactElement,
|
||||
options: RenderComponentOptions = {}
|
||||
): RenderComponentResult {
|
||||
// Clean any residual DOM from prior renders so each call starts
|
||||
// from a fresh document.body. Consumers can ALSO register
|
||||
// `afterEach(cleanup)` for vitest-style isolation.
|
||||
cleanup();
|
||||
const { user: providedUser, ...rtlOptions } = options;
|
||||
let result: RenderResult;
|
||||
try {
|
||||
result = render(ui, rtlOptions);
|
||||
} catch (err) {
|
||||
throw new RenderError(
|
||||
TESTING_DOM_ERROR_CODES.RENDER_SETUP_FAILED,
|
||||
"renderComponent: render() threw during setup",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
const user =
|
||||
providedUser ??
|
||||
userEvent.setup({
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
return Object.assign(result, { user });
|
||||
}
|
||||
62
packages/testkit/testing/testing-dom/src/runner/bun-test.md
Normal file
62
packages/testkit/testing/testing-dom/src/runner/bun-test.md
Normal 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`.
|
||||
189
packages/testkit/testing/testing-dom/src/runner/bun-test.ts
Normal file
189
packages/testkit/testing/testing-dom/src/runner/bun-test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Bun-test re-exports for the `bun` runner condition.
|
||||
*
|
||||
* Bun's built-in test runner (`bun:test`) exposes a similar surface to
|
||||
* vitest. This shim re-exports from `bun:test` and provides the same
|
||||
* mock helpers (`fn`, `spy`, `stub`, `mock`, `fakeClock`, `restoreMocks`,
|
||||
* `restoreTimers`) backed by bun-test's primitives. `package.json`
|
||||
* conditional exports route consumers running under `bun test` to this
|
||||
* file instead of `./vitest.js`.
|
||||
*
|
||||
* Implementation note: `bun:test` is provided by the Bun runtime; types
|
||||
* are sourced from `@types/bun`. The conditional export only resolves
|
||||
* this file when Bun's loader is active, so the import never fires
|
||||
* outside a Bun process.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll as bunAfterAll,
|
||||
afterEach as bunAfterEach,
|
||||
beforeAll as bunBeforeAll,
|
||||
beforeEach as bunBeforeEach,
|
||||
describe as bunDescribe,
|
||||
expect as bunExpect,
|
||||
it as bunIt,
|
||||
jest as bunJest,
|
||||
mock as bunMock,
|
||||
spyOn as bunSpyOn,
|
||||
test as bunTest,
|
||||
} from "bun:test";
|
||||
|
||||
export const afterAll = bunAfterAll;
|
||||
export const afterEach = bunAfterEach;
|
||||
export const beforeAll = bunBeforeAll;
|
||||
export const beforeEach = bunBeforeEach;
|
||||
export const describe = bunDescribe;
|
||||
export const expect = bunExpect;
|
||||
export const it = bunIt;
|
||||
export const test = bunTest;
|
||||
|
||||
/** `vi` alias backed by bun-test's `jest` namespace for API parity. */
|
||||
export const vi = bunJest;
|
||||
|
||||
/**
|
||||
* `expectTypeOf` shim for the bun-test path. Type assertions are erased
|
||||
* at compile time, so a Proxy-backed no-op chain satisfies the runtime
|
||||
* call regardless of which method consumers reach for.
|
||||
*/
|
||||
interface ExpectTypeOfChain {
|
||||
(...args: unknown[]): ExpectTypeOfChain;
|
||||
[key: string]: ExpectTypeOfChain;
|
||||
}
|
||||
|
||||
const noopChain = new Proxy((() => undefined) as unknown as ExpectTypeOfChain, {
|
||||
get(): unknown {
|
||||
return noopChain;
|
||||
},
|
||||
apply(): unknown {
|
||||
return noopChain;
|
||||
},
|
||||
});
|
||||
|
||||
export function expectTypeOf<T = unknown>(_value?: T): ExpectTypeOfChain {
|
||||
return noopChain;
|
||||
}
|
||||
|
||||
/** `inject` shim — see `@suaveplan/testing` runner for rationale. */
|
||||
export function inject<T = unknown>(_key: string): T | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runner-neutral mock-function type. Both the bun-test path (this file)
|
||||
* and the vitest path (`./mocks.ts`) export `fn<TArgs, TReturn>` returning
|
||||
* `RunnerMock<TArgs, TReturn>` so consumers can write
|
||||
* `const m = fn<[string], number>()` and have the call signature typed
|
||||
* under either runner.
|
||||
*/
|
||||
export type RunnerMock<TArgs extends unknown[], TReturn> = ((
|
||||
...args: TArgs
|
||||
) => TReturn) &
|
||||
ReturnType<typeof bunMock>;
|
||||
|
||||
/** Runner-neutral spy/stub return type. */
|
||||
export type SpyHandle = ReturnType<typeof bunSpyOn>;
|
||||
|
||||
export function fn<TArgs extends unknown[] = unknown[], TReturn = unknown>(
|
||||
implementation?: (...args: TArgs) => TReturn
|
||||
): RunnerMock<TArgs, TReturn> {
|
||||
const made = implementation
|
||||
? bunMock(implementation as never)
|
||||
: bunMock(() => undefined as never);
|
||||
return made as unknown as RunnerMock<TArgs, TReturn>;
|
||||
}
|
||||
|
||||
export function spy<T extends object, K extends keyof T & string>(
|
||||
obj: T,
|
||||
method: K
|
||||
): SpyHandle {
|
||||
return bunSpyOn(obj as never, method);
|
||||
}
|
||||
|
||||
export function stub<T extends object, K extends keyof T & string>(
|
||||
obj: T,
|
||||
method: K,
|
||||
implementation?: (...args: unknown[]) => unknown
|
||||
): SpyHandle {
|
||||
const spied = bunSpyOn(obj as never, method);
|
||||
const impl = implementation ?? (() => undefined);
|
||||
(
|
||||
spied as unknown as { mockImplementation(fn: unknown): void }
|
||||
).mockImplementation(impl);
|
||||
return spied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level mock. `bun:test`'s `mock.module()` accepts a runtime
|
||||
* module path. Returns a handle for symmetry with the vitest path; bun
|
||||
* does not expose a public `unmock` so `restore()` is a no-op.
|
||||
*/
|
||||
export function mock(
|
||||
modulePath: string,
|
||||
factory?: () => unknown
|
||||
): { restore(): void } {
|
||||
const mockModule = (
|
||||
bunMock as unknown as {
|
||||
module(path: string, factory?: () => unknown): void;
|
||||
}
|
||||
).module;
|
||||
if (typeof mockModule === "function") {
|
||||
mockModule.call(bunMock, modulePath, factory);
|
||||
}
|
||||
return {
|
||||
restore(): void {
|
||||
// bun:test does not expose unmock; restoreAllMocks() resets state.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install bun-test's fake-timer subsystem if available. Bun does not
|
||||
* yet ship a full fake-timer API parity with vitest; this is a thin
|
||||
* compatibility wrapper.
|
||||
*/
|
||||
export function fakeClock(): {
|
||||
advance(ms: number): void;
|
||||
restore(): void;
|
||||
} {
|
||||
const useFakeTimers = (bunJest as unknown as { useFakeTimers?: () => void })
|
||||
.useFakeTimers;
|
||||
if (typeof useFakeTimers === "function") {
|
||||
useFakeTimers.call(bunJest);
|
||||
}
|
||||
return {
|
||||
advance(ms: number): void {
|
||||
const advanceTimers = (
|
||||
bunJest as unknown as {
|
||||
advanceTimersByTime?: (n: number) => void;
|
||||
}
|
||||
).advanceTimersByTime;
|
||||
if (typeof advanceTimers === "function") {
|
||||
advanceTimers.call(bunJest, ms);
|
||||
}
|
||||
},
|
||||
restore(): void {
|
||||
const useReal = (
|
||||
bunJest as unknown as { useRealTimers?: () => void }
|
||||
).useRealTimers;
|
||||
if (typeof useReal === "function") {
|
||||
useReal.call(bunJest);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreMocks(): void {
|
||||
const restoreAll = (bunJest as unknown as { restoreAllMocks?: () => void })
|
||||
.restoreAllMocks;
|
||||
if (typeof restoreAll === "function") {
|
||||
restoreAll.call(bunJest);
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreTimers(): void {
|
||||
const useReal = (bunJest as unknown as { useRealTimers?: () => void })
|
||||
.useRealTimers;
|
||||
if (typeof useReal === "function") {
|
||||
useReal.call(bunJest);
|
||||
}
|
||||
}
|
||||
28
packages/testkit/testing/testing-dom/src/runner/index.ts
Normal file
28
packages/testkit/testing/testing-dom/src/runner/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Runner barrel for the default vitest path.
|
||||
*
|
||||
* Conditional exports in `package.json` route the `bun` condition to
|
||||
* `./bun-test.ts` directly; the `default` and `vitest` conditions route
|
||||
* to `src/index.ts`, which uses this barrel. This file is therefore
|
||||
* always resolved against vitest semantics.
|
||||
*/
|
||||
export type { RunnerMock, SpyHandle } from "./vitest.js";
|
||||
export {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
expectTypeOf,
|
||||
fakeClock,
|
||||
fn,
|
||||
it,
|
||||
mock,
|
||||
restoreMocks,
|
||||
restoreTimers,
|
||||
spy,
|
||||
stub,
|
||||
test,
|
||||
vi,
|
||||
} from "./vitest.js";
|
||||
57
packages/testkit/testing/testing-dom/src/runner/mocks.md
Normal file
57
packages/testkit/testing/testing-dom/src/runner/mocks.md
Normal 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.
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fakeClock,
|
||||
fn,
|
||||
restoreMocks,
|
||||
restoreTimers,
|
||||
spy,
|
||||
stub,
|
||||
} from "./mocks.js";
|
||||
|
||||
describe("fn", () => {
|
||||
it("creates a mock that records calls", { timeout: 5000 }, () => {
|
||||
const f = fn();
|
||||
f(1, 2, 3);
|
||||
expect(f).toHaveBeenCalledWith(1, 2, 3);
|
||||
});
|
||||
|
||||
it("wraps a provided implementation", { timeout: 5000 }, () => {
|
||||
const f = fn((a: number, b: number) => a + b);
|
||||
expect(f(2, 3)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spy", () => {
|
||||
it("records method calls without changing behavior", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { greet: (n: string) => `hello ${n}` };
|
||||
const handle = spy(obj, "greet");
|
||||
expect(obj.greet("world")).toBe("hello world");
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
handle.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stub", () => {
|
||||
it("replaces the method with the supplied implementation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { value: () => 1 };
|
||||
const handle = stub(obj, "value", () => 99 as never);
|
||||
expect(obj.value()).toBe(99);
|
||||
handle.mockRestore();
|
||||
});
|
||||
|
||||
it("defaults to undefined when no implementation given", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { value: () => 1 };
|
||||
const handle = stub(obj, "value");
|
||||
expect(obj.value()).toBeUndefined();
|
||||
handle.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fakeClock + restoreTimers", () => {
|
||||
it("advances vitest fake timers", { timeout: 5000 }, () => {
|
||||
const clock = fakeClock();
|
||||
let fired = false;
|
||||
setTimeout(() => {
|
||||
fired = true;
|
||||
}, 100);
|
||||
clock.advance(100);
|
||||
expect(fired).toBe(true);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it("restoreTimers switches back to real timers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.useFakeTimers();
|
||||
restoreTimers();
|
||||
// After restore, setTimeout uses real timers — call counts again.
|
||||
expect(vi.isFakeTimers()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreMocks", () => {
|
||||
it("restores all spied / stubbed methods", { timeout: 5000 }, () => {
|
||||
const obj = { v: () => 1 };
|
||||
stub(obj, "v", () => 99 as never);
|
||||
expect(obj.v()).toBe(99);
|
||||
restoreMocks();
|
||||
expect(obj.v()).toBe(1);
|
||||
});
|
||||
});
|
||||
113
packages/testkit/testing/testing-dom/src/runner/mocks.ts
Normal file
113
packages/testkit/testing/testing-dom/src/runner/mocks.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Runner-agnostic mock wrappers.
|
||||
*
|
||||
* On the vitest path these delegate to vitest's `vi`. On the bun-test
|
||||
* path the equivalents come from `bun:test` and live in `bun-test.ts`;
|
||||
* `package.json` conditional exports route consumers to the right one.
|
||||
*/
|
||||
|
||||
import { type Mock, vi } from "vitest";
|
||||
|
||||
/** Runner-neutral spy/stub return type. */
|
||||
export type SpyHandle = ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/**
|
||||
* Runner-neutral mock-function type. Mirrors the alias on the bun-test
|
||||
* path (`./bun-test.ts`) so `const m = fn<[string], number>()` is typed
|
||||
* with the right call signature regardless of which runner is active.
|
||||
*/
|
||||
export type RunnerMock<TArgs extends unknown[], TReturn> = ((
|
||||
...args: TArgs
|
||||
) => TReturn) &
|
||||
Mock;
|
||||
|
||||
/** Wrap or create a vi mock function. */
|
||||
export function fn<TArgs extends unknown[] = unknown[], TReturn = unknown>(
|
||||
implementation?: (...args: TArgs) => TReturn
|
||||
): RunnerMock<TArgs, TReturn> {
|
||||
const made = implementation ? vi.fn(implementation) : vi.fn();
|
||||
return made as unknown as RunnerMock<TArgs, TReturn>;
|
||||
}
|
||||
|
||||
/** Spy on a method without changing its implementation. */
|
||||
export function spy<T extends object, K extends keyof T & string>(
|
||||
obj: T,
|
||||
method: K
|
||||
): SpyHandle {
|
||||
return vi.spyOn(obj as never, method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub a method with an optional implementation. When `implementation`
|
||||
* is omitted the stub returns `undefined`.
|
||||
*/
|
||||
export function stub<T extends object, K extends keyof T & string>(
|
||||
obj: T,
|
||||
method: K,
|
||||
implementation?: (...args: unknown[]) => unknown
|
||||
): SpyHandle {
|
||||
const spied = vi.spyOn(obj as never, method);
|
||||
const impl = implementation ?? (() => undefined);
|
||||
(
|
||||
spied as unknown as { mockImplementation(fn: unknown): void }
|
||||
).mockImplementation(impl);
|
||||
return spied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level mock. Vitest hoists static `vi.mock(...)` calls via a
|
||||
* compiler transform; bracket access (`vi["mock"]`) defeats the static
|
||||
* detection so the call uses the runtime `modulePath` argument as
|
||||
* intended.
|
||||
*/
|
||||
export function mock(
|
||||
modulePath: string,
|
||||
factory?: () => unknown
|
||||
): { restore(): void } {
|
||||
const dynamicMock = vi["mock"] as (
|
||||
path: string,
|
||||
factory?: () => unknown
|
||||
) => void;
|
||||
const dynamicUnmock = vi["unmock"] as (path: string) => void;
|
||||
if (factory) {
|
||||
dynamicMock(modulePath, factory);
|
||||
} else {
|
||||
dynamicMock(modulePath);
|
||||
}
|
||||
return {
|
||||
restore(): void {
|
||||
dynamicUnmock(modulePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install vitest's fake-timer subsystem and return a small control
|
||||
* surface. Use {@link FakeClock} for deterministic clock injection in
|
||||
* production tests; this helper exists for legacy code paths that rely
|
||||
* on `vi.useFakeTimers`.
|
||||
*/
|
||||
export function fakeClock(): {
|
||||
advance(ms: number): void;
|
||||
restore(): void;
|
||||
} {
|
||||
vi.useFakeTimers();
|
||||
return {
|
||||
advance(ms: number): void {
|
||||
vi.advanceTimersByTime(ms);
|
||||
},
|
||||
restore(): void {
|
||||
vi.useRealTimers();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore every spy/stub/mock back to its original. */
|
||||
export function restoreMocks(): void {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
|
||||
/** Switch back to real timers. */
|
||||
export function restoreTimers(): void {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
100
packages/testkit/testing/testing-dom/src/runner/runner.md
Normal file
100
packages/testkit/testing/testing-dom/src/runner/runner.md
Normal 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.
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Type-level spec-conformance pins for the runner adapter.
|
||||
*
|
||||
* Pins:
|
||||
* - `fn<TArgs, TReturn>()` returns a callable typed `(...args: TArgs) => TReturn`
|
||||
* under the vitest path AND (by symmetry) the bun-test path. The bun-test
|
||||
* path can't be exercised under vitest, so we type-check its module
|
||||
* surface via `import type`.
|
||||
* - `RunnerMock` and `SpyHandle` are exported from the package barrel.
|
||||
*/
|
||||
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
import type {
|
||||
RunnerMock as PkgRunnerMock,
|
||||
SpyHandle as PkgSpyHandle,
|
||||
} from "../index.js";
|
||||
import type {
|
||||
RunnerMock as BunRunnerMock,
|
||||
SpyHandle as BunSpyHandle,
|
||||
} from "./bun-test.js";
|
||||
import type {
|
||||
RunnerMock as ViteRunnerMock,
|
||||
SpyHandle as ViteSpyHandle,
|
||||
} from "./mocks.js";
|
||||
import { fn, spy, stub } from "./mocks.js";
|
||||
|
||||
describe("runner generics — vitest path", () => {
|
||||
it("fn<[string], number>() preserves call-signature typing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = fn<[string], number>();
|
||||
expectTypeOf(m).toBeCallableWith("hello");
|
||||
expectTypeOf<ReturnType<typeof m>>().toEqualTypeOf<number>();
|
||||
});
|
||||
|
||||
it("fn<[number, number], number>(impl) preserves call-signature typing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = fn<[number, number], number>((a, b) => a + b);
|
||||
expectTypeOf(m).toBeCallableWith(1, 2);
|
||||
expectTypeOf<ReturnType<typeof m>>().toEqualTypeOf<number>();
|
||||
});
|
||||
|
||||
it("spy returns a SpyHandle assignable to the package's exported type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { greet: (n: string) => `hi ${n}` };
|
||||
const handle = spy(obj, "greet");
|
||||
expectTypeOf(handle).toMatchTypeOf<ViteSpyHandle>();
|
||||
expectTypeOf(handle).toMatchTypeOf<PkgSpyHandle>();
|
||||
handle.mockRestore();
|
||||
});
|
||||
|
||||
it("stub returns a SpyHandle assignable to the package's exported type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { v: () => 1 };
|
||||
const handle = stub(obj, "v", () => 99 as never);
|
||||
expectTypeOf(handle).toMatchTypeOf<ViteSpyHandle>();
|
||||
handle.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runner generics — bun-test path symmetry", () => {
|
||||
it("BunRunnerMock<TArgs, TReturn> shape mirrors the vitest path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Both aliases parameterize on TArgs/TReturn identically; we
|
||||
// assert each carries the runtime call signature.
|
||||
type ViteCall = ViteRunnerMock<[string], number>;
|
||||
type BunCall = BunRunnerMock<[string], number>;
|
||||
expectTypeOf<ViteCall>().toBeCallableWith("hi");
|
||||
expectTypeOf<BunCall>().toBeCallableWith("hi");
|
||||
expectTypeOf<ReturnType<ViteCall>>().toEqualTypeOf<number>();
|
||||
expectTypeOf<ReturnType<BunCall>>().toEqualTypeOf<number>();
|
||||
});
|
||||
|
||||
it("BunSpyHandle is exported and PkgRunnerMock matches both paths", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
type _BunSpy = BunSpyHandle;
|
||||
type Pkg = PkgRunnerMock<[number], string>;
|
||||
expectTypeOf<Pkg>().toBeCallableWith(1);
|
||||
expectTypeOf<ReturnType<Pkg>>().toEqualTypeOf<string>();
|
||||
});
|
||||
});
|
||||
71
packages/testkit/testing/testing-dom/src/runner/vitest.md
Normal file
71
packages/testkit/testing/testing-dom/src/runner/vitest.md
Normal 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();
|
||||
});
|
||||
```
|
||||
32
packages/testkit/testing/testing-dom/src/runner/vitest.ts
Normal file
32
packages/testkit/testing/testing-dom/src/runner/vitest.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Vitest re-exports for the default runner condition.
|
||||
*
|
||||
* `package.json` conditional exports route the bare `@suaveplan/testing`
|
||||
* import to this file under the `default` and `vitest` conditions. The
|
||||
* bun-test path resolves to `./bun-test.ts` instead.
|
||||
*/
|
||||
|
||||
export {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
expectTypeOf,
|
||||
inject,
|
||||
it,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
export type { RunnerMock, SpyHandle } from "./mocks.js";
|
||||
export {
|
||||
fakeClock,
|
||||
fn,
|
||||
mock,
|
||||
restoreMocks,
|
||||
restoreTimers,
|
||||
spy,
|
||||
stub,
|
||||
} from "./mocks.js";
|
||||
4
packages/testkit/testing/testing-dom/src/setup/index.ts
Normal file
4
packages/testkit/testing/testing-dom/src/setup/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Side-effect-only re-export: `./polyfills.ts` installs `globalThis.crypto`
|
||||
// and Text encoder/decoder shims for jsdom/happy-dom on import. No named
|
||||
// exports; the bare side-effect import is the entire contract.
|
||||
import "./polyfills";
|
||||
61
packages/testkit/testing/testing-dom/src/setup/polyfills.md
Normal file
61
packages/testkit/testing/testing-dom/src/setup/polyfills.md
Normal 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.
|
||||
50
packages/testkit/testing/testing-dom/src/setup/polyfills.ts
Normal file
50
packages/testkit/testing/testing-dom/src/setup/polyfills.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Side-effecting polyfills module for happy-dom / jsdom / bare-Node test
|
||||
* environments on WSL2 or older toolchains.
|
||||
*
|
||||
* Import via vitest `setupFiles`:
|
||||
*
|
||||
* ```ts
|
||||
* // vitest.config.ts
|
||||
* export default defineConfig({
|
||||
* test: {
|
||||
* setupFiles: ["@suaveplan/testing-dom/polyfills"],
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Installs:
|
||||
* - `globalThis.crypto` (from `node:crypto`'s `webcrypto` export) if missing
|
||||
* - `globalThis.TextEncoder` / `TextDecoder` (usually present, covered defensively)
|
||||
*
|
||||
* Exports nothing. Safe to import multiple times; operations are idempotent.
|
||||
*/
|
||||
|
||||
import { webcrypto } from "node:crypto";
|
||||
import {
|
||||
TextDecoder as NodeTextDecoder,
|
||||
TextEncoder as NodeTextEncoder,
|
||||
} from "node:util";
|
||||
|
||||
type WritableGlobal = {
|
||||
crypto?: Crypto;
|
||||
TextEncoder?: typeof NodeTextEncoder;
|
||||
TextDecoder?: typeof NodeTextDecoder;
|
||||
};
|
||||
|
||||
const g = globalThis as unknown as WritableGlobal;
|
||||
|
||||
if (
|
||||
typeof g.crypto === "undefined" ||
|
||||
typeof g.crypto.getRandomValues !== "function"
|
||||
) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
|
||||
if (typeof g.TextEncoder === "undefined") {
|
||||
g.TextEncoder = NodeTextEncoder;
|
||||
}
|
||||
|
||||
if (typeof g.TextDecoder === "undefined") {
|
||||
g.TextDecoder = NodeTextDecoder;
|
||||
}
|
||||
1
packages/testkit/testing/testing-dom/src/user/index.ts
Normal file
1
packages/testkit/testing/testing-dom/src/user/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type SetupUserOptions, setupUser } from "./setup-user";
|
||||
63
packages/testkit/testing/testing-dom/src/user/setup-user.md
Normal file
63
packages/testkit/testing/testing-dom/src/user/setup-user.md
Normal 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.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "../runner/index.js";
|
||||
import { setupUser } from "./setup-user.js";
|
||||
|
||||
describe("setupUser", () => {
|
||||
it("returns a UserEvent instance", { timeout: 5000 }, () => {
|
||||
const user = setupUser();
|
||||
expect(user).toBeDefined();
|
||||
expect(typeof user.click).toBe("function");
|
||||
});
|
||||
|
||||
it("wires advanceTimers when fake timers are active", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const user = setupUser();
|
||||
expect(user).toBeDefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("respects explicit options override", { timeout: 5000 }, () => {
|
||||
const user = setupUser({ delay: null, pointerEventsCheck: 1 });
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("imports vi via the runner adapter (no direct vitest hard-import)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
// Resolve via cwd (vitest runs from package root) — happy-dom's
|
||||
// `import.meta.url` uses a non-file scheme, which `node:fs`
|
||||
// refuses, so we cannot use `new URL(..., import.meta.url)` here.
|
||||
const source = await readFile(
|
||||
path.resolve(process.cwd(), "src/user/setup-user.ts"),
|
||||
"utf8"
|
||||
);
|
||||
// Adapter routing: the file MUST resolve `vi` through the
|
||||
// runner adapter (`../runner/index.js`) so the bun-test
|
||||
// conditional path doesn't pull a vitest-only import.
|
||||
expect(source).not.toMatch(/from\s+["']vitest["']/);
|
||||
expect(source).toMatch(/from\s+["']\.\.\/runner\/index\.js["']/);
|
||||
});
|
||||
});
|
||||
32
packages/testkit/testing/testing-dom/src/user/setup-user.ts
Normal file
32
packages/testkit/testing/testing-dom/src/user/setup-user.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `setupUser(options?)` — produce a `@testing-library/user-event` v14
|
||||
* instance with happy-dom-friendly defaults. When vitest fake timers are
|
||||
* active, the instance is wired with `advanceTimers` automatically.
|
||||
*/
|
||||
|
||||
import userEvent, { type UserEvent } from "@testing-library/user-event";
|
||||
// Route through the runner adapter rather than hard-importing `vitest`
|
||||
// so the bun-test conditional path resolves correctly. The adapter
|
||||
// re-exports `vi` (vitest path) or the bun-test `jest` namespace alias.
|
||||
import { vi } from "../runner/index.js";
|
||||
|
||||
/** Options passed through to user-event's `setup()`. */
|
||||
export interface SetupUserOptions {
|
||||
readonly delay?: number | null;
|
||||
readonly pointerEventsCheck?: number;
|
||||
readonly skipHover?: boolean;
|
||||
}
|
||||
|
||||
export function setupUser(options: SetupUserOptions = {}): UserEvent {
|
||||
const baseOptions = {
|
||||
pointerEventsCheck: 0,
|
||||
...options,
|
||||
};
|
||||
if (vi.isFakeTimers()) {
|
||||
return userEvent.setup({
|
||||
...baseOptions,
|
||||
advanceTimers: vi.advanceTimersByTime.bind(vi),
|
||||
});
|
||||
}
|
||||
return userEvent.setup(baseOptions);
|
||||
}
|
||||
60
packages/testkit/testing/testing-dom/tests/dep-graph.test.ts
Normal file
60
packages/testkit/testing/testing-dom/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Dep-graph guard — runtime closure of @suaveplan/testing-dom must
|
||||
* contain zero `@suaveplan/*` packages. devDeps may include
|
||||
* @suaveplan/testing for our own tests.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(here, "..", "package.json");
|
||||
|
||||
describe("dep-graph guard", () => {
|
||||
it("no @suaveplan/* in dependencies or peerDependencies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const sections: Array<
|
||||
[
|
||||
"dependencies" | "peerDependencies",
|
||||
Record<string, string> | undefined,
|
||||
]
|
||||
> = [
|
||||
["dependencies", pkg.dependencies],
|
||||
["peerDependencies", pkg.peerDependencies],
|
||||
];
|
||||
for (const [name, deps] of sections) {
|
||||
if (!deps) continue;
|
||||
const offending = Object.keys(deps).filter((k) =>
|
||||
k.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(
|
||||
offending,
|
||||
`${name} contains @suaveplan/* deps: ${offending.join(", ")}`
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("devDependencies may contain @suaveplan/testing only (utilities for our own tests)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const ours = Object.keys(pkg.devDependencies ?? {}).filter((k) =>
|
||||
k.startsWith("@suaveplan/")
|
||||
);
|
||||
const allowed = new Set(["@suaveplan/testing"]);
|
||||
for (const dep of ours) {
|
||||
expect(
|
||||
allowed.has(dep),
|
||||
`unexpected @suaveplan/* devDep: ${dep}`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Spec-conformance: pins the dual-runner conditional-exports shape
|
||||
* declared in `package.json`. A regression that flips the `bun`
|
||||
* condition to a vitest-only barrel (or vice versa) would silently
|
||||
* break consumers running under `bun test`; this test catches it at
|
||||
* the JSON-shape level rather than at integration time.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(here, "..", "package.json");
|
||||
|
||||
interface ExportTarget {
|
||||
readonly types?: string;
|
||||
readonly default?: string;
|
||||
}
|
||||
|
||||
interface ExportsMap {
|
||||
readonly "."?: {
|
||||
readonly vitest?: ExportTarget;
|
||||
readonly bun?: ExportTarget;
|
||||
readonly default?: ExportTarget;
|
||||
};
|
||||
readonly "./polyfills"?: string | ExportTarget;
|
||||
readonly "./matchers"?: string | ExportTarget;
|
||||
readonly "./config"?: string | ExportTarget;
|
||||
readonly "./errors"?: string | ExportTarget;
|
||||
readonly "./render"?: string | ExportTarget;
|
||||
readonly "./queries"?: string | ExportTarget;
|
||||
readonly "./user"?: string | ExportTarget;
|
||||
readonly "./package.json"?: string;
|
||||
}
|
||||
|
||||
interface MinimalPkg {
|
||||
exports?: ExportsMap;
|
||||
}
|
||||
|
||||
async function loadPkg(): Promise<MinimalPkg> {
|
||||
return JSON.parse(await readFile(pkgPath, "utf8")) as MinimalPkg;
|
||||
}
|
||||
|
||||
describe("dual-runner conditional exports", () => {
|
||||
it("routes vitest + default to the index barrel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root, "exports['.'] missing").toBeDefined();
|
||||
expect(root?.vitest?.default).toBe("./dist/index.js");
|
||||
expect(root?.default?.default).toBe("./dist/index.js");
|
||||
});
|
||||
|
||||
it("routes the bun condition to the bun-test barrel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root?.bun?.default).toBe("./dist/runner/bun-test.js");
|
||||
});
|
||||
|
||||
it("vitest and default conditions share the same types entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root?.vitest?.types).toBe("./dist/index.d.ts");
|
||||
expect(root?.default?.types).toBe("./dist/index.d.ts");
|
||||
});
|
||||
|
||||
it("bun condition has its own types entry", { timeout: 5000 }, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root?.bun?.types).toBe("./dist/runner/bun-test.d.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subpath exports presence", () => {
|
||||
const REQUIRED_SUBPATHS = [
|
||||
"./polyfills",
|
||||
"./matchers",
|
||||
"./config",
|
||||
"./errors",
|
||||
"./render",
|
||||
"./queries",
|
||||
"./user",
|
||||
] as const;
|
||||
|
||||
it("declares every required subpath", { timeout: 5000 }, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const exportsMap = pkg.exports ?? {};
|
||||
for (const sub of REQUIRED_SUBPATHS) {
|
||||
expect(
|
||||
Object.hasOwn(exportsMap, sub),
|
||||
`package.json#exports is missing "${sub}"`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("polyfills + matchers resolve to side-effecting dist files", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const exportsMap = pkg.exports ?? {};
|
||||
const polyfills = exportsMap["./polyfills"];
|
||||
const matchers = exportsMap["./matchers"];
|
||||
expect(
|
||||
typeof polyfills === "string" ? polyfills : polyfills?.default
|
||||
).toBe("./dist/setup/polyfills.js");
|
||||
expect(
|
||||
typeof matchers === "string" ? matchers : matchers?.default
|
||||
).toBe("./dist/matchers/matchers.js");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Grep guard — fail if `src/` contains any disallowed construct:
|
||||
* `any`, `ts-ignore`, `ts-expect-error`, `TODO`, `FIXME`, `@deprecated`,
|
||||
* `.skip(`, `.only(`. Test fixtures and benchmarks are excluded.
|
||||
*/
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const srcRoot = resolve(here, "..", "src");
|
||||
|
||||
const PATTERNS: Array<{ name: string; regex: RegExp }> = [
|
||||
{ name: "any", regex: /(?<![a-zA-Z0-9_])any(?![a-zA-Z0-9_])/ },
|
||||
{ name: "ts-ignore", regex: /@ts-ignore/ },
|
||||
{ name: "ts-expect-error", regex: /@ts-expect-error/ },
|
||||
{ name: "TODO", regex: /\bTODO\b/ },
|
||||
{ name: "FIXME", regex: /\bFIXME\b/ },
|
||||
{ name: "@deprecated", regex: /@deprecated/ },
|
||||
{ name: ".skip(", regex: /\.skip\s*\(/ },
|
||||
{ name: ".only(", regex: /\.only\s*\(/ },
|
||||
];
|
||||
|
||||
const EXCLUDED_FILE_SUFFIXES = [".test.ts", ".test.tsx", ".bench.ts"];
|
||||
|
||||
const EXCLUDED_FROM_ANY_CHECK: readonly string[] = [
|
||||
// Mock helpers necessarily reach into untyped runner internals.
|
||||
"runner/mocks.ts",
|
||||
"runner/bun-test.ts",
|
||||
];
|
||||
|
||||
async function walk(dir: string, out: string[]): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full, out);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))
|
||||
) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed source guard", () => {
|
||||
it("src/ contains no disallowed constructs", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const all: string[] = [];
|
||||
await walk(srcRoot, all);
|
||||
const files = all.filter((file) => {
|
||||
const rel = relative(srcRoot, file).replace(/\\/g, "/");
|
||||
return !EXCLUDED_FILE_SUFFIXES.some((s) => rel.endsWith(s));
|
||||
});
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await readFile(file, "utf8");
|
||||
const lines = text.split("\n");
|
||||
const rel = relative(srcRoot, file).replace(/\\/g, "/");
|
||||
for (const { name, regex } of PATTERNS) {
|
||||
if (name === "any" && EXCLUDED_FROM_ANY_CHECK.includes(rel)) {
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("*") ||
|
||||
trimmed.startsWith("/*")
|
||||
)
|
||||
continue;
|
||||
if (regex.test(line)) {
|
||||
violations.push(
|
||||
`${rel}:${i + 1} contains "${name}": ${trimmed}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Side-effecting polyfills module for happy-dom / jsdom / bare-Node test
|
||||
* environments on WSL2 or older toolchains.
|
||||
*
|
||||
* Import via vitest `setupFiles`:
|
||||
*
|
||||
* ```ts
|
||||
* // vitest.config.ts
|
||||
* export default defineConfig({
|
||||
* test: {
|
||||
* setupFiles: ["@suaveplan/testing/polyfills"],
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Installs:
|
||||
* - `globalThis.crypto` (from `node:crypto`'s `webcrypto` export) if missing
|
||||
* - `globalThis.TextEncoder` / `TextDecoder` (usually present, covered defensively)
|
||||
*
|
||||
* Exports nothing. Safe to import multiple times; operations are idempotent.
|
||||
*/
|
||||
|
||||
import { webcrypto } from "node:crypto";
|
||||
import {
|
||||
TextDecoder as NodeTextDecoder,
|
||||
TextEncoder as NodeTextEncoder,
|
||||
} from "node:util";
|
||||
|
||||
type WritableGlobal = {
|
||||
crypto?: Crypto;
|
||||
TextEncoder?: typeof NodeTextEncoder;
|
||||
TextDecoder?: typeof NodeTextDecoder;
|
||||
};
|
||||
|
||||
const g = globalThis as unknown as WritableGlobal;
|
||||
|
||||
if (
|
||||
typeof g.crypto === "undefined" ||
|
||||
typeof g.crypto.getRandomValues !== "function"
|
||||
) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
|
||||
if (typeof g.TextEncoder === "undefined") {
|
||||
g.TextEncoder = NodeTextEncoder;
|
||||
}
|
||||
|
||||
if (typeof g.TextDecoder === "undefined") {
|
||||
g.TextDecoder = NodeTextDecoder;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Tree-shake guard — verify subpaths don't drag unrelated modules.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const distRoot = resolve(here, "..", "dist");
|
||||
|
||||
async function distExists(): Promise<boolean> {
|
||||
try {
|
||||
await readFile(resolve(distRoot, "index.js"), "utf8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readDist(rel: string): Promise<string> {
|
||||
return readFile(resolve(distRoot, rel), "utf8");
|
||||
}
|
||||
|
||||
describe("tree-shake guards", () => {
|
||||
it("matchers subpath does not pull renderComponent", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
if (!(await distExists())) return;
|
||||
const text = await readDist("matchers/matchers.js");
|
||||
expect(text).not.toMatch(/function\s+renderComponent\b/);
|
||||
expect(text).not.toMatch(/class\s+RenderError\b/);
|
||||
});
|
||||
|
||||
it("user/setup-user does not pull jest-dom matchers", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
if (!(await distExists())) return;
|
||||
const text = await readDist("user/setup-user.js");
|
||||
expect(text).not.toMatch(/toBeInTheDocument/);
|
||||
expect(text).not.toMatch(/jest-dom/);
|
||||
});
|
||||
|
||||
it("polyfills subpath has no functional surface", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
if (!(await distExists())) return;
|
||||
const text = await readDist("setup/polyfills.js");
|
||||
expect(text).not.toMatch(/export\s+(?:const|function|class)/);
|
||||
});
|
||||
});
|
||||
19
packages/testkit/testing/testing-dom/tsconfig.lib.json
Normal file
19
packages/testkit/testing/testing-dom/tsconfig.lib.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node", "bun"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
71
packages/testkit/testing/testing-dom/vite.config.ts
Normal file
71
packages/testkit/testing/testing-dom/vite.config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/testing-dom",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
// Multi-entry so every subpath export in package.json#exports
|
||||
// maps to a real emitted file. Modules not imported from the
|
||||
// root barrel (polyfills, matchers, config, runner variants)
|
||||
// are listed explicitly so Rollup keeps them.
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"setup/polyfills": "src/setup/polyfills.ts",
|
||||
"matchers/matchers": "src/matchers/matchers.ts",
|
||||
"config/index": "src/config/index.ts",
|
||||
"runner/vitest": "src/runner/vitest.ts",
|
||||
"runner/bun-test": "src/runner/bun-test.ts",
|
||||
"errors/index": "src/errors/index.ts",
|
||||
"render/index": "src/render/index.ts",
|
||||
"queries/queries": "src/queries/queries.ts",
|
||||
"user/setup-user": "src/user/setup-user.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@testing-library/dom",
|
||||
"@testing-library/jest-dom",
|
||||
"@testing-library/react",
|
||||
"@testing-library/user-event",
|
||||
"@vitejs/plugin-react",
|
||||
"bun:test",
|
||||
"dom-accessibility-api",
|
||||
"happy-dom",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"node:fs",
|
||||
"node:path",
|
||||
"node:url",
|
||||
"node:util",
|
||||
"node:crypto",
|
||||
"tslib",
|
||||
"vitest",
|
||||
"vitest/config",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user